diff --git a/packages/core/src/handlers/server-meta.js b/packages/core/src/handlers/server-meta.js index f190fca..f16df6e 100644 --- a/packages/core/src/handlers/server-meta.js +++ b/packages/core/src/handlers/server-meta.js @@ -5,6 +5,7 @@ // account exists, and gates itself on the server password. import { encodeStoredKey, hexToBytes } from '../crypto.js'; +import { drainRequestBody } from '../http.js'; import { isValidDid, isValidHandle, @@ -23,7 +24,6 @@ import { * @property {(request: Request) => Promise<{did: string, scope: string}|null>} authenticate * @property {(type: 'account'|'identity'|'sync', body: Object) => Promise} emitEvent * @property {(commit: {cid: string, rev: string}) => Promise} emitSyncEvent - * @property {(request: Request) => Promise} drainRequestBody * @property {() => Response} readOnlyError * @property {(did: string|null) => void} setCachedIdentity - Clears the DID and signing-key caches after /init writes them */ @@ -43,7 +43,6 @@ export function createServerMetaHandlers(ctx) { authenticate, emitEvent, emitSyncEvent, - drainRequestBody, readOnlyError, setCachedIdentity, } = ctx; diff --git a/packages/core/src/http.js b/packages/core/src/http.js new file mode 100644 index 0000000..19de978 --- /dev/null +++ b/packages/core/src/http.js @@ -0,0 +1,90 @@ +// @pdsjs/core/http - Request and response mechanics every handler shares: +// CORS, cookies, HTML responses and draining a body a handler will not read. + +// CORS headers for cross-origin requests +export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': + 'Content-Type, Authorization, DPoP, atproto-accept-labelers, atproto-proxy, x-bsky-topics', + // Browser clients must be able to READ these response headers. Without + // exposing DPoP-Nonce, a browser OAuth client cannot see the nonce the server + // hands back with `use_dpop_nonce` and so cannot retry — login fails on it. + 'Access-Control-Expose-Headers': 'DPoP-Nonce, WWW-Authenticate', +}; + +/** + * Add CORS headers to response + * @param {Response} response + * @returns {Response} + */ +export function addCorsHeaders(response) { + // WebSocket upgrade responses (101) can't be reconstructed + // webSocket is a Cloudflare-specific property + if (response.status === 101 || /** @type {any} */ (response).webSocket) { + return response; + } + + const newHeaders = new Headers(response.headers); + for (const [key, value] of Object.entries(corsHeaders)) { + newHeaders.set(key, value); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); +} + +/** + * Consume a request body that a handler is about to reject unread. + * Workers runtimes raise an uncaught error when a response is sent while + * the request stream is still readable. + * @param {Request} request + * @returns {Promise} + */ +export async function drainRequestBody(request) { + if (!request.body) return; + try { + await request.arrayBuffer(); + } catch { + // Body already consumed or aborted + } +} + +/** + * Read one cookie from a request's Cookie header. + * @param {Request} request + * @param {string} name + * @returns {string|null} + */ +export function readCookie(request, name) { + const header = request.headers.get('cookie'); + if (!header) return null; + for (const pair of header.split(';')) { + const eq = pair.indexOf('='); + if (eq === -1) continue; + if (pair.slice(0, eq).trim() === name) { + return decodeURIComponent(pair.slice(eq + 1).trim()); + } + } + return null; +} + +/** + * An HTML response for the account pages. These carry per-account data and a + * session cookie, so they must never be cached. + * @param {string} html + * @param {number} [status=200] + * @param {string|null} [setCookie=null] + * @returns {Response} + */ +export function htmlResponse(html, status = 200, setCookie = null) { + const headers = new Headers({ + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'same-origin', + }); + if (setCookie) headers.set('Set-Cookie', setCookie); + return new Response(html, { status, headers }); +} diff --git a/packages/core/src/pds.js b/packages/core/src/pds.js index 45567e0..603f2e2 100644 --- a/packages/core/src/pds.js +++ b/packages/core/src/pds.js @@ -69,6 +69,13 @@ import { createIdentityHandlers } from './handlers/xrpc-identity.js'; import { createPreferencesHandlers } from './handlers/xrpc-preferences.js'; import { createRepoHandlers } from './handlers/xrpc-repo.js'; import { createSyncHandlers } from './handlers/xrpc-sync.js'; +import { + addCorsHeaders, + corsHeaders, + drainRequestBody, + htmlResponse, + readCookie, +} from './http.js'; import { loadRepositoryFromCar } from './loader.js'; import { buildMst, mstAdd, mstDelete, mstUpdate } from './mst.js'; import { @@ -318,41 +325,6 @@ const ACCOUNT_BLOB_PAGE_LIMIT = 30; // The media kinds the Files page can filter by; mirrors blob-sql's bucketing const BLOB_KINDS = ['image', 'video', 'audio', 'text', 'other']; -// CORS headers for cross-origin requests -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': - 'Content-Type, Authorization, DPoP, atproto-accept-labelers, atproto-proxy, x-bsky-topics', - // Browser clients must be able to READ these response headers. Without - // exposing DPoP-Nonce, a browser OAuth client cannot see the nonce the server - // hands back with `use_dpop_nonce` and so cannot retry — login fails on it. - 'Access-Control-Expose-Headers': 'DPoP-Nonce, WWW-Authenticate', -}; - -/** - * Add CORS headers to response - * @param {Response} response - * @returns {Response} - */ -function addCorsHeaders(response) { - // WebSocket upgrade responses (101) can't be reconstructed - // webSocket is a Cloudflare-specific property - if (response.status === 101 || /** @type {any} */ (response).webSocket) { - return response; - } - - const newHeaders = new Headers(response.headers); - for (const [key, value] of Object.entries(corsHeaders)) { - newHeaders.set(key, value); - } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: newHeaders, - }); -} - /** * Derive the curve and compressed public key for a stored private key. * @param {Uint8Array} storedKeyBytes - As stored: bare P-256 scalar or curve-tagged @@ -378,22 +350,6 @@ async function derivePublicKey(storedKeyBytes, k256Signer = null) { return { curve, publicKey: compressPublicKey(uncompressed) }; } -/** - * Consume a request body that a handler is about to reject unread. - * Workers runtimes raise an uncaught error when a response is sent while - * the request stream is still readable. - * @param {Request} request - * @returns {Promise} - */ -async function drainRequestBody(request) { - if (!request.body) return; - try { - await request.arrayBuffer(); - } catch { - // Body already consumed or aborted - } -} - /** * How many apps the Apps page shows: one per authority domain, unioned across * the collections that hold records and the OAuth sessions in play. An app can @@ -433,43 +389,6 @@ function plausibleHost(value) { return /^[a-z0-9][a-z0-9.:-]{0,252}$/.test(host) ? host : null; } -/** - * Read one cookie from a request's Cookie header. - * @param {Request} request - * @param {string} name - * @returns {string|null} - */ -function readCookie(request, name) { - const header = request.headers.get('cookie'); - if (!header) return null; - for (const pair of header.split(';')) { - const eq = pair.indexOf('='); - if (eq === -1) continue; - if (pair.slice(0, eq).trim() === name) { - return decodeURIComponent(pair.slice(eq + 1).trim()); - } - } - return null; -} - -/** - * An HTML response for the account pages. These carry per-account data and a - * session cookie, so they must never be cached. - * @param {string} html - * @param {number} [status=200] - * @param {string|null} [setCookie=null] - * @returns {Response} - */ -function htmlResponse(html, status = 200, setCookie = null) { - const headers = new Headers({ - 'Content-Type': 'text/html; charset=utf-8', - 'Cache-Control': 'no-store', - 'Referrer-Policy': 'same-origin', - }); - if (setCookie) headers.set('Set-Cookie', setCookie); - return new Response(html, { status, headers }); -} - /** * The language-tagged variants of a permission-set text property, e.g. * `title:fr` -> `{ fr: '...' }`. @@ -649,7 +568,6 @@ export class PersonalDataServer { authenticate: (request) => this.authenticate(request), emitEvent: (type, body) => this.emitEvent(type, body), emitSyncEvent: (commit) => this.emitSyncEvent(commit), - drainRequestBody: (request) => drainRequestBody(request), readOnlyError: () => this.readOnlyError(), setCachedIdentity: (did) => { this._did = did; diff --git a/test/server-meta.test.js b/test/server-meta.test.js index 1c0194c..94dff83 100644 --- a/test/server-meta.test.js +++ b/test/server-meta.test.js @@ -74,7 +74,6 @@ function createMeta({ emitSyncEvent: async (commit) => { syncEvents.push(commit); }, - drainRequestBody: async () => {}, readOnlyError: () => Response.json( { error: 'AuthenticationRequired', message: 'This PDS is read-only' },