diff --git a/Dockerfile b/Dockerfile index 14b86080..3d873f9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,8 @@ FROM node:24-slim AS production ENV NODE_ENV=production WORKDIR /app COPY --from=builder /app/build /app/build -COPY --from=builder /app/build-server/server.js /app/server.js +# The bundle splits, and the chunks it imports at runtime sit beside it. +COPY --from=builder /app/build-server/ /app/ EXPOSE 3000 USER node CMD ["node", "server.js"] diff --git a/README.md b/README.md index b6a0cf44..ef220de6 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,9 @@ Or, if you prefer to use a container: docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest ``` -Alternatively, you can copy the build files into a directory of your choice and serve it yourself: +Either one serves the build through `server.js`, which fills in the link preview for each page from the space, person or note its url names. + +Alternatively, you can copy the build files into a directory of your choice and serve it yourself, in which case every page gets the platform's own preview: ```sh mkdir ./mount diff --git a/package.json b/package.json index 8474c08e..28ab6781 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,11 @@ "bump": "node scripts/release/bump.mjs", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check src e2e packages fdroid playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/desktop/*.mjs scripts/release && eslint src e2e packages fdroid capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts", + "lint": "prettier --check src e2e packages fdroid playwright.config.ts capacitor.config.ts vite.config.server.ts server.js server electron/*.ts electron/electron-builder.config.mjs scripts/desktop/*.mjs scripts/release && eslint src e2e packages fdroid capacitor.config.ts vite.config.server.ts server.js server electron/*.ts electron/electron-builder.config.mjs scripts", "test": "playwright test", "test:ui": "playwright test --ui", "test:desktop": "playwright test --config e2e/desktop/playwright.config.ts", + "test:server": "node --test --test-force-exit server/test/*.test.mjs", "test:desktop-storage": "node --test packages/desktop-secure-storage/test/*.test.mjs", "format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write", "format:all": "prettier --write src", diff --git a/server.js b/server.js index 3113dcaa..8f112008 100644 --- a/server.js +++ b/server.js @@ -5,11 +5,10 @@ import {fileURLToPath} from "node:url" import "dotenv/config" import {serve} from "@hono/node-server" import {serveStatic} from "@hono/node-server/serve-static" -import {App, Relays} from "@welshman/app" -import {normalizeRelayUrl} from "@welshman/util" -import {load} from "cheerio" import {Hono} from "hono" +import {createPreview} from "./server/preview.js" + const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -27,202 +26,6 @@ try { process.exit(1) } -const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME -const PLATFORM_DESCRIPTION = process.env.VITE_PLATFORM_DESCRIPTION - -// Match client-side decode logic -const decodeRelay = url => { - try { - return normalizeRelayUrl(decodeURIComponent(url)) - } catch { - return undefined - } -} - -const requestUrlFromContext = context => { - const requestUrl = new URL(context.req.url) - const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim() - const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim() - - if (forwardedProto === "http" || forwardedProto === "https") { - requestUrl.protocol = `${forwardedProto}:` - } - - if (forwardedHost) { - requestUrl.host = forwardedHost - } - - return requestUrl -} - -const relays = new App().use(Relays) - -const fetchRelayMeta = async relayUrl => { - if (!relayUrl) return undefined - try { - return await relays.load(normalizeRelayUrl(relayUrl)) - } catch (err) { - console.error(`Failed to fetch relay metadata for ${relayUrl}:`, err) - return undefined - } -} - -const buildDefaultImage = requestUrl => { - return new URL("/maskable-icon-512x512.png", requestUrl.origin).toString() -} - -const getMetadataForInvite = async (url, match) => { - const relayParam = url.searchParams.get("r") - if (!relayParam) return undefined - - const relayMetadata = await fetchRelayMeta(relayParam) - if (!relayMetadata) return undefined - - const relayDisplay = relayMetadata.displayUrl() - const spaceName = relayMetadata.name - const relayDescription = relayMetadata.description - - const title = spaceName - ? `Invite to ${spaceName} on ${PLATFORM_NAME}` - : `Invite to a Space on ${PLATFORM_NAME}` - - const parts = [] - if (spaceName) { - parts.push(`You are invited to join ${spaceName} on ${PLATFORM_NAME}.`) - } else { - parts.push(`You are invited to join a space on ${PLATFORM_NAME}.`) - } - - if (relayDisplay) parts.push(`Relay: ${relayDisplay}.`) - if (relayDescription) parts.push(relayDescription) - else parts.push(PLATFORM_DESCRIPTION) - - const description = parts.join(" ") - const image = - relayMetadata.icon || relayMetadata.picture || relayMetadata.image || buildDefaultImage(url) - - return { - title, - description, - image, - url: url.toString(), - site: url.origin, - } -} - -const getMetadataForSpace = async (url, match) => { - const relayParam = decodeRelay(match[1]) - if (!relayParam) return undefined - - const relayMetadata = await fetchRelayMeta(relayParam) - if (!relayMetadata) return undefined - - const spaceName = relayMetadata.display() - - return { - title: `${spaceName} on ${PLATFORM_NAME}`, - description: relayMetadata.description || PLATFORM_DESCRIPTION, - image: - relayMetadata.icon || relayMetadata.picture || relayMetadata.image || buildDefaultImage(url), - url: url.toString(), - site: url.origin, - } -} - -const getMetadataForSpaceSection = async (url, match) => { - const spaceMeta = await getMetadataForSpace(url, match) - if (!spaceMeta) return undefined - - const section = match[2] - const sectionName = section.charAt(0).toUpperCase() + section.slice(1) - spaceMeta.title = `${sectionName} on ${spaceMeta.title}` - return spaceMeta -} - -const getMetadataForSpaceItem = async (url, match) => { - const spaceMeta = await getMetadataForSpace(url, match) - if (!spaceMeta) return undefined - - const section = match[2] - let itemType = "Item" - if (section === "calendar") itemType = "Event" - if (section === "threads") itemType = "Thread" - if (section === "polls") itemType = "Poll" - if (section === "goals") itemType = "Goal" - if (section === "classifieds") itemType = "Listing" - - spaceMeta.title = `${itemType} on ${spaceMeta.title}` - return spaceMeta -} - -const getMetadataForRoom = async (url, match) => { - const spaceMeta = await getMetadataForSpace(url, match) - if (!spaceMeta) return undefined - - // Room metadata requires fetching from Nostr, which can be added later. - spaceMeta.title = `Room on ${spaceMeta.title}` - return spaceMeta -} - -const routes = [ - [/^\/join\/?$/, getMetadataForInvite], - [ - /^\/spaces\/([^/]+)\/(calendar|chat|threads|polls|goals|classifieds|recent)\/?$/, - getMetadataForSpaceSection, - ], - [ - /^\/spaces\/([^/]+)\/(calendar|threads|polls|goals|classifieds)\/([^/]+)\/?$/, - getMetadataForSpaceItem, - ], - [/^\/spaces\/([^/]+)\/([^/]+)\/?$/, getMetadataForRoom], - [/^\/spaces\/([^/]+)\/?$/, getMetadataForSpace], -] - -const getMetadataForRoute = async url => { - for (const [regex, getMetadata] of routes) { - const match = url.pathname.match(regex) - if (match) { - try { - return await getMetadata(url, match) - } catch (err) { - console.error(`Error generating metadata for route ${url.pathname}:`, err) - return undefined - } - } - } - return undefined -} - -const injectMeta = metadata => { - const $ = load(TEMPLATE_HTML) - - if (metadata.title) { - $("title").text(metadata.title) - $('meta[property="og:title"]').attr("content", metadata.title) - $('meta[name="twitter:title"]').attr("content", metadata.title) - } - - if (metadata.description) { - $('meta[name="description"]').attr("content", metadata.description) - $('meta[property="og:description"]').attr("content", metadata.description) - $('meta[name="twitter:description"]').attr("content", metadata.description) - } - - if (metadata.image) { - $('meta[property="og:image"]').attr("content", metadata.image) - $('meta[name="twitter:image"]').attr("content", metadata.image) - } - - if (metadata.url) { - $('meta[property="og:url"]').attr("content", metadata.url) - $('meta[name="twitter:site"]').attr("content", metadata.site) - $('meta[name="twitter:url"]').attr("content", metadata.url) - $('link[rel="canonical"]').attr("href", metadata.url) - } - - return $.html() -} - const app = new Hono() // Only allow GET and HEAD requests @@ -261,16 +64,8 @@ app.use( }), ) -// SPA fallback for routes that don't match static files -app.get("*", async context => { - const requestUrl = requestUrlFromContext(context) - const metadata = await getMetadataForRoute(requestUrl) - const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML - - return context.html(html, 200, { - "Cache-Control": metadata ? "no-store" : "no-cache", - }) -}) +// A tld looks like a file extension, so a client route can arrive looking like a missing asset. +app.route("/", createPreview(TEMPLATE_HTML)) serve( { diff --git a/server/preview.js b/server/preview.js new file mode 100644 index 00000000..35e71ad8 --- /dev/null +++ b/server/preview.js @@ -0,0 +1,379 @@ +import * as nip19 from "nostr-tools/nip19" +import {Hono} from "hono" +import {load} from "cheerio" +import {ParsedType, parse, renderAsText, summarize} from "@welshman/content" +import {MINUTE, ellipsize, first, identity, int, ms, sleep, tryCatch} from "@welshman/lib" +import {displayRelayUrl, getIdFilters, normalizeRelayUrl} from "@welshman/util" +import { + Article, + Classified, + Poll, + Thread, + TimeEvent, + ZapGoal, + displayPubkey, +} from "@welshman/domain" +import {Domain, Network, Profiles, Relays, Rooms, createApp, makeRoomKey} from "@welshman/app" + +const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME + +const fromCsv = value => (value || "").split(",").filter(identity) + +const defaultRelays = fromCsv(process.env.VITE_DEFAULT_RELAYS).map(normalizeRelayUrl) + +const app = createApp({ + config: { + getDefaultRelays: () => defaultRelays, + getIndexerRelays: () => fromCsv(process.env.VITE_INDEXER_RELAYS).map(normalizeRelayUrl), + }, +}) + +const oneLine = text => ellipsize((text || "").replace(/\s+/g, " ").trim(), 200) + +// A mention reads as a name rather than as bech32, so whoever is mentioned has to be loaded too. +const readEvent = async event => { + if (event) { + const parsed = parse(event) + const mentions = parsed.filter(node => node.type === ParsedType.Profile) + + await Promise.all(mentions.map(node => app.use(Profiles).load(node.value.pubkey))) + + const getName = node => + node.type === ParsedType.Profile + ? app.use(Profiles).get(node.value.pubkey)?.display() + : undefined + + return oneLine(renderAsText(summarize(parsed, getName)).toString()) + } + + return "" +} + +const decodePubkey = entity => { + const decoded = tryCatch(() => nip19.decode(entity)) + + if (decoded?.type === "npub") { + return decoded.data + } + + if (decoded?.type === "nprofile") { + return decoded.data.pubkey + } +} + +const decodeEvent = entity => { + const decoded = tryCatch(() => nip19.decode(entity)) + + if (decoded?.type === "nevent") { + return {id: decoded.data.id, relays: decoded.data.relays || []} + } + + if (decoded?.type === "note") { + return {id: decoded.data, relays: []} + } + + if (decoded?.type === "naddr") { + const {kind, pubkey, identifier, relays = []} = decoded.data + + return {id: [kind, pubkey, identifier].join(":"), relays} + } +} + +// A relay that never answers its nip-11 request would otherwise hold the page open indefinitely. +const loadSpace = async url => { + const relay = await Promise.race([app.use(Relays).load(url), sleep(ms(2))]) + + return { + url, + name: relay?.display() || displayRelayUrl(url), + description: relay?.description, + image: relay?.icon || relay?.banner, + } +} + +const loadEvent = (idOrAddress, relays) => + app + .use(Network) + .loadLenient({filters: getIdFilters([idOrAddress]), relays}) + .then(first) + +// One describer per route, each naming the space or the platform whatever fails to load. + +const describePerson = async entity => { + const pubkey = decodePubkey(entity) + + if (pubkey) { + const profile = await app.use(Profiles).load(pubkey) + + return { + title: `${profile?.display() || displayPubkey(pubkey)} on ${PLATFORM_NAME}`, + description: oneLine(profile?.about()) || `Someone on ${PLATFORM_NAME}.`, + image: profile?.picture(), + } + } +} + +const describeNote = async entity => { + const pointer = decodeEvent(entity) + + if (pointer) { + const event = await loadEvent(pointer.id, [...pointer.relays, ...defaultRelays]) + const author = event && (await app.use(Profiles).load(event.pubkey)) + + return { + title: author ? `A note by ${author.display()}` : `A note on ${PLATFORM_NAME}`, + description: (await readEvent(event)) || `Somebody's note on ${PLATFORM_NAME}.`, + image: author?.picture(), + } + } +} + +const describeSpace = space => ({ + title: `${space.name} on ${PLATFORM_NAME}`, + description: space.description || `${space.name} is a community on ${PLATFORM_NAME}.`, +}) + +const describeItem = (factory, noun) => async (space, params) => { + const event = await loadEvent(params.id, [space.url]) + const title = event && app.use(Domain).reader(factory)(event).title() + + return { + title: title ? `${title} in ${space.name}` : `${noun} in ${space.name}`, + description: (await readEvent(event)) || space.description || `${noun} in ${space.name}.`, + } +} + +const describeRoom = async (space, params) => { + const room = await app.use(Rooms).load(makeRoomKey(space.url, params.h)) + const name = room?.meta?.name() + + return { + title: name ? `${name} in ${space.name}` : `A room in ${space.name}`, + description: oneLine(room?.meta?.about()) || space.description || `Chat in ${space.name}.`, + } +} + +const requestUrl = context => { + const url = new URL(context.req.url) + const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim() + const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim() + + if (forwardedProto === "http" || forwardedProto === "https") { + url.protocol = `${forwardedProto}:` + } + + if (forwardedHost) { + // The host setter leaves a port already on the url alone unless the new host carries one. + url.port = "" + url.host = forwardedHost + } + + return url +} + +const injectMetadata = (template, metadata, url) => { + const $ = load(template) + + $("title").text(metadata.title) + $('meta[property="og:title"]').attr("content", metadata.title) + $('meta[name="twitter:title"]').attr("content", metadata.title) + $('meta[name="description"]').attr("content", metadata.description) + $('meta[property="og:description"]').attr("content", metadata.description) + $('meta[name="twitter:description"]').attr("content", metadata.description) + $('meta[property="og:image"]').attr("content", metadata.image) + $('meta[name="twitter:image"]').attr("content", metadata.image) + $('meta[property="og:url"]').attr("content", url.toString()) + $('meta[name="twitter:url"]').attr("content", url.toString()) + $('meta[name="twitter:site"]').attr("content", url.origin) + $('link[rel="canonical"]').attr("href", url.toString()) + + return $.html() +} + +const cache = new Map() + +// A preview costs a round trip to a relay, and a crawler asks for the same url several times. +const remember = async (key, describe) => { + const cached = cache.get(key) + + if (cached && cached.expiresAt > Date.now()) { + return cached.metadata + } + + const metadata = await describe() + + if (cache.size > 500) { + cache.clear() + } + + cache.set(key, {metadata, expiresAt: Date.now() + ms(int(5, MINUTE))}) + + return metadata +} + +export const createPreview = template => { + const renderTemplate = context => context.html(template, 200, {"Cache-Control": "no-cache"}) + + const render = describe => async context => { + const url = requestUrl(context) + const metadata = await remember(url.pathname + url.search, () => + describe(url, context.req.param()), + ).catch(error => console.error(`Failed to build a preview for ${url.pathname}:`, error)) + + if (metadata) { + const image = metadata.image || new URL("/maskable-icon-512x512.png", url.origin).toString() + + return context.html(injectMetadata(template, {...metadata, image}, url), 200, { + "Cache-Control": "no-cache", + }) + } + + return renderTemplate(context) + } + + // A space relay answers nothing without auth, so most of these fall back to its nip-11 profile. + const renderSpace = describe => + render(async (url, params) => { + const relayUrl = tryCatch(() => normalizeRelayUrl(params.relay)) + + if (relayUrl) { + const space = await loadSpace(relayUrl) + + return {image: space.image, ...(await describe(space, params))} + } + }) + + const preview = new Hono() + + preview.get( + "/join", + render(async url => { + const relayUrl = tryCatch(() => normalizeRelayUrl(url.searchParams.get("r"))) + + if (relayUrl) { + const space = await loadSpace(relayUrl) + + return { + title: `Join ${space.name} on ${PLATFORM_NAME}`, + description: + space.description || `You are invited to join ${space.name} on ${PLATFORM_NAME}.`, + image: space.image, + } + } + }), + ) + + preview.get( + "/people/:npub", + render((url, params) => describePerson(params.npub)), + ) + preview.get( + "/notes/:nevent", + render((url, params) => describeNote(params.nevent)), + ) + + preview.get( + "/:entity{(npub|nprofile|nevent|note|naddr)1[a-z0-9]+}", + render( + async (url, params) => (await describePerson(params.entity)) || describeNote(params.entity), + ), + ) + + preview.get("/spaces/create", renderTemplate) + preview.get("/spaces/:relay", renderSpace(describeSpace)) + preview.get("/spaces/:relay/admin", renderSpace(describeSpace)) + + preview.get( + "/spaces/:relay/about", + renderSpace(space => ({ + title: `About ${space.name}`, + description: space.description || `What ${space.name} is and who it is for.`, + })), + ) + + preview.get( + "/spaces/:relay/chat", + renderSpace(space => ({ + title: `Chat in ${space.name}`, + description: space.description || `Everybody in ${space.name} talks here.`, + })), + ) + + preview.get( + "/spaces/:relay/directory", + renderSpace(space => ({ + title: `Members of ${space.name}`, + description: `Who is in ${space.name}, and where they are.`, + })), + ) + + preview.get( + "/spaces/:relay/library", + renderSpace(space => ({ + title: `Library of ${space.name}`, + description: `What ${space.name} has collected and kept.`, + })), + ) + + preview.get( + "/spaces/:relay/calendar", + renderSpace(space => ({ + title: `Calendar of ${space.name}`, + description: `What ${space.name} has coming up.`, + })), + ) + + preview.get( + "/spaces/:relay/threads", + renderSpace(space => ({ + title: `Threads in ${space.name}`, + description: `Longer conversations in ${space.name}.`, + })), + ) + + preview.get( + "/spaces/:relay/polls", + renderSpace(space => ({ + title: `Polls in ${space.name}`, + description: `What ${space.name} is deciding.`, + })), + ) + + preview.get( + "/spaces/:relay/goals", + renderSpace(space => ({ + title: `Goals of ${space.name}`, + description: `What ${space.name} is raising money for.`, + })), + ) + + preview.get( + "/spaces/:relay/classifieds", + renderSpace(space => ({ + title: `Classifieds in ${space.name}`, + description: `What ${space.name} is buying and selling.`, + })), + ) + + preview.get( + "/spaces/:relay/articles", + renderSpace(space => ({ + title: `Articles in ${space.name}`, + description: `Long-form writing from ${space.name}.`, + })), + ) + + preview.get("/spaces/:relay/articles/create", renderTemplate) + preview.get("/spaces/:relay/calendar/:id", renderSpace(describeItem(TimeEvent, "An event"))) + preview.get("/spaces/:relay/threads/:id", renderSpace(describeItem(Thread, "A thread"))) + preview.get("/spaces/:relay/polls/:id", renderSpace(describeItem(Poll, "A poll"))) + preview.get("/spaces/:relay/goals/:id", renderSpace(describeItem(ZapGoal, "A goal"))) + preview.get("/spaces/:relay/classifieds/:id", renderSpace(describeItem(Classified, "A listing"))) + preview.get("/spaces/:relay/articles/:id", renderSpace(describeItem(Article, "An article"))) + preview.get("/spaces/:relay/:h", renderSpace(describeRoom)) + + // The built index.html already describes the platform, which is all any other route is about. + preview.get("*", renderTemplate) + + return preview +} diff --git a/server/test/preview.test.mjs b/server/test/preview.test.mjs new file mode 100644 index 00000000..70a7f718 --- /dev/null +++ b/server/test/preview.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import {describe, test} from "node:test" +import {load} from "cheerio" + +process.env.VITE_PLATFORM_NAME = "Flotilla" +process.env.VITE_DEFAULT_RELAYS = "" +process.env.VITE_INDEXER_RELAYS = "" + +const {createPreview} = await import("../preview.js") + +const template = ` +
+