Something went wrong. Try again.
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
Something went wrong. Try again.
15 kB · 395 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396import { PRIVATE_SHARE_QUERY_PARAM } from '@wispplace/constants'import { normalizeSitePath } from '@wispplace/fs-utils'import { createLogger } from '@wispplace/observability'import { buildPrivateStorageKey, buildSessionCookie, countCookieOccurrences, type GeneratedSecret, generateRecordId, generateSessionSecret, hashSecret, hashShareTokenSync, isValidSiteId, PRIVATE_ACCESS_PAGE_STYLES, PRIVATE_GRANT_QUERY_PARAM, PRIVATE_SESSION_TTL_MINUTES, parseCookieHeader, privateFormResponseHeaders, privateResponseHeaders, sessionCookieName,} from '@wispplace/private-sites'import { lookup } from 'mime-types'import { type AuthorizedPrivateSite, exchangePrivateHandoff, exchangePrivateShareToken, loadAuthorizedPrivateSite, type PrivateSiteFile, touchShare,} from './private-sites-db'import { privateStorage } from './storage'
const logger = createLogger('hosting-service')
const INDEX_FILES = ['index.html', 'index.htm']
const isSecureRequest = (request: Request): boolean => { if (request.headers.get('x-forwarded-proto') === 'https') return true try { return new URL(request.url).protocol === 'https:' } catch { return false }}const mainAppOrigin = (): string => { const configured = process.env.MAIN_APP_URL || process.env.DOMAIN if (configured) return configured.replace(/\/+$/, '') const base = (process.env.BASE_HOST || 'wisp.place').split(':')[0] return `https://${base}`}
const privatePageHead = (title: string): string => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex, nofollow"><title>${title} — wisp.place</title><style>${PRIVATE_ACCESS_PAGE_STYLES}</style></head>`const scopedSignInPage = (siteId: string, token: string, audienceDid: string): Response => { const body = `${privatePageHead('sign in required')}<body><main class="private-page private-shell"><script>history.replaceState(null, '', location.pathname)</script><div class="private-brand"><strong>wisp.place</strong></div><h1>this link is for a specific account</h1><p>it was shared with <code>${escapeHtml(audienceDid)}</code>. sign in with that account to open it.</p><form class="private-form" method="POST" action="${escapeHtml(mainAppOrigin())}/private/redeem"> <input type="hidden" name="siteId" value="${escapeHtml(siteId)}"> <input type="hidden" name="token" value="${escapeHtml(token)}"> <button class="private-action" type="submit">sign in with atproto <span aria-hidden="true">→</span></button></form></main></body></html>`
// This page POSTs cross-origin to the main app, so it cannot send `no-referrer`. return new Response(body, { status: 200, headers: { ...privateFormResponseHeaders(), 'Content-Type': 'text/html; charset=utf-8' }, })}const anonymousSignInPage = (siteId: string): Response => { // Keep this independent of database state so hostnames cannot be probed for existence. const body = `${privatePageHead('sign in')}<body><main class="private-page private-shell"><div class="private-brand"><strong>wisp.place</strong></div><p class="private-kicker">access required</p><h1>private site</h1><p>this address needs a share link, or an account with access to it.</p><form class="private-form" method="GET" action="${escapeHtml(mainAppOrigin())}/private/open"> <input type="hidden" name="siteId" value="${escapeHtml(siteId)}"> <button class="private-action" type="submit">sign in with atproto <span aria-hidden="true">→</span></button></form></main></body></html>`
return new Response(body, { status: 200, headers: { ...privateResponseHeaders(), 'Content-Type': 'text/html; charset=utf-8' }, })}
const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c)export const privateNotFound = (): Response => new Response('Not found', { status: 404, headers: { ...privateResponseHeaders(), 'Content-Type': 'text/plain; charset=utf-8' }, })type CredentialQuery = { kind: 'none' } | { kind: 'handoff'; token: string } | { kind: 'share'; token: string }
type PresentedCredential = Exclude<CredentialQuery, { kind: 'none' }>
type PendingSession = { sessionId: string secret: GeneratedSecret expiresAt: Date}
type CredentialResolution = | { kind: 'sessionCreated'; pendingSession: PendingSession; shareId: string | null } | { kind: 'audienceMismatch'; shareToken: string; audienceDid: string } | { kind: 'denied'; source: 'handoff' | 'share' } | { kind: 'unavailable'; source: 'handoff' | 'share' }
type CredentialFailure = Exclude<CredentialResolution, { kind: 'sessionCreated' }>type CredentialExchangeOutcome = { kind: 'notPresent' } | { kind: 'response'; response: Response }type SessionCookieOutcome = { kind: 'missing' } | { kind: 'present'; secretHash: string }type AuthorizedSiteLoadOutcome = | { kind: 'authorized'; authorized: AuthorizedPrivateSite } | { kind: 'missing' } | { kind: 'unavailable' }
type PrivateManifestOutcome = { kind: 'valid'; index: PrivateFileIndex } | { kind: 'invalid' }type PrivateStorageReadOutcome = { kind: 'found'; bytes: Uint8Array } | { kind: 'missing' } | { kind: 'unavailable' }
interface PrivateFileIndex { known: Map<string, PrivateSiteFile> legacyRoot: string}
const credentialQueryFrom = (url: URL): CredentialQuery => { const handoffToken = url.searchParams.get(PRIVATE_GRANT_QUERY_PARAM) if (handoffToken) return { kind: 'handoff', token: handoffToken }
const shareToken = url.searchParams.get(PRIVATE_SHARE_QUERY_PARAM) return shareToken ? { kind: 'share', token: shareToken } : { kind: 'none' }}
const newPendingSession = (): PendingSession => { const secret = generateSessionSecret() return { sessionId: generateRecordId(), secret, expiresAt: new Date(Date.now() + PRIVATE_SESSION_TTL_MINUTES * 60_000), }}
const sessionRedirect = (request: Request, url: URL, secret: GeneratedSecret): Response => { url.searchParams.delete(PRIVATE_SHARE_QUERY_PARAM) url.searchParams.delete(PRIVATE_GRANT_QUERY_PARAM) return new Response(null, { status: 302, headers: { ...privateResponseHeaders(), Location: `${url.pathname}${url.search}`, 'Set-Cookie': buildSessionCookie(secret.value, isSecureRequest(request), PRIVATE_SESSION_TTL_MINUTES * 60), }, })}
const resolveHandoffCredential = async (siteId: string, token: string): Promise<CredentialResolution> => { const pendingSession = newPendingSession() const input = { siteId, secretHash: hashSecret(token), sessionId: pendingSession.sessionId, sessionSecretHash: pendingSession.secret.hash, expiresAt: pendingSession.expiresAt, } try { const exchange = await exchangePrivateHandoff(input) if (!exchange) return { kind: 'denied', source: 'handoff' } return { kind: 'sessionCreated', pendingSession, shareId: exchange.kind === 'share' ? exchange.shareId : null, } } catch { return { kind: 'unavailable', source: 'handoff' } }}
const resolveShareCredential = async (siteId: string, token: string): Promise<CredentialResolution> => { const pendingSession = newPendingSession() const input = { siteId, tokenHash: hashShareTokenSync(token), sessionId: pendingSession.sessionId, sessionSecretHash: pendingSession.secret.hash, expiresAt: pendingSession.expiresAt, } try { const exchange = await exchangePrivateShareToken(input) if (!exchange) return { kind: 'denied', source: 'share' } if (exchange.kind === 'audienceMismatch') { return { kind: 'audienceMismatch', shareToken: token, audienceDid: exchange.audienceDid } } return { kind: 'sessionCreated', pendingSession, shareId: exchange.shareId } } catch { return { kind: 'unavailable', source: 'share' } }}
const resolveCredential = (siteId: string, credential: PresentedCredential): Promise<CredentialResolution> => { return credential.kind === 'handoff' ? resolveHandoffCredential(siteId, credential.token) : resolveShareCredential(siteId, credential.token)}
const touchGrantedShare = (siteId: string, shareId: string): void => { void touchShare(shareId).catch(() => { logger.warn('[PrivateSite] Could not record share use', { siteId, failureKind: 'share-touch-failed' }) })}
const credentialFailureResponse = (siteId: string, resolution: CredentialFailure): Response => { if (resolution.kind === 'audienceMismatch') { logger.info('[PrivateSite] Scoped share needs sign-in', { siteId }) return scopedSignInPage(siteId, resolution.shareToken, resolution.audienceDid) }
if (resolution.kind === 'unavailable') { logger.warn('[PrivateSite] Credential exchange unavailable', { siteId, failureKind: `${resolution.source}-exchange-failed`, }) } else { logger.info('[PrivateSite] Credential exchange denied', { siteId, source: resolution.source }) } return privateNotFound()}
const tryExchangeCredential = async (request: Request, siteId: string): Promise<CredentialExchangeOutcome> => { const url = new URL(request.url) const credential = credentialQueryFrom(url) if (credential.kind === 'none') return { kind: 'notPresent' }
const resolution = await resolveCredential(siteId, credential) if (resolution.kind !== 'sessionCreated') { return { kind: 'response', response: credentialFailureResponse(siteId, resolution) } }
if (resolution.shareId) touchGrantedShare(siteId, resolution.shareId) return { kind: 'response', response: sessionRedirect(request, url, resolution.pendingSession.secret) }}
const sessionCookieFrom = (request: Request): SessionCookieOutcome => { const cookieHeader = request.headers.get('cookie') const name = sessionCookieName(isSecureRequest(request)) // Refuse cookie-tossing ambiguity rather than relying on first/last-wins parsing. if (countCookieOccurrences(cookieHeader, name) !== 1) return { kind: 'missing' }
const raw = parseCookieHeader(cookieHeader)[name] return raw ? { kind: 'present', secretHash: hashSecret(raw) } : { kind: 'missing' }}
/** * The primary-only aggregate query is the session authorization boundary. It * returns files only when the site, session, and any backing share are live. */const loadAuthorizedSite = async (siteId: string, secretHash: string): Promise<AuthorizedSiteLoadOutcome> => { try { const authorized = await loadAuthorizedPrivateSite(siteId, secretHash) return authorized ? { kind: 'authorized', authorized } : { kind: 'missing' } } catch { logger.warn('[PrivateSite] Authorized site lookup failed', { siteId, failureKind: 'authorized-site-load-failed' }) return { kind: 'unavailable' } }}
const invalidPrivatePathResponse = (): Response => { return new Response('Invalid path', { status: 400, headers: { ...privateResponseHeaders(), 'Content-Type': 'text/plain; charset=utf-8' }, })}
const normalizedPrivateRequestPath = (filePath: string): { kind: 'valid'; path: string } | { kind: 'invalid' } => { const path = normalizeSitePath(filePath) return path === null ? { kind: 'invalid' } : { kind: 'valid', path }}
const isCanonicalStoredPath = (path: string): boolean => { const normalized = normalizeSitePath(path) return normalized !== null && normalized.length > 0 && normalized === path}
const indexPrivateFiles = (files: readonly PrivateSiteFile[]): PrivateFileIndex => { const known = new Map(files.map((file) => [file.path, file])) const firstSegments = new Set(files.map((file) => file.path.split('/')[0])) const legacyRoot = files.length > 0 && firstSegments.size === 1 && files.every((file) => file.path.includes('/')) ? `${files[0]!.path.split('/')[0]}/` : '' return { known, legacyRoot }}
const validateAndIndexPrivateFiles = (files: readonly PrivateSiteFile[]): PrivateManifestOutcome => { for (const file of files) { if (!isCanonicalStoredPath(file.path)) return { kind: 'invalid' } } return { kind: 'valid', index: indexPrivateFiles(files) }}
const firstKnownIndex = (directory: string, known: ReadonlyMap<string, PrivateSiteFile>): string | null => { for (const indexFile of INDEX_FILES) { const candidate = `${directory}${indexFile}` if (known.has(candidate)) return candidate } return null}
const targetWithinRoot = ( requested: string, root: string, known: ReadonlyMap<string, PrivateSiteFile>,): string | null => { const rootedRequest = `${root}${requested}` if (requested.length > 0 && known.has(rootedRequest)) return rootedRequest return firstKnownIndex(requested.length > 0 ? `${rootedRequest}/` : root, known)}
const resolvePrivateTarget = (requested: string, index: PrivateFileIndex): string | null => { const target = targetWithinRoot(requested, '', index.known) if (target || !index.legacyRoot) return target return targetWithinRoot(requested, index.legacyRoot, index.known)}
const loadPrivateFile = async (siteId: string, target: string): Promise<PrivateStorageReadOutcome> => { const storageKey = buildPrivateStorageKey(siteId, target) try { const result = await privateStorage.get(storageKey) return result ? { kind: 'found', bytes: result as Uint8Array } : { kind: 'missing' } } catch { return { kind: 'unavailable' } }}
const privateFileResponse = (target: string, bytes: Uint8Array, meta: PrivateSiteFile | undefined): Response => { return new Response(Buffer.from(bytes), { status: 200, headers: { ...privateResponseHeaders(), 'Content-Type': meta?.mimeType ?? (lookup(target) || 'application/octet-stream'), 'Content-Length': String(bytes.byteLength), }, })}
const servePrivateStoredFile = async (siteId: string, target: string, index: PrivateFileIndex): Promise<Response> => { const stored = await loadPrivateFile(siteId, target) if (stored.kind === 'missing') { logger.warn('[PrivateSite] Stored file missing', { siteId, path: target }) return privateNotFound() } if (stored.kind === 'unavailable') { logger.warn('[PrivateSite] Stored file unavailable', { siteId, path: target, failureKind: 'private-storage-read-failed', }) return privateNotFound() } return privateFileResponse(target, stored.bytes, index.known.get(target))}
export const servePrivateSite = async (request: Request, siteId: string, filePath: string): Promise<Response> => { if (!isValidSiteId(siteId)) return privateNotFound()
const requested = normalizedPrivateRequestPath(filePath) if (requested.kind === 'invalid') return invalidPrivatePathResponse()
const exchanged = await tryExchangeCredential(request, siteId) if (exchanged.kind === 'response') return exchanged.response
const session = sessionCookieFrom(request) if (session.kind === 'missing') return anonymousSignInPage(siteId)
const authorized = await loadAuthorizedSite(siteId, session.secretHash) if (authorized.kind === 'unavailable') return privateNotFound() if (authorized.kind === 'missing') return anonymousSignInPage(siteId)
const manifest = validateAndIndexPrivateFiles(authorized.authorized.files) if (manifest.kind === 'invalid') return privateNotFound()
const target = resolvePrivateTarget(requested.path, manifest.index) if (!target) return privateNotFound() return await servePrivateStoredFile(siteId, target, manifest.index)}