From 230bf1af78e51f583ce93323e2502eada7d511ff Mon Sep 17 00:00:00 2001 From: lemma <> Date: Sun, 7 Jun 2026 15:36:26 -0500 Subject: [PATCH] Add support for Atom feeds --- infra/api/src/index.ts | 97 ++++++++++++++++++++++++++++++++++++++++ infra/cf/cloudfront.yaml | 12 +++++ 2 files changed, 109 insertions(+) diff --git a/infra/api/src/index.ts b/infra/api/src/index.ts index e69a980..d4d062f 100644 --- a/infra/api/src/index.ts +++ b/infra/api/src/index.ts @@ -209,6 +209,102 @@ async function verifyPublication(rawPath: string): Promise/g, '>').replace(/"/g, '"'); +} + +async function getPublicationFeed(rawPath: string): Promise { + if (!SITE_DOMAIN) { + return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' }; + } + + const feedSuffix = '/feed.xml'; + const rawPubPath = rawPath.slice(0, rawPath.length - feedSuffix.length); + const pubPath = decodeURIComponent(rawPubPath); + const pubUrl = `https://${SITE_DOMAIN}${pubPath}`; + + const urlLookup = await ddb.send(new GetCommand({ + TableName: TABLE, + Key: { PK: `url#${pubUrl}`, SK: 'info' }, + })); + if (!urlLookup.Item?.pubAtUri) { + return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' }; + } + + const pubAtUri = urlLookup.Item.pubAtUri as string; + const atParts = pubAtUri.slice('at://'.length).split('/'); + const did = atParts[0]; + const pubRkey = atParts[2]; + + const [pubResult, postsResult, authorHandle] = await Promise.all([ + ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `u#${did}#pub`, SK: `pub#${pubRkey}` } })), + ddb.send(new QueryCommand({ + TableName: TABLE, + KeyConditionExpression: 'PK = :pk', + FilterExpression: 'visibility = :vis AND site = :site', + ExpressionAttributeValues: { ':pk': `u#${did}#post`, ':vis': 'published', ':site': pubAtUri }, + ScanIndexForward: false, + Limit: 100, + })), + resolveAuthorHandle(did), + ]); + + if (!pubResult.Item) { + return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' }; + } + + const pub = pubResult.Item; + const pubName = pub.name as string; + const pubSiteUrl = (pub.url as string).replace(/\/$/, ''); + const pubDescription = pub.description as string | undefined; + const posts = (postsResult.Items ?? []).slice(0, 20); + const selfUrl = `https://${SITE_DOMAIN}${rawPath}`; + const updatedAt = posts.length > 0 + ? ((posts[0].updatedAt ?? posts[0].publishedAt) as string) + : new Date().toISOString(); + + const entryXml = posts.map((post) => { + const base = (post.resolvedUrl as string | undefined ?? pubSiteUrl).replace(/\/$/, ''); + const postUrl = `${base}${post.path as string | undefined ?? ''}`; + const summary = (post.description ?? post.textContent) as string | undefined; + const lines = [ + ' ', + ` ${escXml(post.atUri as string)}`, + ` ${escXml(post.title as string)}`, + ` `, + ` ${post.publishedAt as string}`, + ` ${(post.updatedAt ?? post.publishedAt) as string}`, + ]; + if (summary) lines.push(` ${escXml(summary)}`); + lines.push(' '); + return lines.join('\n'); + }); + + const feedLines = [ + '', + '', + ` ${escXml(pubAtUri)}`, + ` ${escXml(pubName)}`, + ]; + if (pubDescription) feedLines.push(` ${escXml(pubDescription)}`); + feedLines.push( + ` `, + ` `, + ` ${updatedAt}`, + ' ', + ` ${escXml(authorHandle)}`, + ' ', + ...entryXml, + '', + ); + + return { + statusCode: 200, + headers: { 'Content-Type': 'application/atom+xml; charset=utf-8', 'Cache-Control': 'public, max-age=300' }, + body: feedLines.join('\n'), + }; +} + async function resolvePublicationMeta(atUri: string): Promise<{ url: string; name: string }> { const parts = atUri.slice('at://'.length).split('/') const pubDid = parts[0] @@ -886,6 +982,7 @@ export async function handler(event: APIGatewayProxyEventV2): Promise