Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182const DEFAULT_APPVIEW = 'https://lichen.wiki'const MAX_RESPONSE_BYTES = 2 * 1024 * 1024const NOTE_URI = /^at:\/\/(did:[a-z0-9]+:(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})+)\/wiki\.lichen\.note\/([A-Za-z0-9._~:-]+)$/
export function createLichenNoteProxyHandler({ appviewUrl = DEFAULT_APPVIEW, allowLoopbackTest = false, fetch: fetchImpl = globalThis.fetch, timeoutMs = 10_000, maxResponseBytes = MAX_RESPONSE_BYTES,} = {}) { let upstream try { upstream = new URL(appviewUrl) } catch { throw new TypeError('Lichen appview URL must be a fixed HTTPS origin.') } const localTest = allowLoopbackTest === true && upstream.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(upstream.hostname) if ((!localTest && upstream.protocol !== 'https:') || upstream.username || upstream.password || upstream.search || upstream.hash || !Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > MAX_RESPONSE_BYTES) { throw new TypeError('Lichen appview must be a fixed HTTPS URL and use valid limits.') }
return async function lichenNoteProxy(req, res) { let requestUrl try { requestUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? 'reams.invalid'}`) } catch { return reply(res, 400, 'Bad request') } const listing = requestUrl.pathname === '/api/lichen/wikis' if (!listing && requestUrl.pathname !== '/api/lichen/note') return false if (req.method !== 'GET') return reply(res, 405, 'Method not allowed', { allow: 'GET' }) const parameter = listing ? 'did' : 'uri' const value = requestUrl.searchParams.get(parameter) if (requestUrl.searchParams.size !== 1 || !value || !(listing ? /^did:[a-z0-9]+:(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})+$/.test(value) : NOTE_URI.test(value))) return reply(res, 400, listing ? 'Invalid account DID' : 'Invalid note URI') const origin = `${req.socket?.encrypted ? 'https' : 'http'}://${req.headers.host ?? ''}` if ((req.headers.origin && req.headers.origin !== origin) || (req.headers['sec-fetch-site'] && !['same-origin', 'none'].includes(req.headers['sec-fetch-site']))) return reply(res, 403, 'Forbidden')
const target = new URL(listing ? '/xrpc/wiki.lichen.listWikis' : '/xrpc/wiki.lichen.getNote', upstream.origin) target.searchParams.set(parameter, value) const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) try { const response = await fetchImpl(target, { method: 'GET', redirect: 'error', signal: controller.signal, headers: { accept: 'application/json' } }) const body = await readLimited(response, maxResponseBytes) const type = response.headers.get('content-type') res.writeHead(response.status, { 'content-type': type && /^application\/(?:json|[a-z0-9.+-]+\+json)(?:\s*;|$)/i.test(type) ? type : 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', }) res.end(body) } catch (error) { if (!res.headersSent) reply(res, error.code === 'TOO_LARGE' ? 502 : error.name === 'AbortError' ? 504 : 502, error.code === 'TOO_LARGE' ? 'Upstream response too large' : error.name === 'AbortError' ? 'Upstream timeout' : 'Upstream unavailable') } finally { clearTimeout(timer) } return true }}
async function readLimited(response, maxBytes) { const declared = Number(response.headers.get('content-length')) if (Number.isFinite(declared) && declared > maxBytes) throw Object.assign(new Error('too large'), { code: 'TOO_LARGE' }) const reader = response.body?.getReader() if (!reader) return new Uint8Array() const chunks = [] let size = 0 try { while (true) { const { done, value } = await reader.read() if (done) break size += value.byteLength if (size > maxBytes) { await reader.cancel(); throw Object.assign(new Error('too large'), { code: 'TOO_LARGE' }) } chunks.push(value) } } finally { reader.releaseLock() } const body = new Uint8Array(size) let offset = 0 for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength } return body}
function reply(res, status, message, headers = {}) { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers }) res.end(message) return true}