diff --git a/server.js b/server.js index d0fa51b7..427367e8 100644 --- a/server.js +++ b/server.js @@ -1,268 +1,429 @@ -import {createServer} from "node:http" -import {createReadStream} from "node:fs" -import {readFile, stat} from "node:fs/promises" -import {dirname, extname, join, normalize} from "node:path" +// @ts-nocheck +import {readFile} from "node:fs/promises" +import {dirname, extname, join} from "node:path" import {fileURLToPath} from "node:url" +import {load as loadHtml} from "cheerio" +import {serve} from "@hono/node-server" +import {serveStatic} from "@hono/node-server/serve-static" +import {Hono} from "hono" +import {request} from "@welshman/net" +import { + Address, + CLASSIFIED, + EVENT_TIME, + POLL, + ROOM_META, + THREAD, + ZAP_GOAL, + displayPubkey, + getTagValue, + normalizeRelayUrl, + readRoomMeta, +} from "@welshman/util" const __dirname = dirname(fileURLToPath(import.meta.url)) const buildDir = join(__dirname, "build") const indexPath = join(buildDir, "index.html") + +const RELAY_CACHE_TTL_MS = 5 * 60 * 1000 +const NOSTR_CACHE_TTL_MS = 60 * 1000 +const RELAY_TIMEOUT_MS = 1500 +const NOSTR_TIMEOUT_MS = 1800 + +const staticTitles = new Map([ + ["/", "Redirecting"], + ["/home", "Home"], + ["/spaces", "Spaces"], + ["/spaces/create", "Create a Space"], + ["/chat", "Messages"], + ["/join", "Join Space"], + ["/people", "Find People"], + ["/settings/about", "About"], + ["/settings/profile", "Profile Settings"], + ["/settings/content", "Content Settings"], + ["/settings/privacy", "Privacy Settings"], + ["/settings/relays", "Relay Settings"], + ["/settings/alerts", "Alert Settings"], + ["/settings/wallet", "Wallet Settings"], +]) + +const spaceSectionTitles = new Map([ + ["chat", "Space Chat"], + ["recent", "Recent Activity"], + ["threads", "Threads"], + ["classifieds", "Classifieds"], + ["calendar", "Calendar"], + ["goals", "Goals"], + ["polls", "Polls"], +]) + +const eventRouteKinds = new Map([ + ["threads", THREAD], + ["goals", ZAP_GOAL], + ["calendar", EVENT_TIME], + ["classifieds", CLASSIFIED], + ["polls", POLL], +]) + +const reservedSingleSegments = new Set([ + "home", + "spaces", + "space", + "chat", + "join", + "people", + "settings", +]) + +const relayInfoCache = new Map() +const roomInfoCache = new Map() +const eventCache = new Map() + const indexHtml = await readFile(indexPath, "utf8").catch(error => { console.error("Unable to start server: build/index.html is missing. Run `pnpm run build` first.") throw error }) -const defaults = { - title: readMetaContent(indexHtml, "og:title") || "Flotilla", - description: - readMetaContent(indexHtml, "og:description") || - readMetaContent(indexHtml, "description") || - "Flotilla is nostr - for communities.", - url: readMetaContent(indexHtml, "og:url") || "", - image: readMetaContent(indexHtml, "twitter:image") || "/maskable-icon-512x512.png", -} +const defaults = getHtmlDefaults(indexHtml) +const app = new Hono() +const staticFiles = serveStatic({ + root: "./build", + rewriteRequestPath: path => path.replace(/^\/+/, ""), +}) -const relayInfoCache = new Map() -const RELAY_CACHE_TTL_MS = 5 * 60 * 1000 -const RELAY_TIMEOUT_MS = 1500 +app.use("*", staticFiles) -const MIME_TYPES = { - ".css": "text/css; charset=utf-8", - ".gif": "image/gif", - ".html": "text/html; charset=utf-8", - ".ico": "image/x-icon", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".js": "text/javascript; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".map": "application/json; charset=utf-8", - ".mjs": "text/javascript; charset=utf-8", - ".png": "image/png", - ".svg": "image/svg+xml", - ".txt": "text/plain; charset=utf-8", - ".webmanifest": "application/manifest+json; charset=utf-8", - ".woff": "font/woff", - ".woff2": "font/woff2", -} +app.get("*", async c => { + const requestUrl = new URL(c.req.url) -const server = createServer(async (req, res) => { - try { - const method = req.method || "GET" - - if (method !== "GET" && method !== "HEAD") { - res.writeHead(405, {Allow: "GET, HEAD"}) - res.end() - - return - } - - const origin = getRequestOrigin(req) - const requestUrl = new URL(req.url || "/", origin) - const headOnly = method === "HEAD" - - if (requestUrl.pathname === "/healthz") { - sendText(res, "ok", "text/plain; charset=utf-8", headOnly) - - return - } - - if (isJoinInvitePath(requestUrl.pathname)) { - const html = await renderInvitePage(requestUrl, origin) - sendText(res, html, "text/html; charset=utf-8", headOnly) - - return - } - - const filePath = resolveStaticPath(requestUrl.pathname) - - if (filePath) { - try { - const fileInfo = await stat(filePath) - - if (fileInfo.isFile()) { - sendFile(res, filePath, headOnly) - - return - } - } catch { - // Fall through to SPA index fallback. - } - } - - sendText(res, indexHtml, "text/html; charset=utf-8", headOnly) - } catch { - res.writeHead(500, {"Content-Type": "text/plain; charset=utf-8"}) - res.end("Internal Server Error") + if (extname(requestUrl.pathname)) { + return c.text("Not Found", 404) } + + const origin = getRequestOrigin(c.req.raw, requestUrl) + const meta = await buildRouteMeta(requestUrl, origin) + const html = renderHtml(indexHtml, meta) + + c.header("Cache-Control", "no-cache") + + return c.html(html) +}) + +app.notFound(c => c.text("Not Found", 404)) + +app.onError((error, c) => { + console.error(error) + + return c.text("Internal Server Error", 500) }) const port = Number.parseInt(process.env.PORT || "3000", 10) const host = process.env.HOST || "0.0.0.0" -server.listen(port, host, () => { - console.log(`Flotilla server listening on http://${host}:${port}`) -}) +serve({fetch: app.fetch, hostname: host, port}) +console.log(`Flotilla server listening on http://${host}:${port}`) -function resolveStaticPath(pathname) { - const decodedPath = decodeURIComponent(pathname) +async function buildRouteMeta(requestUrl, origin) { + const absoluteDefaultImage = toAbsoluteHttpUrl(defaults.image, origin) - if (decodedPath.includes("\0")) { - return undefined + if (!absoluteDefaultImage) { + throw new Error(`Default twitter:image must resolve to an absolute URL. Found: ${defaults.image}`) } - const relativePath = decodedPath.replace(/^\/+/, "") - const normalizedPath = normalize(relativePath) - - if (normalizedPath.startsWith("..")) { - return undefined - } - - return join(buildDir, normalizedPath) -} - -function sendText(res, body, contentType, headOnly) { - res.writeHead(200, { - "Cache-Control": contentType.includes("text/html") ? "no-cache" : "public, max-age=60", - "Content-Type": contentType, - }) - - if (headOnly) { - res.end() - - return - } - - res.end(body) -} - -function sendFile(res, filePath, headOnly) { - const extension = extname(filePath).toLowerCase() - - res.writeHead(200, { - "Cache-Control": extension === ".html" ? "no-cache" : "public, max-age=31536000, immutable", - "Content-Type": MIME_TYPES[extension] || "application/octet-stream", - }) - - if (headOnly) { - res.end() - - return - } - - const stream = createReadStream(filePath) - - stream.on("error", () => { - if (!res.headersSent) { - res.writeHead(500, {"Content-Type": "text/plain; charset=utf-8"}) - res.end("Internal Server Error") - - return - } - - res.destroy() - }) - - stream.pipe(res) -} - -function isJoinInvitePath(pathname) { - return pathname === "/join" || pathname === "/join/" -} - -async function renderInvitePage(requestUrl, origin) { - const relayUrl = parseInviteRelay(requestUrl) - - if (!relayUrl) { - return indexHtml - } - - const relayInfo = await loadRelayInfo(relayUrl) - const relayDisplay = getRelayDisplay(relayUrl) - const platformName = defaults.title - const image = toAbsoluteHttpUrl(defaults.image, origin) || `${origin}/maskable-icon-512x512.png` - - const title = relayInfo?.name - ? `Invitation to join ${relayInfo.name} on ${platformName}` - : `Invitation to join a ${platformName} space` - - const description = relayInfo?.description || `Join this ${platformName} space on ${relayDisplay}.` - const meta = { card: "summary", - description, - image, - site: defaults.url || origin, - title, + description: defaults.description, + image: absoluteDefaultImage, + site: defaults.site, + title: defaults.title, type: "website", url: requestUrl.href, } - return setInviteMeta(indexHtml, meta) + const route = parseRoute(requestUrl.pathname) + + if (route.kind === "join") { + return await buildJoinMeta(meta, requestUrl, origin) + } + + if (route.kind === "static") { + meta.title = route.title + + return meta + } + + if (route.kind === "chat") { + meta.title = getChatTitle(route.chat) + + return meta + } + + if (route.kind === "bech32") { + meta.title = "Opening Link" + + return meta + } + + if (!route.relay) { + return meta + } + + const relayUrl = normalizeRelayParam(route.relay) + const relayInfo = relayUrl ? await loadRelayInfo(relayUrl) : undefined + const relayName = relayInfo?.name || (relayUrl ? getRelayDisplay(relayUrl) : "Space") + const relayHttpUrl = relayUrl ? toRelayHttpUrl(relayUrl) : undefined + + if (relayInfo?.icon) { + meta.image = relayInfo.icon + } + + if (relayInfo?.description) { + meta.description = relayInfo.description + } + + if (route.kind === "space") { + meta.title = relayName + + return meta + } + + if (route.kind === "space-section") { + meta.title = composeSpaceTitle(relayName, route.sectionTitle) + + return meta + } + + if (route.kind === "room") { + const roomInfo = relayUrl ? await loadRoomInfo(relayUrl, route.h) : undefined + const roomName = roomInfo?.name || route.h + + meta.title = composeSpaceTitle(relayName, roomName) + meta.description = roomInfo?.about || meta.description + + const roomImage = roomInfo?.picture + ? toAbsoluteHttpUrl(roomInfo.picture, relayHttpUrl || origin) + : undefined + + if (roomImage) { + meta.image = roomImage + } + + return meta + } + + if (route.kind === "event") { + const event = relayUrl + ? await loadEventForRoute(relayUrl, route.section, route.identifier) + : undefined + const eventTitle = getEventTitle(route.section, event) + + meta.title = composeSpaceTitle(relayName, eventTitle) + meta.description = getEventDescription(route.section, event, meta.description) + + const eventImage = getTagValue("image", event?.tags || []) + const absoluteEventImage = eventImage + ? toAbsoluteHttpUrl(eventImage, relayHttpUrl || origin) + : undefined + + if (absoluteEventImage) { + meta.image = absoluteEventImage + meta.card = "summary_large_image" + } + + return meta + } + + return meta } -function parseInviteRelay(requestUrl) { - const relay = requestUrl.searchParams.get("r") || requestUrl.searchParams.get("relay") +function parseRoute(pathname) { + const normalizedPath = normalizePathname(pathname) - if (!relay) { - return undefined + if (normalizedPath === "/join") { + return {kind: "join"} } - return normalizeInviteRelay(relay) + if (staticTitles.has(normalizedPath)) { + return {kind: "static", title: staticTitles.get(normalizedPath)} + } + + const segments = getPathSegments(normalizedPath) + + if (segments.length === 2 && segments[0] === "chat") { + return {chat: segments[1], kind: "chat"} + } + + if (segments.length === 1 && !reservedSingleSegments.has(segments[0])) { + return {bech32: segments[0], kind: "bech32"} + } + + if ((segments[0] === "spaces" || segments[0] === "space") && segments.length >= 2) { + const relay = segments[1] + + if (segments.length === 2) { + return {kind: "space", relay} + } + + const section = segments[2] + + if (segments.length === 3) { + if (spaceSectionTitles.has(section)) { + return { + kind: "space-section", + relay, + section, + sectionTitle: spaceSectionTitles.get(section), + } + } + + return {h: section, kind: "room", relay} + } + + if (segments.length === 4 && eventRouteKinds.has(section)) { + return { + identifier: segments[3], + kind: "event", + relay, + section, + } + } + } + + return {kind: "unknown"} } -function normalizeInviteRelay(value) { - const trimmed = value.trim() +async function buildJoinMeta(meta, requestUrl, origin) { + const relayUrl = parseInviteRelay(requestUrl) - if (!trimmed) { - return undefined + if (!relayUrl) { + meta.title = staticTitles.get("/join") || "Join Space" + + return meta } - const hasProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed) - const normalized = hasProtocol ? trimmed : `wss://${trimmed}` + const relayInfo = await loadRelayInfo(relayUrl) + const relayDisplay = relayInfo?.name || getRelayDisplay(relayUrl) - try { - const relayUrl = new URL(normalized) + meta.title = `Invitation to join ${relayDisplay}` + meta.description = relayInfo?.description || `Join this Flotilla space on ${relayDisplay}.` + meta.image = relayInfo?.icon || meta.image + meta.url = requestUrl.href + meta.site = defaults.site - if (relayUrl.protocol === "http:") { - relayUrl.protocol = "ws:" - } + return meta +} - if (relayUrl.protocol === "https:") { - relayUrl.protocol = "wss:" - } - - if (!["ws:", "wss:"].includes(relayUrl.protocol)) { - return undefined - } - - relayUrl.hash = "" - relayUrl.search = "" - - return relayUrl.href.replace(/\/$/, "") - } catch { - return undefined +function getChatTitle(chat) { + if (!chat) { + return "Chat" } + + const peers = chat + .split(",") + .map(part => part.trim()) + .filter(Boolean) + + if (peers.length === 1) { + return `Chat with ${displayPubkey(peers[0])}` + } + + if (peers.length > 1) { + return `Group chat (${peers.length})` + } + + return "Chat" +} + +function getEventTitle(section, event) { + if (section === "threads") { + return getTagValue("title", event?.tags || []) || "Thread" + } + + if (section === "calendar") { + return getTagValue("title", event?.tags || []) || "Event" + } + + if (section === "classifieds") { + return getTagValue("title", event?.tags || []) || "Listing" + } + + if (section === "goals") { + return event?.content?.trim() || getTagValue("summary", event?.tags || []) || "Goal" + } + + if (section === "polls") { + return getTagValue("title", event?.tags || []) || "Poll" + } + + return "Event" +} + +function getEventDescription(section, event, fallback) { + const summary = + getTagValue("summary", event?.tags || []) || getTagValue("description", event?.tags || []) + + if (summary) { + return clip(summary, 220) + } + + if (event?.content?.trim()) { + return clip(event.content.trim(), 220) + } + + if (section === "threads") { + return "Read this thread in Flotilla." + } + + if (section === "goals") { + return "Track this goal in Flotilla." + } + + if (section === "calendar") { + return "View this calendar event in Flotilla." + } + + if (section === "classifieds") { + return "Browse this listing in Flotilla." + } + + if (section === "polls") { + return "Take this poll in Flotilla." + } + + return fallback +} + +function composeSpaceTitle(spaceName, leafTitle) { + const cleanedSpace = spaceName?.trim() + const cleanedLeaf = leafTitle?.trim() + + if (cleanedSpace && cleanedLeaf) { + return `${cleanedSpace} / ${cleanedLeaf}` + } + + return cleanedLeaf || cleanedSpace || defaults.title } async function loadRelayInfo(relayUrl) { - const now = Date.now() - const cached = relayInfoCache.get(relayUrl) + const cached = getCachedValue(relayInfoCache, relayUrl) - if (cached && cached.expiresAt > now) { - return cached.value + if (cached) { + return cached } const relayHttpUrl = toRelayHttpUrl(relayUrl) if (!relayHttpUrl) { + setCachedValue(relayInfoCache, relayUrl, undefined, RELAY_CACHE_TTL_MS) + return undefined } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS) - let relayInfo + let value try { const response = await fetch(relayHttpUrl, { @@ -272,29 +433,237 @@ async function loadRelayInfo(relayUrl) { if (response.ok) { const json = await response.json() - const name = typeof json.name === "string" ? json.name.trim() : "" const description = typeof json.description === "string" ? json.description.trim() : "" const icon = typeof json.icon === "string" ? toAbsoluteHttpUrl(json.icon, relayHttpUrl) : undefined - relayInfo = { + value = { description: description || undefined, icon, name: name || undefined, } } } catch { - relayInfo = undefined + value = undefined } finally { clearTimeout(timeout) } - relayInfoCache.set(relayUrl, { - expiresAt: now + RELAY_CACHE_TTL_MS, - value: relayInfo, - }) + setCachedValue(relayInfoCache, relayUrl, value, RELAY_CACHE_TTL_MS) - return relayInfo + return value +} + +async function loadRoomInfo(relayUrl, h) { + const cacheKey = `${relayUrl}|${h}` + const cached = getCachedValue(roomInfoCache, cacheKey) + + if (cached !== undefined) { + return cached + } + + const events = await requestEvents(relayUrl, [{"#d": [h], kinds: [ROOM_META], limit: 20}]) + const roomMetas = [] + + for (const event of events) { + try { + const roomMeta = readRoomMeta(event) + + if (roomMeta.h === h) { + roomMetas.push(roomMeta) + } + } catch { + // Ignore malformed room metadata. + } + } + + const latest = roomMetas.sort((a, b) => b.event.created_at - a.event.created_at)[0] + const roomInfo = latest + ? { + about: latest.about, + name: latest.name, + picture: latest.picture, + } + : undefined + + setCachedValue(roomInfoCache, cacheKey, roomInfo, NOSTR_CACHE_TTL_MS) + + return roomInfo +} + +async function loadEventForRoute(relayUrl, section, identifier) { + const kind = eventRouteKinds.get(section) + + if (!kind || !identifier) { + return undefined + } + + const cacheKey = `${relayUrl}|${section}|${identifier}` + const cached = getCachedValue(eventCache, cacheKey) + + if (cached !== undefined) { + return cached + } + + const filters = getEventFilters(kind, identifier) + const events = filters.length > 0 ? await requestEvents(relayUrl, filters) : [] + const event = events[0] + + setCachedValue(eventCache, cacheKey, event, NOSTR_CACHE_TTL_MS) + + return event +} + +function getEventFilters(kind, identifier) { + if (kind === EVENT_TIME || kind === CLASSIFIED) { + try { + const address = Address.from(identifier) + + return [ + { + "#d": [address.identifier], + authors: [address.pubkey], + kinds: [address.kind], + limit: 1, + }, + ] + } catch { + return [{ids: [identifier], kinds: [kind], limit: 1}] + } + } + + return [{ids: [identifier], kinds: [kind], limit: 1}] +} + +async function requestEvents(relayUrl, filters) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), NOSTR_TIMEOUT_MS) + + try { + return await request({ + autoClose: true, + filters, + relays: [relayUrl], + signal: controller.signal, + }) + } catch { + return [] + } finally { + clearTimeout(timeout) + } +} + +function renderHtml(html, meta) { + const $ = loadHtml(html) + + upsertTitle($, meta.title) + upsertMetaTag($, "name", "description", meta.description) + upsertMetaTag($, "name", "og:url", meta.url) + upsertMetaTag($, "name", "og:type", meta.type) + upsertMetaTag($, "name", "og:title", meta.title) + upsertMetaTag($, "name", "og:description", meta.description) + upsertMetaTag($, "name", "twitter:card", meta.card) + upsertMetaTag($, "name", "twitter:site", meta.site) + upsertMetaTag($, "name", "twitter:title", meta.title) + upsertMetaTag($, "name", "twitter:description", meta.description) + upsertMetaTag($, "name", "twitter:image", meta.image) + + upsertMetaTag($, "property", "og:url", meta.url) + upsertMetaTag($, "property", "og:type", meta.type) + upsertMetaTag($, "property", "og:title", meta.title) + upsertMetaTag($, "property", "og:description", meta.description) + upsertMetaTag($, "property", "og:image", meta.image) + + return $.html() +} + +function upsertTitle($, value) { + let titleTag = $("head > title").first() + + if (titleTag.length === 0) { + $("head").prepend("