import http from 'node:http' import https from 'node:https' const ROUTE = '/api/feedback/space' const COLLECTION = 'app.userinput.space' const DID_PATTERN = /^did:[a-z]+:[a-zA-Z0-9._:%-]{1,300}$/ const RKEY_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._~:-]{1,512}$/ const CID_PATTERN = /^b[a-z2-7]{20,100}$/i const TAG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/ const DEFAULT_TYPES = [{ value: 'bug', label: 'Bug' }, { value: 'feature', label: 'Feature request' }, { value: 'question', label: 'Question' }] const MAX_RESPONSE_BYTES = 64_000 const TIMEOUT_MS = 5_000 export function createUserinputSpaceRefHandler({ spaceDid, spaceRkey, pdsUrl, allowLoopbackTest = false, timeoutMs = TIMEOUT_MS, maxResponseBytes = MAX_RESPONSE_BYTES, } = {}) { const configured = typeof spaceDid === 'string' && DID_PATTERN.test(spaceDid) && typeof spaceRkey === 'string' && RKEY_PATTERN.test(spaceRkey) && typeof pdsUrl === 'string' && validPdsUrl(pdsUrl, allowLoopbackTest) const upstream = configured ? recordUrl(pdsUrl, spaceDid, spaceRkey) : null return async function userinputSpaceRef(req, res) { if (new URL(req.url ?? '/', 'http://reams.invalid').pathname !== ROUTE) return false if (req.method !== 'GET') { sendJson(res, 405, { error: 'method_not_allowed' }, { allow: 'GET' }) return true } if (!upstream) { sendJson(res, 503, { error: 'feedback_unconfigured', message: 'The Reams Feedback space is not configured on this server.' }) return true } try { const payload = await requestRecord(upstream, { timeoutMs, maxResponseBytes }) const result = validateRecord(payload, spaceDid, spaceRkey) sendJson(res, 200, result) } catch (error) { const status = error.code === 'UPSTREAM_TIMEOUT' ? 504 : 502 sendJson(res, status, { error: status === 504 ? 'feedback_upstream_timeout' : 'feedback_space_unavailable' }) } return true } } function validPdsUrl(value, allowLoopbackTest) { let url try { url = new URL(value) } catch { return false } const loopback = url.protocol === 'http:' && allowLoopbackTest === true && url.hostname === '127.0.0.1' return !url.username && !url.password && !url.search && !url.hash && url.pathname === '/' && (url.protocol === 'https:' || loopback) } function recordUrl(pdsUrl, did, rkey) { const url = new URL('/xrpc/com.atproto.repo.getRecord', pdsUrl) url.searchParams.set('repo', did) url.searchParams.set('collection', COLLECTION) url.searchParams.set('rkey', rkey) return url } function requestRecord(url, { timeoutMs, maxResponseBytes }) { return new Promise((resolve, reject) => { let settled = false const finish = (error, value) => { if (settled) return settled = true error ? reject(error) : resolve(value) } const transport = url.protocol === 'https:' ? https : http const request = transport.request(url, { method: 'GET', headers: { accept: 'application/json' }, timeout: timeoutMs }, response => { if (response.statusCode >= 300 && response.statusCode < 400) { response.destroy() finish(new Error('Redirects are not allowed.')) return } const chunks = [] let size = 0 response.on('data', chunk => { size += chunk.length if (size > maxResponseBytes) { const error = Object.assign(new Error('Response exceeds size limit.'), { code: 'UPSTREAM_TOO_LARGE' }) response.destroy(error) finish(error) } else chunks.push(chunk) }) response.on('error', error => finish(error)) response.on('end', () => { if (response.statusCode < 200 || response.statusCode >= 300) return finish(new Error('PDS request failed.')) const type = String(response.headers['content-type'] ?? '').split(';', 1)[0].trim().toLowerCase() if (type !== 'application/json' && !type.endsWith('+json')) return finish(new Error('Invalid PDS response.')) try { finish(null, JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch { finish(new Error('Invalid PDS JSON.')) } }) }) request.on('timeout', () => { const error = Object.assign(new Error('PDS request timed out.'), { code: 'UPSTREAM_TIMEOUT' }) request.destroy(error) finish(error) }) request.on('error', error => finish(error)) request.end() }) } function validateRecord(value, did, rkey) { const entry = asRecord(value) const record = asRecord(entry?.value) const uri = `at://${did}/${COLLECTION}/${rkey}` if (entry?.uri !== uri || typeof entry.cid !== 'string' || !CID_PATTERN.test(entry.cid) || record?.$type !== COLLECTION) { throw new Error('Invalid userinput space strong reference.') } const tags = record.tags === undefined || (Array.isArray(record.tags) && record.tags.length === 0) ? DEFAULT_TYPES : record.tags if (!Array.isArray(tags) || tags.length > 100) throw new Error('Invalid space tag vocabulary.') const seen = new Set() const types = tags.map(tag => { const item = asRecord(tag) if (!item || typeof item.value !== 'string' || !TAG_PATTERN.test(item.value) || seen.has(item.value.toLowerCase()) || (item.label !== undefined && (typeof item.label !== 'string' || item.label.length > 80))) { throw new Error('Invalid space tag vocabulary.') } seen.add(item.value.toLowerCase()) return { value: item.value, label: item.label || item.value } }) return { uri: entry.uri, cid: entry.cid, types } } function asRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null } function sendJson(res, status, payload, extraHeaders = {}) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...extraHeaders }) res.end(JSON.stringify(payload)) }