From c0a3b5c1dc4263f6b6a9869c370801abc7c94647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=C3=A9rian=20Galliat?= Date: Sat, 16 Oct 2021 09:05:52 -0400 Subject: [PATCH] Initial commit --- .gitignore | 2 + README.md | 34 +++++++++++++ cli.js | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++ ffs | 5 ++ oauth.js | 123 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 32 +++++++++++++ password.js | 10 ++++ prompt.js | 13 +++++ 8 files changed, 351 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cli.js create mode 100755 ffs create mode 100644 oauth.js create mode 100644 package.json create mode 100644 password.js create mode 100644 prompt.js 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