|
#!/usr/bin/env node |
|
/** |
|
* ft-sync-linux.mjs |
|
* Extracts X/Twitter cookies from Chrome/Brave on Linux via GNOME Secret Service, |
|
* then calls fieldtheory's syncBookmarksGraphQL directly. |
|
*/ |
|
|
|
import { pbkdf2Sync, createDecipheriv } from 'node:crypto'; |
|
import { copyFileSync, existsSync, unlinkSync, readFileSync } from 'node:fs'; |
|
import { join } from 'node:path'; |
|
import { tmpdir, homedir } from 'node:os'; |
|
import { execSync } from 'node:child_process'; |
|
import { randomUUID } from 'node:crypto'; |
|
|
|
// ── Config ────────────────────────────────────────────────────────────────── |
|
|
|
const BROWSERS = [ |
|
{ |
|
name: 'Chrome', |
|
cookieDb: join(homedir(), '.config/google-chrome/Default/Cookies'), |
|
secretApp: 'chrome', |
|
}, |
|
{ |
|
name: 'Brave', |
|
cookieDb: join(homedir(), '.config/BraveSoftware/Brave-Browser/Default/Cookies'), |
|
secretApp: 'brave', |
|
}, |
|
]; |
|
|
|
// ── Secret Service ─────────────────────────────────────────────────────────── |
|
|
|
function getKeyFromSecretService(appName) { |
|
// Single-line Python — avoids whitespace issues with -c |
|
const py = [ |
|
'import dbus,sys', |
|
'bus=dbus.SessionBus()', |
|
'svc=bus.get_object("org.freedesktop.secrets","/org/freedesktop/secrets")', |
|
'iface=dbus.Interface(svc,"org.freedesktop.Secret.Service")', |
|
'_,sess=iface.OpenSession("plain",dbus.String("",variant_level=1))', |
|
'col=bus.get_object("org.freedesktop.secrets","/org/freedesktop/secrets/aliases/default")', |
|
'ci=dbus.Interface(col,"org.freedesktop.Secret.Collection")', |
|
`r=ci.SearchItems({"application":"${appName}"}) or ci.SearchItems({"xdg:schema":"${appName}_libsecret_os_crypt_password_v2"})`, |
|
'item=bus.get_object("org.freedesktop.secrets",str(r[0]))', |
|
'ii=dbus.Interface(item,"org.freedesktop.Secret.Item")', |
|
's=ii.GetSecret(sess)', |
|
'sys.stdout.write(bytes(s[2]).decode("utf-8"))', |
|
].join(';'); |
|
try { |
|
return execSync(`python3 -c '${py}'`, { |
|
encoding: 'utf8', |
|
stdio: ['pipe', 'pipe', 'pipe'], |
|
timeout: 5000, |
|
}).trim(); |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
// ── Cookie decryption ──────────────────────────────────────────────────────── |
|
|
|
function deriveKey(password) { |
|
// Linux: 1 iteration (macOS uses 1003) |
|
return pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1'); |
|
} |
|
|
|
function decryptValue(hexVal, key) { |
|
if (!hexVal || hexVal.length === 0) return ''; |
|
const buf = Buffer.from(hexVal, 'hex'); |
|
if (buf[0] !== 0x76) return buf.toString('utf8'); // unencrypted |
|
const ciphertext = buf.subarray(3); // strip v10/v11 prefix |
|
const iv = Buffer.alloc(16, 0x20); |
|
const decipher = createDecipheriv('aes-128-cbc', key, iv); |
|
let dec = decipher.update(ciphertext); |
|
dec = Buffer.concat([dec, decipher.final()]); |
|
// Chrome 130+ prepends SHA256(host_key) — strip 32 bytes |
|
if (dec.length > 32) { |
|
const candidate = dec.subarray(32).toString('utf8').replace(/\0+$/, '').trim(); |
|
if (/^[\x21-\x7E]+$/.test(candidate)) return candidate; |
|
} |
|
return dec.toString('utf8').replace(/\0+$/, '').trim(); |
|
} |
|
|
|
// ── SQL via sql.js ─────────────────────────────────────────────────────────── |
|
|
|
async function queryCookies(dbPath) { |
|
const { default: initSqlJs } = await import( |
|
'/home/anthony/n/lib/node_modules/fieldtheory/node_modules/sql.js/dist/sql-asm.js' |
|
); |
|
const SQL = await initSqlJs(); |
|
|
|
let buf; |
|
try { |
|
buf = readFileSync(dbPath); |
|
} catch { |
|
// DB locked — copy first |
|
const tmp = join(tmpdir(), `ft-cookies-${randomUUID()}.db`); |
|
copyFileSync(dbPath, tmp); |
|
try { buf = readFileSync(tmp); } finally { try { unlinkSync(tmp); } catch {} } |
|
} |
|
|
|
const db = new SQL.Database(buf); |
|
const results = db.exec( |
|
`SELECT name, hex(encrypted_value) as ev, value FROM cookies |
|
WHERE (host_key LIKE '%.x.com' OR host_key LIKE '%.twitter.com') |
|
AND name IN ('ct0', 'auth_token')` |
|
); |
|
db.close(); |
|
|
|
if (!results.length) return []; |
|
return results[0].values.map(([name, ev, value]) => ({ name, ev, value })); |
|
} |
|
|
|
// ── Main ───────────────────────────────────────────────────────────────────── |
|
|
|
async function extractCookies(browser) { |
|
if (!existsSync(browser.cookieDb)) return null; |
|
|
|
const rows = await queryCookies(browser.cookieDb); |
|
if (!rows.length) { |
|
console.error(` No X.com cookies found in ${browser.name}.`); |
|
return null; |
|
} |
|
|
|
const passwords = []; |
|
const keyringPass = getKeyFromSecretService(browser.secretApp); |
|
if (keyringPass) passwords.push(keyringPass); |
|
passwords.push('peanuts'); |
|
|
|
for (const password of passwords) { |
|
const key = deriveKey(password); |
|
const decrypted = {}; |
|
let failed = false; |
|
for (const row of rows) { |
|
try { |
|
const val = row.ev ? decryptValue(row.ev, key) : row.value; |
|
if (!val || !/^[\x21-\x7E]+$/.test(val)) { failed = true; break; } |
|
decrypted[row.name] = val; |
|
} catch { |
|
failed = true; |
|
break; |
|
} |
|
} |
|
if (!failed && decrypted.ct0 && decrypted.auth_token) { |
|
return decrypted; |
|
} |
|
} |
|
return null; |
|
} |
|
|
|
async function main() { |
|
const args = process.argv.slice(2); |
|
const full = args.includes('--full'); |
|
|
|
let cookies = null; |
|
let browserName = null; |
|
|
|
for (const browser of BROWSERS) { |
|
console.log(` Trying ${browser.name}...`); |
|
cookies = await extractCookies(browser); |
|
if (cookies) { browserName = browser.name; break; } |
|
} |
|
|
|
if (!cookies) { |
|
console.error(` |
|
Could not extract X.com cookies from Chrome or Brave. |
|
|
|
Make sure you are logged into x.com in one of those browsers, |
|
and that GNOME Keyring is unlocked. |
|
`); |
|
process.exit(1); |
|
} |
|
|
|
console.log(` Got cookies from ${browserName}.`); |
|
console.log(` Starting sync...\n`); |
|
|
|
const { syncBookmarksGraphQL } = await import( |
|
'/home/anthony/n/lib/node_modules/fieldtheory/dist/graphql-bookmarks.js' |
|
); |
|
|
|
const csrfToken = cookies.ct0; |
|
const cookieHeader = `auth_token=${cookies.auth_token}; ct0=${cookies.ct0}`; |
|
|
|
const result = await syncBookmarksGraphQL({ |
|
incremental: !full, |
|
csrfToken, |
|
cookieHeader, |
|
onProgress: (status) => { |
|
process.stderr.write(`\r Page ${status.page} — ${status.newAdded} new bookmarks...`); |
|
if (status.done) process.stderr.write('\n'); |
|
}, |
|
}); |
|
|
|
console.log(`\n ✓ ${result.added} new bookmarks synced (${result.totalBookmarks} total)`); |
|
console.log(` Stop reason: ${result.stopReason}`); |
|
|
|
if (result.added > 0) { |
|
console.log(` Run 'ft index' to rebuild the search index.`); |
|
} |
|
} |
|
|
|
main().catch((err) => { |
|
console.error(`\n Error: ${err.message}\n`); |
|
process.exit(1); |
|
}); |