feat: improve og invite preview

This commit is contained in:
2026-04-17 23:03:48 +05:30
parent 1c8457a4bf
commit ff5bd8b092
4 changed files with 383 additions and 2 deletions
+2 -1
View File
@@ -28,5 +28,6 @@ WORKDIR /app
# Copy only the built output - no source, no .env, no dev deps
COPY --from=builder /app/build ./build
COPY --from=builder /app/server.mjs ./server.mjs
CMD ["npx", "serve", "-s", "build"]
CMD ["node", "server.mjs"]
+1 -1
View File
@@ -31,7 +31,7 @@ To run your own Flotilla, it's as simple as:
```sh
pnpm install
pnpm run build
npx serve -s build
pnpm run start
```
Or, if you prefer to use a container:
+1
View File
@@ -5,6 +5,7 @@
"scripts": {
"dev": "vite dev",
"build": "./build.sh",
"start": "node server.mjs",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
+379
View File
@@ -0,0 +1,379 @@
import http from "node:http"
import path from "node:path"
import {promises as fs} from "node:fs"
const PORT = Number(process.env.PORT || 3000)
const BUILD_DIR = path.resolve("build")
const INDEX_HTML_PATH = path.join(BUILD_DIR, "index.html")
const DEFAULT_IMAGE_PATH = "/maskable-icon-512x512.png"
const CACHE_SECONDS = 60
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".ico": "image/x-icon",
".txt": "text/plain; charset=utf-8",
".map": "application/json; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
}
const BOT_UA_REGEX =
/bot|crawler|spider|slackbot|telegrambot|twitterbot|facebookexternalhit|discordbot|linkedinbot|whatsapp|skypeuripreview|applebot|googlebot/i
const relayMetadataCache = new Map()
const htmlEscape = value =>
String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
const isBotRequest = req => BOT_UA_REGEX.test(String(req.headers["user-agent"] || ""))
const relayToHttpUrl = relay => {
try {
const url = new URL(relay)
if (url.protocol === "wss:") {
url.protocol = "https:"
} else if (url.protocol === "ws:") {
url.protocol = "http:"
}
return url.toString()
} catch {
return null
}
}
const normalizeRelayParam = relayParam => {
const relay = String(relayParam || "").trim()
if (!relay) {
return null
}
try {
const parsed = new URL(relay.includes("://") ? relay : `wss://${relay}`)
if (!["ws:", "wss:", "http:", "https:"].includes(parsed.protocol)) {
return null
}
if (parsed.protocol === "http:") {
parsed.protocol = "ws:"
}
if (parsed.protocol === "https:") {
parsed.protocol = "wss:"
}
return parsed.toString()
} catch {
return null
}
}
const fetchJsonWithTimeout = async (url, timeoutMs = 5000) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(url, {
method: "GET",
headers: {Accept: "application/nostr+json, application/json"},
signal: controller.signal,
})
if (!response.ok) {
return null
}
return await response.json()
} catch {
return null
} finally {
clearTimeout(timeout)
}
}
const getRelayMetadata = async relayUrl => {
if (!relayUrl) {
return null
}
const now = Date.now()
const cached = relayMetadataCache.get(relayUrl)
if (cached && now - cached.at < CACHE_SECONDS * 1000) {
return cached.value
}
const httpUrl = relayToHttpUrl(relayUrl)
if (!httpUrl) {
return null
}
const profile = await fetchJsonWithTimeout(httpUrl)
const value = {
name: profile?.name || profile?.title || null,
icon: profile?.icon || profile?.picture || null,
}
relayMetadataCache.set(relayUrl, {at: now, value})
return value
}
const getAbsoluteUrl = req => {
const host = req.headers["x-forwarded-host"] || req.headers.host || `localhost:${PORT}`
const protocolHeader = req.headers["x-forwarded-proto"]
const protocol = String(protocolHeader || "").split(",")[0] || "http"
return new URL(req.url || "/", `${protocol}://${host}`)
}
const getLocalRequestUrl = req => {
const host = req.headers.host || `localhost:${PORT}`
return new URL(req.url || "/", `http://${host}`)
}
const toAbsoluteImageUrl = (url, origin) => {
try {
return new URL(url, origin).toString()
} catch {
return new URL(DEFAULT_IMAGE_PATH, origin).toString()
}
}
const escapeRegex = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const replaceOrInsertMeta = (html, keyValue, content, preferredKey = "name") => {
const escapedValue = escapeRegex(keyValue)
const tag = `<meta ${preferredKey}="${htmlEscape(keyValue)}" content="${htmlEscape(content)}" />`
const pattern = new RegExp(`<meta[^>]*(?:name|property)=["']${escapedValue}["'][^>]*>`, "i")
if (pattern.test(html)) {
return html.replace(pattern, tag)
}
return html.replace("</head>", ` ${tag}\n</head>`)
}
const replaceOrInsertTitle = (html, title) => {
const tag = `<title>${htmlEscape(title)}</title>`
if (/<title[^>]*>.*<\/title>/i.test(html)) {
return html.replace(/<title[^>]*>.*<\/title>/i, tag)
}
return html.replace("</head>", ` ${tag}\n</head>`)
}
const replaceOrInsertCanonical = (html, href) => {
const tag = `<link rel="canonical" href="${htmlEscape(href)}" />`
const pattern = /<link[^>]*rel=["']canonical["'][^>]*>/i
if (pattern.test(html)) {
return html.replace(pattern, tag)
}
return html.replace("</head>", ` ${tag}\n</head>`)
}
const injectInviteMetadata = (indexHtml, {title, description, image, ogUrl}) => {
let html = indexHtml
html = replaceOrInsertTitle(html, title)
html = replaceOrInsertMeta(html, "description", description, "name")
html = replaceOrInsertMeta(html, "og:title", title, "property")
html = replaceOrInsertMeta(html, "og:description", description, "property")
html = replaceOrInsertMeta(html, "og:image", image, "property")
html = replaceOrInsertMeta(html, "og:url", ogUrl, "property")
html = replaceOrInsertMeta(html, "og:type", "website", "property")
html = replaceOrInsertMeta(html, "twitter:card", "summary_large_image", "name")
html = replaceOrInsertMeta(html, "twitter:title", title, "name")
html = replaceOrInsertMeta(html, "twitter:description", description, "name")
html = replaceOrInsertMeta(html, "twitter:image", image, "name")
html = replaceOrInsertCanonical(html, ogUrl)
return html
}
const loadIndexTemplate = async () => {
try {
return await fs.readFile(INDEX_HTML_PATH, "utf-8")
} catch {
return [
"<!doctype html>",
'<html lang="en">',
"<head>",
' <meta charset="utf-8" />',
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
"</head>",
"<body>",
" <div id=\"app\"></div>",
"</body>",
"</html>",
].join("\n")
}
}
const getSpaceMetadata = async (r, c) => {
const relay = String(r || "").trim()
const claim = String(c || "").trim()
let name = "Space Name"
if (relay && claim) {
name = `Space (${claim})`
} else if (claim) {
name = `Space (${claim})`
} else if (relay) {
name = `Space (${relay})`
}
return {
name,
icon: null,
}
}
const send = (res, statusCode, body, contentType = "text/plain; charset=utf-8") => {
res.writeHead(statusCode, {
"Content-Type": contentType,
"Cache-Control": "no-store",
})
res.end(body)
}
const redirect = (res, location, statusCode = 302) => {
res.writeHead(statusCode, {
Location: location,
"Cache-Control": "no-store",
})
res.end()
}
const fileExists = async filePath => {
try {
const stat = await fs.stat(filePath)
return stat.isFile()
} catch {
return false
}
}
const serveIndexOrFallback = async res => {
if (await fileExists(INDEX_HTML_PATH)) {
await serveFile(res, INDEX_HTML_PATH)
return
}
const fallbackHtml = await loadIndexTemplate()
send(res, 200, fallbackHtml, "text/html; charset=utf-8")
}
const serveFile = async (res, filePath) => {
try {
const data = await fs.readFile(filePath)
const ext = path.extname(filePath).toLowerCase()
const contentType = MIME_TYPES[ext] || "application/octet-stream"
res.writeHead(200, {
"Content-Type": contentType,
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
})
res.end(data)
} catch {
send(res, 404, "Not Found")
}
}
const sanitizeRequestedPath = pathname => {
const safePath = path.normalize(pathname).replace(/^\.+\//, "")
const candidatePath = path.join(BUILD_DIR, safePath)
if (!candidatePath.startsWith(BUILD_DIR)) {
return null
}
return candidatePath
}
const server = http.createServer(async (req, res) => {
try {
const method = req.method || "GET"
if (!["GET", "HEAD"].includes(method)) {
send(res, 405, "Method Not Allowed")
return
}
const requestUrl = getAbsoluteUrl(req)
const pathname = decodeURIComponent(requestUrl.pathname)
if (pathname.startsWith("/join")) {
const url = getLocalRequestUrl(req)
const r = url.searchParams.get("r")
const c = url.searchParams.get("c")
const space = await getSpaceMetadata(r, c)
const spaceName = space?.name || "Space Name"
const title = `Invitation to ${spaceName} • Flotilla`
const description = `Join ${spaceName} on Flotilla - a Nostr community space`
const image = toAbsoluteImageUrl(space?.icon || DEFAULT_IMAGE_PATH, requestUrl.origin)
const indexHtml = await loadIndexTemplate()
const ogHtml = injectInviteMetadata(indexHtml, {
title,
description,
image,
ogUrl: requestUrl.toString(),
})
if (isBotRequest(req)) {
send(res, 200, ogHtml, "text/html; charset=utf-8")
return
}
await serveIndexOrFallback(res)
return
}
const directFilePath = sanitizeRequestedPath(pathname)
if (!directFilePath) {
send(res, 400, "Bad Request")
return
}
const stat = await fs.stat(directFilePath).catch(() => null)
if (stat?.isFile()) {
await serveFile(res, directFilePath)
return
}
await serveIndexOrFallback(res)
} catch {
send(res, 500, "Internal Server Error")
}
})
server.listen(PORT, () => {
console.log(`Flotilla server running on port ${PORT}`)
})