diff --git a/app/bun.lock b/app/bun.lock index 6beeaf2..19e0367 100644 --- a/app/bun.lock +++ b/app/bun.lock @@ -91,6 +91,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "drizzle-kit": "^0.31.4", + "fast-xml-parser": "^5.11.1", "openimg": "^1.1.0", "prettier": "^3.6.2", "tailwindcss": "^4", @@ -486,6 +487,8 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.10", "", { "os": "win32", "cpu": "x64" }, "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -1238,6 +1241,8 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "arch": ["arch@3.0.0", "", {}, "sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -1608,7 +1613,9 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], + + "fast-xml-parser": ["fast-xml-parser@5.11.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -1770,6 +1777,8 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], @@ -2072,6 +2081,8 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -2316,7 +2327,7 @@ "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], + "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], @@ -2488,6 +2499,8 @@ "xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], @@ -2596,6 +2609,8 @@ "@aws-sdk/client-sts/@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="], + "@aws-sdk/core/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], + "@aws-sdk/credential-provider-login/@aws-sdk/core": ["@aws-sdk/core@3.973.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.5", "@smithy/core": "^3.23.2", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-wdQ8vrvHkKIV7yNUKXyjPWKCdYEUrZTHJ8Ojd5uJxXp9vqPCkUR1dpi1NtOLcrDgueJH7MUH5lQZxshjFPSbDA=="], "@aws-sdk/credential-provider-login/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.993.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.11", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.11", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.993.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.9", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.2", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.16", "@smithy/middleware-retry": "^4.4.33", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.10", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.32", "@smithy/util-defaults-mode-node": "^4.2.35", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-iOq86f2H67924kQUIPOAvlmMaOAvOLoDOIb66I2YqSUpMYB6ufiuJW3RlREgskxv86S5qKzMnfy/X6CqMjK6XQ=="], @@ -2968,6 +2983,8 @@ "@aws-sdk/client-sts/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew=="], + "@aws-sdk/core/fast-xml-parser/strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], + "@aws-sdk/credential-provider-login/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.5", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.3.6", "tslib": "^2.6.2" } }, "sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA=="], "@aws-sdk/credential-provider-login/@aws-sdk/core/@smithy/core": ["@smithy/core@3.23.2", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA=="], diff --git a/app/package.json b/app/package.json index 6be4365..2e3556b 100644 --- a/app/package.json +++ b/app/package.json @@ -107,6 +107,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "drizzle-kit": "^0.31.4", + "fast-xml-parser": "^5.11.1", "openimg": "^1.1.0", "prettier": "^3.6.2", "tailwindcss": "^4", diff --git a/app/src/app/rss/route.ts b/app/src/app/rss/route.ts index 64f954a..5a4f74d 100644 --- a/app/src/app/rss/route.ts +++ b/app/src/app/rss/route.ts @@ -1,36 +1,11 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { getPublishedEvents } from "@/lib/published-events"; -import { mainConfig } from "@/lib/config"; +import { generateRSS } from "@/lib/event-feeds"; -function generateRSS( - events: Awaited>, - origin: string, -) { - return ` - - - All Things Web Bay Area events - Sup! Subscribe to stay up to date with our monthly events. - ${origin} - ${new Date().toUTCString()} - ${events - .map( - (event) => ` - ${event.name} - ${event.tagline} - ${origin}/${event.slug} - ${event.createdAt.toUTCString()} - `, - ) - .join("\n")} - -`; -} - -export async function GET(request: NextRequest) { +export async function GET() { try { const events = await getPublishedEvents(); - const content = generateRSS(events, mainConfig.instance.origin); + const content = generateRSS(events); return new NextResponse(content, { headers: { diff --git a/app/src/app/sitemap.xml/route.ts b/app/src/app/sitemap.xml/route.ts index 6b9e575..174341e 100644 --- a/app/src/app/sitemap.xml/route.ts +++ b/app/src/app/sitemap.xml/route.ts @@ -1,36 +1,11 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { getPublishedEvents } from "@/lib/published-events"; -import { mainConfig } from "@/lib/config"; +import { generateSiteMap } from "@/lib/event-feeds"; -function getUrlElementWithDate(url: string, date: string) { - return ` - ${url} - ${date} - `; -} - -function generateSiteMap( - events: Awaited>, - origin: string, -) { - return ` - - ${getUrlElementWithDate(`${origin}/`, new Date().toISOString())} - ${getUrlElementWithDate(`${origin}/speakers`, new Date().toISOString())} - ${getUrlElementWithDate(`${origin}/about`, new Date().toISOString())} - ${events - .map( - (event) => - `${getUrlElementWithDate(`${origin}/${event.slug}`, event.updatedAt.toISOString())}`, - ) - .join("\n")} - `; -} - -export async function GET(request: NextRequest) { +export async function GET() { try { const events = await getPublishedEvents(); - const content = generateSiteMap(events, mainConfig.instance.origin); + const content = generateSiteMap(events); return new NextResponse(content, { headers: { diff --git a/app/src/lib/event-feeds.ts b/app/src/lib/event-feeds.ts new file mode 100644 index 0000000..b36ace5 --- /dev/null +++ b/app/src/lib/event-feeds.ts @@ -0,0 +1,70 @@ +import type { Event } from "./events"; + +const PUBLIC_SITE_ORIGIN = "https://allthingsweb.dev"; +type FeedEvent = Pick< + Event, + "name" | "tagline" | "slug" | "createdAt" | "updatedAt" +>; + +function escapeXml(value: string): string { + return value + .replace( + /[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu, + "", + ) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function eventUrl(slug: string): string { + return `${PUBLIC_SITE_ORIGIN}/${encodeURIComponent(slug)}`; +} + +export function generateRSS(events: FeedEvent[]) { + return ` + + + All Things Web Bay Area events + Sup! Subscribe to stay up to date with our monthly events. + ${PUBLIC_SITE_ORIGIN} + ${new Date().toUTCString()} + ${events + .map( + (event) => ` + ${escapeXml(event.name)} + ${escapeXml(event.tagline)} + ${escapeXml(eventUrl(event.slug))} + ${event.createdAt.toUTCString()} + `, + ) + .join("\n")} + +`; +} + +function getUrlElement(url: string, date?: string) { + return ` + ${escapeXml(url)} + ${date ? `${date}` : ""} + `; +} + +export function generateSiteMap(events: FeedEvent[]) { + return ` + + ${getUrlElement(`${PUBLIC_SITE_ORIGIN}/`)} + ${getUrlElement(`${PUBLIC_SITE_ORIGIN}/speakers`)} + ${getUrlElement(`${PUBLIC_SITE_ORIGIN}/about`)} + ${events + .map((event) => + getUrlElement( + eventUrl(event.slug), + event.updatedAt.toISOString(), + ), + ) + .join("\n")} + `; +} diff --git a/app/tests/event-feeds.test.ts b/app/tests/event-feeds.test.ts new file mode 100644 index 0000000..0b6085d --- /dev/null +++ b/app/tests/event-feeds.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { XMLParser, XMLValidator } from "fast-xml-parser"; +import { generateRSS, generateSiteMap } from "../src/lib/event-feeds"; + +const event = { + name: "Show & Tell 🚀", + tagline: 'Meet "developers" & share ideas fake', + slug: "2026-show-and-tell", + createdAt: new Date("2026-09-01T00:00:00Z"), + updatedAt: new Date("2026-09-16T02:00:00Z"), +}; + +describe("public event feeds", () => { + test("RSS parses special characters as text, never injected feed elements", () => { + const xml = generateRSS([event]); + expect(XMLValidator.validate(xml)).toBe(true); + const channel = new XMLParser().parse(xml).rss.channel; + expect(channel.item.title).toBe(event.name); + expect(channel.item.description).toBe(event.tagline); + expect(channel.item.link).toBe( + "https://allthingsweb.dev/2026-show-and-tell", + ); + expect(channel.link).toBe("https://allthingsweb.dev"); + expect(channel.item.pubDate).toBe(event.createdAt.toUTCString()); + expect(Array.isArray(channel.item)).toBe(false); + }); + + test("RSS omits invalid XML characters while preserving Unicode and newlines", () => { + const xml = generateRSS([ + { ...event, name: "Hello\u0000\uD800 🚀\nWorld" }, + ]); + expect(XMLValidator.validate(xml)).toBe(true); + expect(new XMLParser().parse(xml).rss.channel.item.title).toBe( + "Hello 🚀\nWorld", + ); + }); + + test("sitemap uses the protocol namespace and public URLs with stable modification dates", () => { + const xml = generateSiteMap([event, { ...event, slug: "a & b#c" }]); + expect(XMLValidator.validate(xml)).toBe(true); + const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml).urlset; + expect(parsed["@_xmlns"]).toBe( + "http://www.sitemaps.org/schemas/sitemap/0.9", + ); + expect(parsed.url.map((url: { loc: string }) => url.loc)).toEqual([ + "https://allthingsweb.dev/", + "https://allthingsweb.dev/speakers", + "https://allthingsweb.dev/about", + "https://allthingsweb.dev/2026-show-and-tell", + "https://allthingsweb.dev/a%20%26%20b%23c", + ]); + expect(parsed.url[3].lastmod).toBe(event.updatedAt.toISOString()); + expect(parsed.url[0].lastmod).toBeUndefined(); + }); + + test("empty event feeds remain valid documents", () => { + expect(XMLValidator.validate(generateRSS([]))).toBe(true); + expect(XMLValidator.validate(generateSiteMap([]))).toBe(true); + }); +});