commit c0a3b5c1dc4263f6b6a9869c370801abc7c94647 Author: Valérian Galliat Date: Sat Oct 16 09:05:52 2021 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d502512 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules +/package-lock.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..562a7ad --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Firefox Sync CLI + +> Manage Firefox Sync from the CLI! ✨ + +## Usage + +``` +Usage: ffs [options] [command] + +Options: + --version Show version. + -c, --creds File to get Firefox Sync creds from (or + write to during authentication and + refresh). + -v, --verbose Output more details. + -h, --help Show this screen. + +Commands: + auth [email] [password] Sign in using email and password. + oauth Sign in using OAuth. + collections List available collections. + get [options] [id...] Get some or all items from a collection. + When getting all items, pass '--full' to + get the full objects. + set Store one or multiple payloads from + 'stdin' in the given collection. + delete Delete the given items by ID. + quota Get the current usage and storage quota. + collection-usage Get the usage in kB of all collections. + collection-counts Get the number of items in each + collection. + configuration Get the storage server configuration. + help [command] Display help for command. +``` diff --git a/cli.js b/cli.js new file mode 100644 index 0000000..1f894bd --- /dev/null +++ b/cli.js @@ -0,0 +1,132 @@ +const { Command } = require('commander') +const Sync = require('firefox-sync') +const { version } = require('./package') + +const auth = { + password: require('./password'), + oauth: require('./oauth') +} + +const program = new Command() + +program + .helpOption('-h, --help', 'Show this screen.') + .version(version, '--version', 'Show version.') + .addHelpCommand('help [command]', 'Display help for command.') + .allowExcessArguments(false) + +program + .option('-c, --creds ', 'File to get Firefox Sync creds from (or write to during authentication and refresh).') + .option('-v, --verbose', 'Output more details.') + +program.hook('preAction', (program, command) => { + const credsFile = program.opts().creds + + const sync = Sync({ + credsFile, + oauthOptions: { + access_type: 'offline' + } + }) + + if (!['password', 'oauth'].includes(command.name())) { + if (!credsFile) { + program._displayError(1, 'sync.missingCreds', "error: missing creds, see '--creds'") + } + } + + // Inject Sync instance. + command.setOptionValue('sync', sync) +}) + +async function handleCreds (args, creds) { + if (!args['--creds']) { + log(creds) + return + } + + // Library alrady took care of this with `credsFile`. + console.error(`Wrote creds to '${args['--creds']}'`) +} + +program + .command('auth [email] [password]') + .description('Sign in using email and password.') + .action(async (email, password, options) => handleCreds(options, await auth.password(email, password, options))) + +program + .command('oauth') + .description('Sign in using OAuth.') + .action(async options => handleCreds(options, await auth.oauth(options))) + +function log (object) { + console.log(JSON.stringify(object, null, 2)) +} + +program + .command('collections') + .description('List available collections.') + .action(async options => log(await options.sync.getCollections())) + +program + .command('get [id...]') + .description("Get some or all items from a collection. When getting all items, pass '--full' to get the full objects.") + .option('--full', 'Retrieve full objects (implicit when selecting specific objects).') + .action(async (collection, ids, options) => { + const params = {} + + if (options.full) { + params.full = true + } + + if (ids.length > 0) { + params.full = true + params.ids = ids + } + + log(await options.sync.getCollection(collection, params)) + }) + +program + .command('set ') + .description("Store one or multiple payloads from 'stdin' in the given collection.") + .action((collection, id) => { + console.error(`Write methods not yet implemented! +Feel free to contribute at !`) + process.exit(1) + }) + +program + .command('delete ') + .description('Delete the given items by ID.') + .action((collection, id) => { + console.error(`Write methods not yet implemented! +Feel free to contribute at !`) + process.exit(1) + }) + +program + .command('quota') + .description('Get the current usage and storage quota.') + .action(async options => log(await options.sync.getQuota())) + +program + .command('collection-usage') + .description('Get the usage in kB of all collections.') + .action(async options => log(await options.sync.getCollectionUsage())) + +program + .command('collection-counts') + .description('Get the number of items in each collection.') + .action(async options => log(await options.sync.getCollectionCounts())) + +program + .command('configuration') + .description('Get the storage server configuration.') + .action(async options => log(await options.sync.getConfiguration())) + +function cli (argv) { + return program.parseAsync(argv) +} + +module.exports = cli diff --git a/ffs b/ffs new file mode 100755 index 0000000..83cbb28 --- /dev/null +++ b/ffs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +const cli = require('./cli') + +cli(process.argv) diff --git a/oauth.js b/oauth.js new file mode 100644 index 0000000..428e3a1 --- /dev/null +++ b/oauth.js @@ -0,0 +1,123 @@ +const fs = require('fs').promises +const path = require('path') +const os = require('os') +const sqlite3 = require('sqlite3') +const sqlite = require('sqlite') +const ini = require('ini') + +function getProfiles (dir, store) { + return Object.keys(store) + .filter(key => key.startsWith('Profile')) + .map(key => store[key]) + .map(profile => ({ + name: profile.Name, + path: path.resolve(dir, profile.Path) + })) +} + +async function getFirefoxProfilesImpl (dir) { + try { + return getProfiles(dir, ini.parse(await fs.readFile(path.resolve(dir, 'profiles.ini'), 'utf8'))) + } catch (err) { + if (err.code !== 'ENOENT') { + throw err + } + } +} + +async function getFirefoxProfiles () { + return await getFirefoxProfilesImpl(path.resolve(os.homedir(), '.mozilla/firefox')) || + getFirefoxProfilesImpl(path.resolve(os.homedir(), 'Library/Application Support/Firefox')) +} + +async function fetchLastRedirectImpl (profile) { + const db = await sqlite.open({ + filename: `file:${path.resolve(profile.path, 'places.sqlite')}?immutable=1`, + driver: sqlite3.Database, + mode: sqlite3.OPEN_READONLY | sqlite3.OPEN_URI + }) + + try { + const result = await db.get("SELECT url FROM moz_places WHERE url LIKE 'https://lockbox.firefox.com/fxa/android-redirect.html%' ORDER BY last_visit_date DESC LIMIT 1") + return result?.url + } finally { + await db.close() + } +} + +function fetchLastRedirect (profiles) { + return Promise.all(profiles.map(profile => fetchLastRedirectImpl(profile))) +} + +async function poll (profiles, initialState) { + while (true) { + await new Promise(resolve => setTimeout(resolve, 1000)) + + const state = await fetchLastRedirect(profiles) + + for (const [i, url] of state.entries()) { + if (initialState[i] !== url) { + console.error('\n') + return url + } + } + + process.stderr.write('.') + } +} + +async function oauth (options) { + const { sync, verbose } = options + const profiles = await getFirefoxProfiles() + + if (!profiles || profiles.length === 0) { + console.error(`Couldn't find Firefox directory. + +This method relies on parsing the Firefox history to retreive the OAuth +challenge response and cannot work right now. + +Consider using 'ffs auth password' as a fallback. +`) + process.exit(1) + } + + if (verbose) { + for (const profile of profiles) { + console.error(`[verbose] profile ${profile.name}`) + } + } + + const initialState = await fetchLastRedirect(profiles) + + if (verbose) { + for (const [i, url] of initialState.entries()) { + console.error(`[verbose] state [${profiles[i].name}] ${url}`) + } + + console.error() + } + + const challenge = await sync.auth.oauth.challenge() + + console.error(`Visit the following URL in Firefox to sign in: + + ${challenge.url} +`) + + console.error(`We'll detect the OAuth response automatically. +If it hangs, closing Firefox can sometimes help. +Otheriwse, consider using 'ffs auth password' as a fallback. +`) + + const url = await poll(profiles, initialState) + + if (verbose) { + console.error(`[verbose] detected ${url}`) + } + + const result = Object.fromEntries(new URL(url).searchParams) + + return sync.auth.oauth.complete(challenge, result) +} + +module.exports = oauth diff --git a/package.json b/package.json new file mode 100644 index 0000000..8ea125d --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "firefox-sync-cli", + "version": "0.0.0", + "description": "Manage Firefox Sync from the CLI! ✨", + "license": "Unlicense", + "author": "Val (https://val.codejam.info)", + "files": [ + "cli.js", + "ffs", + "oauth.js", + "password.js", + "prompt.js" + ], + "bin": { + "ffs": "ffs" + }, + "repository": "valeriangalliat/firefox-sync-cli", + "scripts": { + "lint": "standard" + }, + "dependencies": { + "commander": "^8.2.0", + "firefox-sync": "^1.0.0", + "ini": "^2.0.0", + "prompts": "^2.4.2", + "sqlite": "^4.0.23", + "sqlite3": "^5.0.2" + }, + "devDependencies": { + "standard": "^16.0.4" + } +} diff --git a/password.js b/password.js new file mode 100644 index 0000000..26f86b3 --- /dev/null +++ b/password.js @@ -0,0 +1,10 @@ +const prompt = require('./prompt') + +async function password (email, password, options) { + email = email || await prompt({ type: 'text', message: 'Email:' }) + password = password || await prompt({ type: 'password', message: 'Password:' }) + + return options.sync.auth.password(email, password) +} + +module.exports = password diff --git a/prompt.js b/prompt.js new file mode 100644 index 0000000..956519c --- /dev/null +++ b/prompt.js @@ -0,0 +1,13 @@ +const prompts = require('prompts') + +async function prompt (params) { + const res = await prompts({ ...params, name: 'value', stdout: process.stderr }) + + if (!res.value) { + process.exit() + } + + return res.value +} + +module.exports = prompt