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.
8.2 kB · 222 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223import { isIP } from 'node:net'import type { Record as WispSettings } from '@wispplace/lexicons/types/place/wisp/settings'import { isLocalhostFetchAllowed, type SafeFetchOptions, safeFetchJson } from '@wispplace/safe-fetch'import { getSiteSettingsCache } from './db'
const DEFAULT_HANDLE_RESOLVER_URL = 'https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle'const DEFAULT_PLC_DIRECTORY_URL = 'https://plc.directory'// did:plc identifiers are fixed-width lowercase base32 (RFC 4648 without 0/1/8/9).const PLC_DID_PATTERN = /^did:plc:[a-z2-7]{24}$/
type JsonFetcher = typeof safeFetchJson
interface DidDocument { alsoKnownAs?: unknown[]}
interface IdentityResolutionOptions { /** Test seam. Production requests use SSRF-hardened safeFetchJson. */ fetchJson?: JsonFetcher fetchOptions?: SafeFetchOptions handleResolverUrl?: string plcDirectoryUrl?: string}
function environment(name: string): string | undefined { return process.env[name] || (typeof Bun !== 'undefined' ? Bun.env[name] : undefined)}
function normalizeHostname(hostname: string): string { return hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase()}
function isDnsHostname(hostname: string): boolean { const normalized = normalizeHostname(hostname) if (normalized.length === 0 || normalized.length > 253 || isIP(normalized) !== 0) return false const withoutTrailingDot = normalized.endsWith('.') ? normalized.slice(0, -1) : normalized if (!withoutTrailingDot) return false return withoutTrailingDot.split('.').every((label) => { return label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label) })}
function isLoopbackDevelopmentHostname(hostname: string): boolean { const normalized = normalizeHostname(hostname) return ( normalized === 'localhost' || normalized.endsWith('.localhost') || normalized === '127.0.0.1' || normalized === '::1' )}
function isValidHandle(handle: string): boolean { if (handle.length < 3 || handle.length > 253 || handle !== handle.toLowerCase()) return false if (!handle.includes('.') || handle.includes('..') || handle.endsWith('.')) return false return isDnsHostname(handle)}
function parseDidWeb(did: string): { authority: string; pathSegments: string[] } | null { if (!did.startsWith('did:web:') || did.length > 2048) return null const parts = did.slice('did:web:'.length).split(':') const encodedAuthority = parts.shift() if (!encodedAuthority || parts.some((part) => part === '.' || part === '..' || !/^[A-Za-z0-9._~-]+$/.test(part))) { return null }
// did:web represents an explicit port as %3A. Reject other escapes so a // method-specific identifier cannot smuggle a different URL authority. if (/%(?!3a)/i.test(encodedAuthority)) return null const authority = encodedAuthority.replace(/%3a/gi, ':')
try { const url = new URL(`https://${authority}`) if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return null if (!isDnsHostname(url.hostname)) return null return { authority: url.host, pathSegments: parts } } catch { return null }}
/** Strictly accepts the DID methods this hosting service resolves. */function isValidDid(did: string): boolean { return PLC_DID_PATTERN.test(did) || parseDidWeb(did) !== null}
/** Strictly accepts a supported DID or canonical AT Protocol handle. */export function isValidAtprotoIdentifier(identifier: string): boolean { return isValidDid(identifier) || isValidHandle(identifier)}
/** Convert a validated did:web identifier to its HTTPS DID document URL. */export function didWebToHttps(did: string): string { const parsed = parseDidWeb(did) if (!parsed) throw new Error('Invalid did:web format') const base = `https://${parsed.authority}` if (parsed.pathSegments.length === 0) return `${base}/.well-known/did.json` return `${base}/${parsed.pathSegments.map((part) => encodeURIComponent(part)).join('/')}/did.json`}
function parseIdentityEndpoint(value: string, name: string): URL { let url: URL try { url = new URL(value) } catch { throw new Error(`Invalid ${name} URL`) }
if (url.username || url.password || url.search || url.hash || !url.hostname) { throw new Error(`Invalid ${name} URL`) } if (/%(?:2f|5c|00)/i.test(url.pathname)) throw new Error(`Invalid ${name} URL path`) const hostname = normalizeHostname(url.hostname) if (!isDnsHostname(hostname) && isIP(hostname) === 0) throw new Error(`Invalid ${name} hostname`)
const localHttp = isLocalhostFetchAllowed() && isLoopbackDevelopmentHostname(hostname) if (url.protocol !== 'https:' && !(url.protocol === 'http:' && localHttp)) { throw new Error(`${name} must use HTTPS`) } return url}
function configuredIdentityEndpoint(configured: string | undefined, fallback: string, name: string): string { const url = parseIdentityEndpoint(configured || fallback, name) url.pathname = url.pathname.replace(/\/+$/, '') || '/' return url.toString()}
function identityFetchOptions(options: IdentityResolutionOptions): SafeFetchOptions { const fetchOptions = { ...(options.fetchOptions || {}) } if (isLocalhostFetchAllowed()) fetchOptions.allowLocalhost = true return fetchOptions}
async function fetchJson<T>(url: string, options: IdentityResolutionOptions): Promise<T> { const fetcher = options.fetchJson || safeFetchJson return fetcher<T>(url, identityFetchOptions(options))}
function parseDidDocument(value: unknown): DidDocument | null { if (typeof value !== 'object' || value === null) return null const document = value as DidDocument if (document.alsoKnownAs !== undefined && !Array.isArray(document.alsoKnownAs)) return null return document}
function plcDocumentUrl(directory: string, did: string): string { const url = new URL(directory) url.pathname = `${url.pathname.replace(/\/+$/, '')}/${encodeURIComponent(did)}` return url.toString()}
/** Fetch a validated DID document with the pinned safe-fetch transport. */export async function getDidDocument( did: string, options: IdentityResolutionOptions = {},): Promise<DidDocument | null> { if (!isValidDid(did)) return null
try { const url = did.startsWith('did:plc:') ? plcDocumentUrl( configuredIdentityEndpoint( options.plcDirectoryUrl || environment('WISP_PLC_DIRECTORY_URL'), DEFAULT_PLC_DIRECTORY_URL, 'PLC directory', ), did, ) : didWebToHttps(did) return parseDidDocument(await fetchJson<unknown>(url, options)) } catch { return null }}
function didDocumentIncludesHandle(document: DidDocument, handle: string): boolean { const expected = `at://${handle}` return (document.alsoKnownAs || []).some((value) => typeof value === 'string' && value.toLowerCase() === expected)}
/** * Resolve a handle only after strict handle validation, then bind the result to * the handle declared in the fetched DID document. */export async function resolveDid(identifier: string, options: IdentityResolutionOptions = {}): Promise<string | null> { if (isValidDid(identifier)) return identifier if (!isValidHandle(identifier)) return null
try { const resolverUrl = new URL( configuredIdentityEndpoint( options.handleResolverUrl || environment('WISP_HANDLE_RESOLVER_URL'), DEFAULT_HANDLE_RESOLVER_URL, 'handle resolver', ), ) resolverUrl.searchParams.set('handle', identifier) const result = await fetchJson<{ did?: unknown }>(resolverUrl.toString(), options) if (!result || typeof result.did !== 'string' || !isValidDid(result.did)) return null
const document = await getDidDocument(result.did, options) return document && didDocumentIncludesHandle(document, identifier) ? result.did : null } catch { return null }}
export async function getCachedSettings(did: string, rkey: string): Promise<WispSettings | null> { const cached = await getSiteSettingsCache(did, rkey) if (!cached) return null
return { $type: 'place.wisp.settings', directoryListing: cached.directory_listing, spaMode: cached.spa_mode ?? undefined, custom404: cached.custom_404 ?? undefined, indexFiles: cached.index_files ?? undefined, cleanUrls: cached.clean_urls, headers: cached.headers ?? undefined, }}