From 81f66a15de69114205e48149460142af60600508 Mon Sep 17 00:00:00 2001 From: Boris Mann Date: Thu, 2 Jul 2026 11:46:45 -0700 Subject: [PATCH] Add OAuth login flow with 127.0.0.1 loopback callback; keep app-password auth as fallback --- .env.example | 14 +- .gitignore | 2 + README.md | 27 +- package-lock.json | 623 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 3 + src/assets.ts | 65 ++--- src/atproto.ts | 260 +++++++++++++++---- src/config.ts | 28 ++- src/index.ts | 48 ++-- src/oauth.ts | 109 ++++++++ 10 files changed, 1051 insertions(+), 128 deletions(-) create mode 100644 src/oauth.ts diff --git a/.env.example b/.env.example index 7c9dbd6..0cea246 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,16 @@ GHOST_URL=https://your-ghost-site.com GHOST_API_KEY=your-ghost-content-api-key +ATPUBLICATION_AT_URI=at://did:plc:.../site.standard.publication/... + +# Auth: OAuth is the default. Set only if you want password auth. +# For OAuth, ATP_IDENTIFIER is optional on first run (you'll be prompted). +# For password auth, both ATP_IDENTIFIER and ATP_APP_PASSWORD are required. ATP_IDENTIFIER=your-handle-or-did -ATP_APP_PASSWORD=your-app-password +# ATP_APP_PASSWORD=your-app-password + +# ATP_SERVICE only matters for password auth. Leave as-is for OAuth. ATP_SERVICE=https://bsky.social -ATPUBLICATION_AT_URI=at://did:plc:.../site.standard.publication/... + +# Optional OAuth configuration +# GHOSTOFF_OAUTH_CLIENT_ID=http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto%20transition%3Ageneric +# GHOSTOFF_OAUTH_SCOPE=atproto transition:generic diff --git a/.gitignore b/.gitignore index 1b40598..e6d8377 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ node_modules/ ghostoff-export/ ghostoff-session.json ghostoff-state.json +ghostoff-oauth-session.json +ghostoff-oauth-state.json # Build artifacts dist/ diff --git a/README.md b/README.md index 2dce570..77b549e 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,11 @@ cp .env.example .env ```env GHOST_URL=https://your-ghost-site.com GHOST_API_KEY=your-ghost-content-api-key -ATP_IDENTIFIER=your-handle-or-did -ATP_APP_PASSWORD=your-app-password -ATP_SERVICE=https://selfhosted.social ATPUBLICATION_AT_URI=at://did:plc:.../site.standard.publication/... + +# Auth: OAuth is the default. Set only if you want password auth. +ATP_IDENTIFIER=your-handle-or-did +# ATP_APP_PASSWORD=your-app-password ``` Run a dry first pass to inspect what would be uploaded: @@ -36,7 +37,15 @@ npm install npm run dev -- --dry-run ``` -When you're ready, run the real migration: +When you're ready, run the real migration with OAuth (default): + +```bash +npm run dev +``` + +If OAuth is not configured and you haven't set `ATP_IDENTIFIER`, the CLI will prompt for your handle and open a browser to `127.0.0.1` for authentication. + +To use an app password instead, set `ATP_APP_PASSWORD`: ```bash npm run dev @@ -52,11 +61,16 @@ Rerunning the same command will `putRecord` existing records instead of creating | `--ghost-api-key` | `GHOST_API_KEY` | Ghost Content API key | | `--atproto-identifier` | `ATP_IDENTIFIER` | atproto handle or DID | | `--atproto-app-password` | `ATP_APP_PASSWORD` | atproto app password | -| `--atproto-service` | `ATP_SERVICE` | PDS/service URL | +| `--atproto-service` | `ATP_SERVICE` | PDS/service URL (password auth only) | | `--publication-at-uri` | `ATPUBLICATION_AT_URI` | Existing `site.standard.publication` AT-URI | +| `--oauth-client-id` | `GHOSTOFF_OAUTH_CLIENT_ID` | OAuth client_id URL (dev default: `http://localhost`) | +| `--oauth-scope` | `GHOSTOFF_OAUTH_SCOPE` | OAuth scope (default: `atproto transition:generic`) | +| `--oauth-session-file` | — | OAuth session cache (default: `ghostoff-oauth-session.json`) | +| `--oauth-state-file` | — | OAuth transient state cache (default: `ghostoff-oauth-state.json`) | | `--dry-run` | — | Build records without uploading | | `--export-dir` | — | Local asset cache directory (default: `ghostoff-export`) | | `--state-file` | — | Idempotency state file (default: `ghostoff-state.json`) | +| `--session-file` | — | Password session cache (default: `ghostoff-session.json`) | | `--verbose` | — | Verbose logging | ## Scripts @@ -77,7 +91,8 @@ src/ ├── assets.ts # image download, cache, resize, blob upload ├── html-to-offprint.ts # Ghost HTML -> Offprint blocks ├── facets.ts # inline formatting -> richtext facets -├── atproto.ts # auth, record create/put, validation +├── atproto.ts # auth adapters (OAuth + password), record ops, validation +├── oauth.ts # loopback OAuth server and file-backed stores ├── state.ts # idempotency state file ├── rate-limit.ts # retry + rate-limit helpers └── types.ts # shared type definitions diff --git a/package-lock.json b/package-lock.json index 8d56cfc..80fe3dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,12 @@ "@atcute/lexicons": "^2.0.2", "@atcute/password-session": "^1.0.1", "@atcute/standard-site": "^2.0.2", + "@atproto/api": "^0.20.25", + "@atproto/oauth-client-node": "^0.4.5", "commander": "^12.1.0", "dotenv": "^16.4.7", "linkedom": "^0.18.9", + "open": "^11.0.0", "sharp": "^0.33.5" }, "devDependencies": { @@ -117,6 +120,338 @@ "unicode-segmenter": "^0.14.5" } }, + "node_modules/@atproto-labs/did-resolver": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.3.4.tgz", + "integrity": "sha512-nBECoVG59NfbYthayxfR0s1dLFVdN+RKMaL0p0uaNX/U1SAETksN6Yydmr4oWOKSkyyU3GO9XZeo1yzwHnRcZw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "^0.3.3", + "@atproto-labs/pipe": "^0.2.3", + "@atproto-labs/simple-store": "^0.4.3", + "@atproto-labs/simple-store-memory": "^0.2.3", + "@atproto/did": "^0.5.3", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/fetch": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.3.3.tgz", + "integrity": "sha512-2gABLf0VEI86Sxo6YhGKQYK1tGhTZ4y+3TrCWputnupEZxuS/hw6+pFw9ceXZ3v9nnMNthzsAEcaAQ3V/tXsqg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/pipe": "^0.2.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/fetch-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.3.4.tgz", + "integrity": "sha512-OTzgMG58RgZAnaX6OITh9qEW8kOeNvM8O5aJi9dHlK78AqW7OLhtJZnh1aIIkrJRhRYazNfegTVSdhlNn1TJ1A==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "^0.3.3", + "@atproto-labs/pipe": "^0.2.3", + "ipaddr.js": "^2.1.0", + "undici_v6": "npm:undici@^6.x", + "undici_v7": "npm:undici@^7.x", + "undici_v8": "npm:undici@^8.x" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/handle-resolver": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.4.4.tgz", + "integrity": "sha512-fQIcAQrsqmixI0Nt/3RRfBr/NLeK58lMFVCtlLoenjVOiVmI1xPBKvfktYTY3yvHcvMbo07r/RiE9ktG9nfCLQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "^0.4.3", + "@atproto-labs/simple-store-memory": "^0.2.3", + "@atproto/did": "^0.5.3", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/handle-resolver-node": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver-node/-/handle-resolver-node-0.2.4.tgz", + "integrity": "sha512-2Vgu4ySIX5zEU6iDQtCdmap3byEogaAi4AmtH8qqU5WsFsjji1v7UbP4fYj4A1Gr5Htvza2YJ4vLNvg+DunRSA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch-node": "^0.3.4", + "@atproto-labs/handle-resolver": "^0.4.4", + "@atproto/did": "^0.5.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/identity-resolver": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.4.3.tgz", + "integrity": "sha512-3VfQxHA+p/+FlvusCcYYJR6GFrA0qYWgNlwfCnCbW7VaEWl2Ztf3jAbaa5ZHYNu1WBJ0hMhuYC67zgEI+dSZfQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.4", + "@atproto-labs/handle-resolver": "^0.4.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/pipe": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.2.3.tgz", + "integrity": "sha512-hsjkaKGdhEGKhXuOOfIeYyZSQZfw007AXQJak/QTd9pLY1wHUJG85a9+u13xoMeav95gHkBRzswti7+kqsxgdw==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/simple-store": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.4.3.tgz", + "integrity": "sha512-ML1HAEtQIixkcU4FCGbZtAkoHTfmXDi63tNVuSSPG/cVUKyrUURg6Cr1bcfvbbkMTwRj9abEwa3JZR7UUYi4Ew==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/simple-store-memory": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.2.3.tgz", + "integrity": "sha512-RtL48op8Db/QFNbW+9Y+Os6DOMqysOkoG5QelTZ0qcZt/j0pUBY8v2zSr5/faVJ5Y30h1cONxMydV48tVWf8pg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "^0.4.3", + "lru-cache": "^10.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/api": { + "version": "0.20.25", + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.20.25.tgz", + "integrity": "sha512-PTwt6X0U45C9vikr8NRi0Qzcep9VqJet52HtfcxgG9R7ofRHxqLVPzfVuN/f2xOaYM6H5IG/Nk1E9kXZcI0mSQ==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.5.3", + "@atproto/lexicon": "^0.7.4", + "@atproto/syntax": "^0.6.4", + "@atproto/xrpc": "^0.8.3", + "await-lock": "^3.0.0", + "multiformats": "^13.0.0", + "tlds": "^1.234.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/common-web": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.5.3.tgz", + "integrity": "sha512-FkMhOcNv1y7r5984+zXB+uMN4zJ4QFLEbZrYyQo6bNCf/Kfav/pEHXXENlaZEOweq2NYXf+tr2giMssBuM9u5A==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.4", + "@atproto/lex-json": "^0.1.3", + "@atproto/syntax": "^0.6.4", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/did": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.5.3.tgz", + "integrity": "sha512-nKcdu5qB9iNJFaBeQOQUJ+6mA1npFcZ3DmD0xB+etkMRd/3UvOQql/CvcMZaYzl+eGoVs1JDdEsvoZz+idfhaw==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/jwk": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.7.3.tgz", + "integrity": "sha512-YK0rObYOZ4GphDvNtLyHnq6Z4sapSUESLKU6ty+uoAwK20NKBHep1rNfmiD0IurgZ2jnd3K9Rii49R51Qj4TEg==", + "license": "MIT", + "dependencies": { + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/jwk-jose": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.2.3.tgz", + "integrity": "sha512-y6V+G9qwsKwmMs7eIpSFJy62S/e6oEiGxnhysLPZ2S8m86/Ps/6vqEjTZqYttlc2NufnJnjzS7XQxY40Q0rVsg==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "^0.7.3", + "jose": "^5.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/jwk-webcrypto": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.3.3.tgz", + "integrity": "sha512-yOLro2nh8IFjLpN7VQP82NXLhlOcXrglU8mTJn1vHHjdv8M3ii8e9/ePAlR46cwBfCp0qc8kopcGp9wEjDcn9A==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "^0.7.3", + "@atproto/jwk-jose": "^0.2.3", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/lex-data": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.1.4.tgz", + "integrity": "sha512-f9U95sk0zUtxHktvK59peU+Shd0cIUYV68p//GS7sfdkAxUIeaspvX6CY+Quv9Oa4aozmsXI52SjaNn1wuU8RQ==", + "license": "MIT", + "dependencies": { + "multiformats": "^13.0.0", + "tslib": "^2.8.1", + "uint8arrays": "^5.0.0", + "unicode-segmenter": "^0.14.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/lex-json": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.1.3.tgz", + "integrity": "sha512-Ch2w9bCLFOwWINFFxpZo6DBW7+ZxkehciU3P8N94gSyENLe3/5WWXHdHvkiH7EXZrpI7fKY8nVWn4GIL0ttqxw==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/lexicon": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.7.4.tgz", + "integrity": "sha512-ulk4RGwMBp4vbkTOZcIwZJeTEPlj3PImOnx9TqxSxMDnBdySxarpd0Aprg7+iAvGyKTsMcrYHpeoLukZaquk0A==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.5.3", + "@atproto/syntax": "^0.6.4", + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.7.7.tgz", + "integrity": "sha512-G9KEHtAaLhQsnIcriPULsEITC32tnjl5TnRYpMtxXj6A2rlMsgbfftC4pzG7g/v1X/Ap8VoSBZaNsSQ5PQ9xzQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.4", + "@atproto-labs/fetch": "^0.3.3", + "@atproto-labs/handle-resolver": "^0.4.4", + "@atproto-labs/identity-resolver": "^0.4.3", + "@atproto-labs/simple-store": "^0.4.3", + "@atproto-labs/simple-store-memory": "^0.2.3", + "@atproto/did": "^0.5.3", + "@atproto/jwk": "^0.7.3", + "@atproto/oauth-types": "^0.7.4", + "@atproto/xrpc": "^0.8.3", + "core-js": "^3", + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-node": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.4.5.tgz", + "integrity": "sha512-AdhqrjbOmkTKVSI87Ult+50F8DIZYMOYieAiAjQE3z5u/wmL8MgFZ9b5VXzo0tDp9yteftjTm2tAQ+PPXf3xHg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.4", + "@atproto-labs/handle-resolver-node": "^0.2.4", + "@atproto-labs/simple-store": "^0.4.3", + "@atproto/did": "^0.5.3", + "@atproto/jwk": "^0.7.3", + "@atproto/jwk-jose": "^0.2.3", + "@atproto/jwk-webcrypto": "^0.3.3", + "@atproto/oauth-client": "^0.7.7", + "@atproto/oauth-types": "^0.7.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-types": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.7.4.tgz", + "integrity": "sha512-LI6fTaj+G3uPptKADJyiQl36qCFgpBUAzAzCCJUOgavdTq2p3ZY+lz/YjwZjyXcef8fEr8LPT9cMDbHhrvs0og==", + "license": "MIT", + "dependencies": { + "@atproto/did": "^0.5.3", + "@atproto/jwk": "^0.7.3", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/syntax": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.6.4.tgz", + "integrity": "sha512-ELgpShRGMF65cvLXcoMCZFL0HBzy67yz/Nlhox3yOtTh120B09KjjvZYXhMysVog3nUJqv2EW5rf1VRYCeM/hw==", + "license": "MIT", + "dependencies": { + "iso-datestring-validator": "^2.2.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/xrpc": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.8.3.tgz", + "integrity": "sha512-0gUGN71+bSqV3PI5U4cQ4fdnw4nI0eD6czHDQ6jB7hMYBwNcJpwAyfA9W+C1G1wayIvP0SIap/M0H/pSKDWFIQ==", + "license": "MIT", + "dependencies": { + "@atproto/lexicon": "^0.7.4", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", @@ -946,12 +1281,33 @@ "undici-types": "~6.21.0" } }, + "node_modules/await-lock": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-3.0.0.tgz", + "integrity": "sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA==", + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -1002,6 +1358,17 @@ "node": ">=18" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -1036,6 +1403,46 @@ "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1224,12 +1631,96 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-arrayish": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/iso-datestring-validator": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", + "license": "MIT" + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/linkedom": { "version": "0.18.12", "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", @@ -1254,6 +1745,18 @@ } } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -1266,6 +1769,50 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1326,12 +1873,20 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.22.4", @@ -1372,6 +1927,45 @@ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", "license": "ISC" }, + "node_modules/uint8arrays": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.1.tgz", + "integrity": "sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.0.0" + } + }, + "node_modules/undici_v6": { + "name": "undici", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici_v7": { + "name": "undici", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici_v8": { + "name": "undici", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.6.0.tgz", + "integrity": "sha512-l2FlC6I510GawyEd1qgcE/okihKrzy+BRTEBlu6T0fdbM9m5yxtIH5Oa3ysRsH0zC4EhmWUEaSDsy2QngBeRlw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -1398,6 +1992,31 @@ "optional": true } } + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 61f64fa..977236a 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,12 @@ "@atcute/lexicons": "^2.0.2", "@atcute/password-session": "^1.0.1", "@atcute/standard-site": "^2.0.2", + "@atproto/api": "^0.20.25", + "@atproto/oauth-client-node": "^0.4.5", "commander": "^12.1.0", "dotenv": "^16.4.7", "linkedom": "^0.18.9", + "open": "^11.0.0", "sharp": "^0.33.5" }, "devDependencies": { diff --git a/src/assets.ts b/src/assets.ts index 62d70d7..ad82c80 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -5,7 +5,8 @@ import sharp from 'sharp'; import type { BlobRef } from './types.js'; import type { Logger } from './logger.js'; import { writeFile, readFile, stat } from 'node:fs/promises'; -import { respectRateLimit, shouldRetry, backoffDelay, parseRateLimitHeaders, sleep } from './rate-limit.js'; +import { sleep, backoffDelay } from './rate-limit.js'; +import type { AtprotoSession } from './atproto.js'; export interface AssetInfo { url: string; @@ -22,7 +23,7 @@ export interface AssetState { height?: number; } -const BLOB_SIZE_LIMIT = 900_000; // stay comfortably under the 1 MB atproto limit +const BLOB_SIZE_LIMIT = 900_000; export function resolveUrl(base: string, url: string): string { if (!url) return url; @@ -67,7 +68,7 @@ export async function downloadImage(url: string, localPath: string, log: Logger) log.debug(`using cached image: ${url}`); return readFile(localPath); } catch { - // not cached, continue to download + // not cached } const response = await fetch(url, { @@ -89,12 +90,10 @@ async function processImageBuffer(buffer: Buffer, mimeType: string, log: Logger) let width = metadata.width ?? 1; let height = metadata.height ?? 1; - // Resize very large images first if (width > 2048 || height > 2048) { image = image.resize({ width: 2048, height: 2048, fit: 'inside', withoutEnlargement: true }); } - // Use JPEG for the blob unless the source is a PNG that is already small const targetFormat = mimeType === 'image/png' && buffer.length <= BLOB_SIZE_LIMIT ? 'png' : 'jpeg'; for (let attempt = 0; attempt < 10; attempt++) { @@ -116,7 +115,6 @@ async function processImageBuffer(buffer: Buffer, mimeType: string, log: Logger) }; } - // If still too large, shrink the dimensions image = image.resize({ width: Math.max(400, Math.round(width * 0.7)), height: Math.max(400, Math.round(height * 0.7)), fit: 'inside' }); const nextMeta = await image.metadata(); width = nextMeta.width ?? width; @@ -131,8 +129,7 @@ export async function getOrUploadImage( base: string, exportDir: string, stateAssets: Record, - accessJwt: string, - service: string, + session: AtprotoSession, log: Logger ): Promise { const resolved = resolveUrl(base, url); @@ -172,52 +169,30 @@ export async function getOrUploadImage( } log.debug(`uploading blob for ${resolved} (${processed.buffer.length} bytes)`); - const uploadUrl = new URL('/xrpc/com.atproto.repo.uploadBlob', service); - let response!: Response; + let blobRef: BlobRef | undefined; for (let attempt = 0; attempt < 3; attempt++) { - response = await fetch(uploadUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessJwt}`, - 'Content-Type': processed.mimeType, - Accept: 'application/json', - }, - body: new Uint8Array(processed.buffer), - }); - - if (response.ok) break; - - const text = await response.text().catch(() => response.statusText); - log.warn(`blob upload attempt ${attempt + 1} failed (${resolved}): ${response.status} ${text}`); - - if (attempt < 2 && shouldRetry(response.status)) { - await respectRateLimit(response, log, 'uploadBlob'); - await sleep(backoffDelay(attempt)); - continue; + try { + blobRef = await session.uploadBlob(processed.buffer, processed.mimeType, log); + break; + } catch (err: any) { + log.warn(`blob upload attempt ${attempt + 1} failed (${resolved}): ${err?.message ?? err}`); + if (attempt < 2) { + await sleep(backoffDelay(attempt)); + continue; + } + log.error(`blob upload failed (${resolved}): ${err?.message ?? err}`); + return undefined; } - log.error(`blob upload failed (${resolved}): ${response.status} ${text}`); - return undefined; } - if (!response.ok) { + if (!blobRef) { return undefined; } - const json = (await response.json()) as { blob: BlobRef }; - if (!json.blob) { - log.error(`blob upload returned no blob ref (${resolved})`); - return undefined; - } - - const rateInfo = parseRateLimitHeaders(response.headers); - if (rateInfo?.remaining !== undefined) { - log.debug(`uploadBlob rate limit: ${rateInfo.remaining}/${rateInfo.limit} remaining`); - } - stateAssets[resolved] = { url: resolved, - blob: json.blob, + blob: blobRef, width: processed.width, height: processed.height, }; @@ -225,7 +200,7 @@ export async function getOrUploadImage( return { url: resolved, localPath, - blob: json.blob, + blob: blobRef, width: processed.width, height: processed.height, }; diff --git a/src/atproto.ts b/src/atproto.ts index ae7fd0b..51f8a13 100644 --- a/src/atproto.ts +++ b/src/atproto.ts @@ -1,23 +1,41 @@ +import { randomBytes } from 'node:crypto'; +import { readFile, writeFile, chmod } from 'node:fs/promises'; import { Client, retryFetchHandler } from '@atcute/client'; import { PasswordSession } from '@atcute/password-session'; import type { PasswordSessionData } from '@atcute/password-session'; import { parseResourceUri } from '@atcute/lexicons/syntax'; import { safeParse } from '@atcute/lexicons'; import { SiteStandardDocument } from '@atcute/standard-site'; -import { readFile, writeFile } from 'node:fs/promises'; +import { NodeOAuthClient, OAuthSession } from '@atproto/oauth-client-node'; import type { Config } from './config.js'; import type { BlobRef } from './types.js'; import type { Logger } from './logger.js'; +import { + createFileSessionStore, + createFileStateStore, + promptForHandle, + startLoopbackCallbackServer, +} from './oauth.js'; export type { BlobRef }; export interface AtprotoSession { - client: Client; did: string; handle: string; - accessJwt: string; service: string; - passwordSession: PasswordSession; + client: Client; + uploadBlob(buffer: Buffer, mimeType: string, log: Logger): Promise; +} + +const LOCAL_REDIRECT_PATH = '/callback'; +const LOCAL_REDIRECT_URI = 'http://127.0.0.1/callback'; + +function localRedirectUri(port: number): string { + return `http://127.0.0.1:${port}${LOCAL_REDIRECT_PATH}`; +} + +function buildLocalClientId(scope: string): string { + return `http://localhost/?redirect_uri=${encodeURIComponent(LOCAL_REDIRECT_URI)}&scope=${encodeURIComponent(scope)}`; } async function readSessionFile(path: string): Promise { @@ -30,15 +48,104 @@ async function readSessionFile(path: string): Promise { - await writeFile(path, JSON.stringify(data, null, 2), 'utf-8'); + await writeFile(path, JSON.stringify(data, null, 2), { mode: 0o600 }); } -export async function getSession(config: Config, log: Logger): Promise { +class PasswordSessionAdapter implements AtprotoSession { + private passwordSession: PasswordSession; + client: Client; + did: string; + handle: string; + service: string; + + constructor(passwordSession: PasswordSession) { + this.passwordSession = passwordSession; + this.client = new Client({ + handler: retryFetchHandler({ handler: passwordSession, maxRetries: 3 }), + }); + this.did = passwordSession.did; + this.handle = passwordSession.session.handle; + this.service = passwordSession.session.service; + } + + async uploadBlob(buffer: Buffer, mimeType: string, log: Logger): Promise { + const url = new URL('/xrpc/com.atproto.repo.uploadBlob', this.passwordSession.dispatchUrl); + let response!: Response; + for (let attempt = 0; attempt < 3; attempt++) { + response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.passwordSession.session.accessJwt}`, + 'Content-Type': mimeType, + Accept: 'application/json', + }, + body: new Uint8Array(buffer), + }); + if (response.ok) break; + const text = await response.text().catch(() => response.statusText); + if (attempt < 2) { + log.warn(`password uploadBlob attempt ${attempt + 1} failed: ${response.status} ${text}`); + await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); + continue; + } + throw new Error(`uploadBlob failed ${response.status}: ${text}`); + } + const json = (await response.json()) as { blob: BlobRef }; + if (!json.blob) { + throw new Error('uploadBlob response missing blob'); + } + return json.blob; + } +} + +class OAuthSessionAdapter implements AtprotoSession { + oauthSession: OAuthSession; + client: Client; + did: string; + handle: string; + service: string; + + constructor(oauthSession: OAuthSession) { + this.oauthSession = oauthSession; + this.client = new Client({ + handler: retryFetchHandler({ handler: oauthSession.fetchHandler.bind(oauthSession), maxRetries: 3 }), + }); + this.did = oauthSession.did; + this.handle = ''; + this.service = oauthSession.serverMetadata.issuer ?? ''; + } + + async uploadBlob(buffer: Buffer, mimeType: string): Promise { + const response = await this.oauthSession.fetchHandler('/xrpc/com.atproto.repo.uploadBlob', { + method: 'POST', + headers: { + 'Content-Type': mimeType, + Accept: 'application/json', + }, + body: new Uint8Array(buffer), + }); + if (!response.ok) { + const text = await response.text().catch(() => response.statusText); + throw new Error(`uploadBlob failed ${response.status}: ${text}`); + } + const json = (await response.json()) as { blob: BlobRef }; + if (!json.blob) { + throw new Error('uploadBlob response missing blob'); + } + return json.blob; + } +} + +async function getPasswordSession(config: Config, log: Logger): Promise { + if (!config.atpIdentifier || !config.atpAppPassword) { + throw new Error('Missing ATP_IDENTIFIER and/or ATP_APP_PASSWORD for password auth'); + } + const cached = await readSessionFile(config.sessionFile); let session: PasswordSession; if (cached) { - log.debug('resuming cached atproto session'); + log.debug('resuming cached password session'); session = await PasswordSession.resume(cached, { onUpdate(data) { writeSessionFile(config.sessionFile, data).catch(() => {}); @@ -66,53 +173,120 @@ export async function getSession(config: Config, log: Logger): Promise { + let savedSessions: Record; + try { + const raw = await readFile(sessionStorePath, 'utf-8'); + savedSessions = JSON.parse(raw) as Record; + } catch { + return undefined; } - return { - client, - did: sessionInfo.data.did, - handle: sessionInfo.data.handle, - accessJwt: data.accessJwt, - service: config.atpService, - passwordSession: session, - }; + const subs = Object.keys(savedSessions); + if (subs.length === 0) return undefined; + + for (const sub of subs) { + try { + const session = await client.restore(sub); + log.debug(`restored OAuth session for ${sub}`); + return session; + } catch (err: any) { + log.debug(`failed to restore OAuth session for ${sub}: ${err?.message ?? err}`); + } + } + return undefined; } -export async function uploadBlob( - session: AtprotoSession, - buffer: Buffer, - mimeType: string, - log: Logger -): Promise { - const url = new URL('/xrpc/com.atproto.repo.uploadBlob', session.service); - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${session.accessJwt}`, - 'Content-Type': mimeType, - Accept: 'application/json', +async function startOAuthFlow(config: Config, log: Logger): Promise { + const scope = config.oauthScope; + const clientId = config.oauthClientId || buildLocalClientId(scope); + + const client = new NodeOAuthClient({ + clientMetadata: { + client_id: clientId, + client_name: 'GhostOff', + application_type: 'native', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + redirect_uris: [LOCAL_REDIRECT_URI], + scope, + token_endpoint_auth_method: 'none', + dpop_bound_access_tokens: true, }, - body: new Uint8Array(buffer), + stateStore: createFileStateStore(config.oauthStateFile), + sessionStore: createFileSessionStore(config.oauthSessionFile), }); - if (!response.ok) { - const text = await response.text().catch(() => response.statusText); - throw new Error(`uploadBlob failed ${response.status}: ${text}`); + let restored: OAuthSession | undefined; + try { + restored = await restoreOAuthSession(client, config.oauthSessionFile, log); + } catch { + // ignore + } + if (restored) return restored; + + const handle = config.atpIdentifier || (await promptForHandle()); + + const { port, getParams } = await startLoopbackCallbackServer(0); + const redirectUri = localRedirectUri(port); + const state = randomBytes(16).toString('base64url'); + + const authorizeUrl = await client.authorize(handle, { + redirect_uri: redirectUri as any, + state, + }); + + log.info('Open this URL in your browser to authenticate GhostOff:'); + log.info(authorizeUrl.toString()); + + try { + const { default: open } = await import('open'); + await open(authorizeUrl.toString()); + } catch { + // fallback to manual URL + } + + const params = await getParams(); + const error = params.get('error'); + if (error) { + throw new Error(`OAuth authorization error: ${error} - ${params.get('error_description') ?? ''}`); } - const json = (await response.json()) as { blob: BlobRef }; - if (!json.blob) { - throw new Error('uploadBlob response missing blob'); + const { session, state: returnedState } = await client.callback(params, { redirect_uri: redirectUri as any }); + if (returnedState !== state) { + throw new Error('OAuth state mismatch'); } - return json.blob; + + log.info(`authenticated as ${session.did}`); + await chmod(config.oauthSessionFile, 0o600).catch(() => {}); + await chmod(config.oauthStateFile, 0o600).catch(() => {}); + return session; +} + +export async function getSession(config: Config, log: Logger): Promise { + let adapter: AtprotoSession; + + if (config.atpAppPassword) { + const passwordSession = await getPasswordSession(config, log); + adapter = new PasswordSessionAdapter(passwordSession); + } else { + const oauthSession = await startOAuthFlow(config, log); + adapter = new OAuthSessionAdapter(oauthSession); + } + + if (!adapter.handle) { + const info = await adapter.client.get('com.atproto.server.getSession'); + if (!info.ok) { + throw new Error(`failed to get session info: ${(info.data as any).message ?? info.status}`); + } + adapter.handle = info.data.handle; + } + + log.info(`session ready: ${adapter.handle} (${adapter.did})`); + return adapter; } export interface StandardDocumentRecord { diff --git a/src/config.ts b/src/config.ts index 01238a3..aba56bb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,13 +4,17 @@ import { program } from 'commander'; export interface Config { ghostUrl: string; ghostApiKey: string; - atpIdentifier: string; - atpAppPassword: string; + atpIdentifier?: string; + atpAppPassword?: string; atpService: string; publicationAtUri: string; exportDir: string; stateFile: string; sessionFile: string; + oauthClientId?: string; + oauthScope: string; + oauthSessionFile: string; + oauthStateFile: string; dryRun: boolean; verbose: boolean; } @@ -32,9 +36,13 @@ export function loadConfig(argv?: string[]): Config { .option('--atproto-app-password ', 'atproto app password') .option('--atproto-service ', 'atproto PDS/service URL') .option('--publication-at-uri ', 'existing site.standard.publication AT-URI') + .option('--oauth-client-id ', 'OAuth client_id URL (default: http://localhost dev mode)') + .option('--oauth-scope ', 'OAuth requested scope', 'atproto transition:generic') + .option('--oauth-session-file ', 'OAuth session store file', 'ghostoff-oauth-session.json') + .option('--oauth-state-file ', 'OAuth transient state store file', 'ghostoff-oauth-state.json') .option('--export-dir ', 'local asset export/cache directory', 'ghostoff-export') .option('--state-file ', 'idempotency state file', 'ghostoff-state.json') - .option('--session-file ', 'persisted session file', 'ghostoff-session.json') + .option('--session-file ', 'persisted password session file', 'ghostoff-session.json') .option('--dry-run', 'build records without uploading', false) .option('--verbose', 'verbose logging', false); @@ -43,14 +51,20 @@ export function loadConfig(argv?: string[]): Config { const ghostUrl = requireEnv('GHOST_URL or --ghost-url', opts.ghostUrl ?? process.env.GHOST_URL); const ghostApiKey = requireEnv('GHOST_API_KEY or --ghost-api-key', opts.ghostApiKey ?? process.env.GHOST_API_KEY); - const atpIdentifier = requireEnv('ATP_IDENTIFIER or --atproto-identifier', opts.atprotoIdentifier ?? process.env.ATP_IDENTIFIER); - const atpAppPassword = requireEnv('ATP_APP_PASSWORD or --atproto-app-password', opts.atpAppPassword ?? process.env.ATP_APP_PASSWORD); + const atpIdentifier = opts.atprotoIdentifier ?? process.env.ATP_IDENTIFIER ?? undefined; + const atpAppPassword = opts.atprotoAppPassword ?? process.env.ATP_APP_PASSWORD ?? undefined; const atpService = opts.atprotoService ?? process.env.ATP_SERVICE ?? 'https://bsky.social'; const publicationAtUri = requireEnv( 'ATPUBLICATION_AT_URI or --publication-at-uri', opts.publicationAtUri ?? process.env.ATPUBLICATION_AT_URI ); + if (!atpAppPassword && !atpIdentifier) { + throw new Error( + 'Missing configuration: provide ATP_IDENTIFIER/--atproto-identifier (for OAuth) or ATP_APP_PASSWORD/--atproto-app-password (for password auth).' + ); + } + return { ghostUrl: ghostUrl.replace(/\/$/, ''), ghostApiKey, @@ -61,6 +75,10 @@ export function loadConfig(argv?: string[]): Config { exportDir: opts.exportDir, stateFile: opts.stateFile, sessionFile: opts.sessionFile, + oauthClientId: opts.oauthClientId ?? process.env.GHOSTOFF_OAUTH_CLIENT_ID ?? undefined, + oauthScope: opts.oauthScope ?? process.env.GHOSTOFF_OAUTH_SCOPE ?? 'atproto transition:generic', + oauthSessionFile: opts.oauthSessionFile ?? 'ghostoff-oauth-session.json', + oauthStateFile: opts.oauthStateFile ?? 'ghostoff-oauth-state.json', dryRun: !!opts.dryRun, verbose: !!opts.verbose, }; diff --git a/src/index.ts b/src/index.ts index 07a9c43..d0658fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,7 +45,6 @@ async function main() { log.info(`processing: ${post.title}`); const assetMap = new Map(); - // Collect and upload inline images const inlineUrls = collectImageUrls(post.html, config.ghostUrl); if (post.feature_image) { inlineUrls.push(post.feature_image); @@ -59,8 +58,7 @@ async function main() { config.ghostUrl, config.exportDir, state.assets as Record, - session.accessJwt, - config.atpService, + session, log ); if (info) { @@ -106,18 +104,18 @@ async function main() { if (config.dryRun || !session) { log.info(`[dry-run] would create/update site.standard.document for "${post.title}"`); documentRef = { uri: 'at://dry-run/site.standard.document/dry', cid: 'dry-run' }; - } else if (existing?.documentUri?.startsWith('at://did:') && existing.documentCid && existing.documentCid !== 'dry-run') { - documentRef = await putRecord( - session, - 'site.standard.document', - rkeyFromUri(existing.documentUri), - documentRecord, - existing.documentCid, - log - ); - } else { - documentRef = await createRecord(session, 'site.standard.document', documentRecord, log); - } + } else if (existing?.documentUri?.startsWith('at://did:') && existing.documentCid && existing.documentCid !== 'dry-run') { + documentRef = await putRecord( + session, + 'site.standard.document', + rkeyFromUri(existing.documentUri), + documentRecord, + existing.documentCid, + log + ); + } else { + documentRef = await createRecord(session, 'site.standard.document', documentRecord, log); + } const articleRecord = { $type: 'app.offprint.document.article', @@ -132,16 +130,16 @@ async function main() { if (config.dryRun || !session) { log.info(`[dry-run] would create/update app.offprint.document.article for "${post.title}"`); articleRef = { uri: 'at://dry-run/app.offprint.document.article/dry', cid: 'dry-run' }; - } else if (existing?.articleUri?.startsWith('at://did:') && existing.articleCid && existing.articleCid !== 'dry-run') { - articleRef = await putRecord( - session, - 'app.offprint.document.article', - rkeyFromUri(existing.articleUri), - articleRecord, - existing.articleCid, - log - ); - } else { + } else if (existing?.articleUri?.startsWith('at://did:') && existing.articleCid && existing.articleCid !== 'dry-run') { + articleRef = await putRecord( + session, + 'app.offprint.document.article', + rkeyFromUri(existing.articleUri), + articleRecord, + existing.articleCid, + log + ); + } else { articleRef = await createRecord(session, 'app.offprint.document.article', articleRecord, log); } diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..5e37fda --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,109 @@ +import { createServer } from 'node:http'; +import { readFile, writeFile } from 'node:fs/promises'; +import type { AddressInfo } from 'node:net'; +import { createInterface } from 'node:readline/promises'; +import type { NodeSavedSession, NodeSavedSessionStore, NodeSavedState, NodeSavedStateStore } from '@atproto/oauth-client-node'; + +async function readJsonFile(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + return JSON.parse(raw) as T; + } catch { + return {} as T; + } +} + +async function writeJsonFile(path: string, data: unknown, mode?: number): Promise { + await writeFile(path, JSON.stringify(data, null, 2), { mode }); +} + +export function createFileStateStore(filePath: string): NodeSavedStateStore { + return { + async set(key: string, value: NodeSavedState) { + const data = await readJsonFile>(filePath); + data[key] = value; + await writeJsonFile(filePath, data, 0o600); + }, + async get(key: string): Promise { + const data = await readJsonFile>(filePath); + return data[key]; + }, + async del(key: string) { + const data = await readJsonFile>(filePath); + delete data[key]; + await writeJsonFile(filePath, data, 0o600); + }, + }; +} + +export function createFileSessionStore(filePath: string): NodeSavedSessionStore { + return { + async set(sub: string, value: NodeSavedSession) { + const data = await readJsonFile>(filePath); + data[sub] = value; + await writeJsonFile(filePath, data, 0o600); + }, + async get(sub: string): Promise { + const data = await readJsonFile>(filePath); + return data[sub]; + }, + async del(sub: string) { + const data = await readJsonFile>(filePath); + delete data[sub]; + await writeJsonFile(filePath, data, 0o600); + }, + }; +} + +export function startLoopbackCallbackServer(port = 0): Promise<{ port: number; getParams: () => Promise }> { + return new Promise((resolve, reject) => { + let gotParams: ((params: URLSearchParams) => void) | undefined; + let rejectParams: ((err: Error) => void) | undefined; + + const paramsPromise = new Promise((res, rej) => { + gotParams = res; + rejectParams = rej; + }); + + const server = createServer((req, res) => { + const reqUrl = new URL(req.url || '/', 'http://127.0.0.1'); + if (reqUrl.pathname !== '/callback') { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('not found'); + return; + } + + const params = reqUrl.searchParams; + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Authentication successful. You can close this tab and return to the terminal.

'); + + server.close((closeErr) => { + if (closeErr) { + rejectParams?.(closeErr); + } else { + gotParams?.(params); + } + }); + }); + + server.on('error', reject); + + server.listen({ host: '127.0.0.1', port }, () => { + const address = server.address() as AddressInfo; + resolve({ + port: address.port, + getParams: () => paramsPromise, + }); + }); + }); +} + +export async function promptForHandle(): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question('Enter your atproto handle (e.g. handle.example.com): '); + return answer.trim(); + } finally { + rl.close(); + } +} -- 2.51.2