Initial commit

This commit is contained in:
Valérian Galliat 2021-10-16 09:05:52 -04:00
commit c0a3b5c1dc
8 changed files with 351 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/node_modules
/package-lock.json

34
README.md Normal file
View File

@ -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> 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] <collection> [id...] Get some or all items from a collection.
When getting all items, pass '--full' to
get the full objects.
set <collection> Store one or multiple payloads from
'stdin' in the given collection.
delete <collection> <id...> 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.
```

132
cli.js Normal file
View File

@ -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>', '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 <collection> [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 <collection>')
.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 <https://github.com/valeriangalliat/firefox-sync-cli>!`)
process.exit(1)
})
program
.command('delete <collection> <id...>')
.description('Delete the given items by ID.')
.action((collection, id) => {
console.error(`Write methods not yet implemented!
Feel free to contribute at <https://github.com/valeriangalliat/firefox-sync-cli>!`)
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

5
ffs Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env node
const cli = require('./cli')
cli(process.argv)

123
oauth.js Normal file
View File

@ -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

32
package.json Normal file
View File

@ -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"
}
}

10
password.js Normal file
View File

@ -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

13
prompt.js Normal file
View File

@ -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