fix(metadata): add case-insensitive HTML title fallback parsing for invite links

This commit is contained in:
2026-04-24 19:53:43 +05:30
committed by hodlbod
parent 8a0abacf6f
commit 01b5d990a6
21 changed files with 694 additions and 6 deletions
+4 -1
View File
@@ -28,5 +28,8 @@ 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.js ./server.js
CMD ["npx", "serve", "-s", "build"]
EXPOSE 3000
CMD ["node", "server.js"]
+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:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

+1
View File
@@ -5,6 +5,7 @@
"scripts": {
"dev": "vite dev",
"build": "./build.sh",
"start": "node server.js",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
+684
View File
@@ -0,0 +1,684 @@
import http from "node:http"
import path from "node:path"
import {createReadStream} from "node:fs"
import {promises as fs} from "node:fs"
import {fileURLToPath} from "node:url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const BUILD_DIR = path.join(__dirname, "build")
const INDEX_PATH = path.join(BUILD_DIR, "index.html")
const readPositiveInt = (value, fallback) => {
const parsed = Number.parseInt(value || "", 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
const PORT = readPositiveInt(process.env.PORT, 3000)
const HOST = process.env.HOST || "0.0.0.0"
const REQUEST_TIMEOUT_MS = readPositiveInt(process.env.INVITE_PREVIEW_TIMEOUT_MS, 1200)
const CACHE_MAX_ITEMS = readPositiveInt(process.env.INVITE_PREVIEW_CACHE_MAX, 500)
const POSITIVE_CACHE_TTL_MS = readPositiveInt(
process.env.INVITE_PREVIEW_CACHE_TTL_MS,
15 * 60 * 1000,
)
const NEGATIVE_CACHE_TTL_MS = readPositiveInt(
process.env.INVITE_PREVIEW_NEGATIVE_CACHE_TTL_MS,
2 * 60 * 1000,
)
const MIME_TYPES = Object.freeze({
".avif": "image/avif",
".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",
".png": "image/png",
".svg": "image/svg+xml",
".txt": "text/plain; charset=utf-8",
".webmanifest": "application/manifest+json; charset=utf-8",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
})
const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const escapeHtml = value =>
value
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
const truncate = (value, limit) => {
if (value.length <= limit) {
return value
}
if (limit <= 3) {
return value.slice(0, limit)
}
return `${value.slice(0, limit - 3)}...`
}
const sanitizeText = (value, limit) => {
if (typeof value !== "string") {
return undefined
}
const compact = value.replace(/\s+/g, " ").trim()
if (!compact) {
return undefined
}
return truncate(compact, limit)
}
const isRecord = value => Boolean(value) && typeof value === "object" && !Array.isArray(value)
const readMetaContent = (html, key) => {
const pattern = new RegExp(
`<meta\\s+[^>]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*content=["']([^"']*)["'][^>]*>`,
"i",
)
const match = html.match(pattern)
return match?.[1]
}
const upsertTitle = (html, title) => {
const escapedTitle = escapeHtml(title)
const pattern = /<title[^>]*>.*?<\/title>/is
if (pattern.test(html)) {
return html.replace(pattern, `<title>${escapedTitle}</title>`)
}
return html.replace("</head>", ` <title>${escapedTitle}</title>\n </head>`)
}
const upsertMetaTag = (html, key, content, attribute) => {
const pattern = new RegExp(
`<meta\\s+[^>]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*>`,
"i",
)
const tag = `<meta ${attribute}="${key}" content="${escapeHtml(content)}" />`
if (pattern.test(html)) {
return html.replace(pattern, tag)
}
return html.replace("</head>", ` ${tag}\n </head>`)
}
const upsertCanonical = (html, href) => {
const pattern = /<link\s+[^>]*rel=["']canonical["'][^>]*>/i
const tag = `<link rel="canonical" href="${escapeHtml(href)}" />`
if (pattern.test(html)) {
return html.replace(pattern, tag)
}
return html.replace("</head>", ` ${tag}\n </head>`)
}
const normalizeRelayInput = value => {
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `wss://${trimmed}`
try {
const relayUrl = new URL(withScheme)
if (!["ws:", "wss:", "http:", "https:"].includes(relayUrl.protocol)) {
return undefined
}
relayUrl.hash = ""
relayUrl.search = ""
if (relayUrl.pathname !== "/") {
relayUrl.pathname = relayUrl.pathname.replace(/\/+$/, "")
}
return relayUrl.toString()
} catch {
return undefined
}
}
const relayToInfoUrl = relayUrl => {
try {
const relayHttpUrl = new URL(relayUrl)
if (relayHttpUrl.protocol === "ws:") relayHttpUrl.protocol = "http:"
if (relayHttpUrl.protocol === "wss:") relayHttpUrl.protocol = "https:"
if (!["http:", "https:"].includes(relayHttpUrl.protocol)) {
return undefined
}
relayHttpUrl.hash = ""
relayHttpUrl.search = ""
return relayHttpUrl.toString()
} catch {
return undefined
}
}
const normalizeImageUrl = (value, baseUrl) => {
if (typeof value !== "string") {
return undefined
}
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
try {
const imageUrl = new URL(trimmed, baseUrl)
if (!["http:", "https:"].includes(imageUrl.protocol)) {
return undefined
}
return imageUrl.toString()
} catch {
return undefined
}
}
const decodeHtmlEntities = value =>
value
.replaceAll("&amp;", "&")
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
const readHtmlAttribute = (tag, key) => {
const pattern = new RegExp(`${escapeRegExp(key)}=["']([^"']*)["']`, "i")
const match = tag.match(pattern)
return match?.[1]
}
const readHtmlTagContent = (html, tag) => {
const pattern = new RegExp(`<${escapeRegExp(tag)}[^>]*>([\\s\\S]*?)<\\/${escapeRegExp(tag)}>`, "i")
const match = html.match(pattern)
return match?.[1]
}
const readHtmlMetaContent = (html, key) => {
const forwardPattern = new RegExp(
`<meta\\s+[^>]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*content=["']([^"']*)["'][^>]*>`,
"i",
)
const reversePattern = new RegExp(
`<meta\\s+[^>]*content=["']([^"']*)["'][^>]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*>`,
"i",
)
return html.match(forwardPattern)?.[1] || html.match(reversePattern)?.[1]
}
const readHtmlIconHref = html => {
const links = html.match(/<link\s+[^>]*>/gi) || []
for (const link of links) {
const rel = readHtmlAttribute(link, "rel")?.toLowerCase() || ""
if (!rel.includes("icon")) {
continue
}
const href = readHtmlAttribute(link, "href")
if (href) {
return href
}
}
}
const requestOrigin = request => {
const forwardedProto = request.headers["x-forwarded-proto"]
const forwardedHost = request.headers["x-forwarded-host"]
const host =
(typeof forwardedHost === "string" && forwardedHost.split(",")[0].trim()) ||
request.headers.host ||
"localhost"
const protocol =
(typeof forwardedProto === "string" && forwardedProto.split(",")[0].trim()) ||
(request.socket.encrypted ? "https" : "http")
return `${protocol}://${host}`
}
const absoluteUrlFromRequest = (requestUrl, value) => {
try {
return new URL(value, `${requestUrl.protocol}//${requestUrl.host}`).toString()
} catch {
return value
}
}
const safeBuildPath = pathname => {
let decodedPath = pathname
try {
decodedPath = decodeURIComponent(pathname)
} catch {
return undefined
}
const normalizedPath = path.posix.normalize(decodedPath)
const routePath = normalizedPath === "/" ? "/index.html" : normalizedPath
const resolvedPath = path.resolve(BUILD_DIR, `.${routePath}`)
if (!resolvedPath.startsWith(BUILD_DIR + path.sep) && resolvedPath !== BUILD_DIR) {
return undefined
}
return resolvedPath
}
const findStaticFile = async pathname => {
const candidatePath = safeBuildPath(pathname)
if (!candidatePath) {
return undefined
}
try {
const stats = await fs.stat(candidatePath)
if (stats.isFile()) {
return {filePath: candidatePath, size: stats.size}
}
if (stats.isDirectory()) {
const nestedIndex = path.join(candidatePath, "index.html")
const indexStats = await fs.stat(nestedIndex)
if (indexStats.isFile()) {
return {filePath: nestedIndex, size: indexStats.size}
}
}
} catch {
return undefined
}
return undefined
}
const cacheByRelay = new Map()
const inFlightByRelay = new Map()
const getCachedRelayData = relayUrl => {
const cached = cacheByRelay.get(relayUrl)
if (cached === undefined) {
return undefined
}
if (cached.expiresAt <= Date.now()) {
cacheByRelay.delete(relayUrl)
return undefined
}
cacheByRelay.delete(relayUrl)
cacheByRelay.set(relayUrl, cached)
return cached.value
}
const setCachedRelayData = (relayUrl, value, ttlMs) => {
if (cacheByRelay.size >= CACHE_MAX_ITEMS) {
const oldestKey = cacheByRelay.keys().next().value
if (oldestKey !== undefined) {
cacheByRelay.delete(oldestKey)
}
}
cacheByRelay.set(relayUrl, {
expiresAt: Date.now() + ttlMs,
value,
})
}
const fetchRelayMetadata = async relayUrl => {
const cached = getCachedRelayData(relayUrl)
if (cached !== undefined) {
return cached
}
const inFlight = inFlightByRelay.get(relayUrl)
if (inFlight !== undefined) {
return inFlight
}
const loader = (async () => {
const infoUrl = relayToInfoUrl(relayUrl)
if (!infoUrl) {
const empty = {}
setCachedRelayData(relayUrl, empty, NEGATIVE_CACHE_TTL_MS)
return empty
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
let metadata = {}
try {
const response = await fetch(infoUrl, {
method: "GET",
headers: {
Accept: "application/nostr+json, application/json;q=0.9, */*;q=0.1",
},
redirect: "follow",
signal: controller.signal,
})
if (response.ok) {
const text = await response.text()
let payload
try {
const parsed = JSON.parse(text)
payload = isRecord(parsed) ? parsed : undefined
} catch {
payload = undefined
}
const jsonName = sanitizeText(payload?.name || payload?.title, 80)
const jsonDescription = sanitizeText(payload?.description, 180)
const jsonIcon = normalizeImageUrl(payload?.icon || payload?.picture || payload?.image, infoUrl)
metadata = {
...(jsonName ? {name: jsonName} : {}),
...(jsonDescription ? {description: jsonDescription} : {}),
...(jsonIcon ? {icon: jsonIcon} : {}),
}
if (Object.keys(metadata).length === 0) {
const htmlName = sanitizeText(decodeHtmlEntities(readHtmlTagContent(text, "title") || ""), 80)
const htmlDescription = sanitizeText(
decodeHtmlEntities(readHtmlMetaContent(text, "description") || ""),
180,
)
const htmlIcon = normalizeImageUrl(
decodeHtmlEntities(
readHtmlMetaContent(text, "og:image") ||
readHtmlMetaContent(text, "twitter:image") ||
readHtmlIconHref(text) ||
"",
),
infoUrl,
)
metadata = {
...(htmlName ? {name: htmlName} : {}),
...(htmlDescription ? {description: htmlDescription} : {}),
...(htmlIcon ? {icon: htmlIcon} : {}),
}
}
}
} catch {
metadata = {}
} finally {
clearTimeout(timeout)
}
setCachedRelayData(
relayUrl,
metadata,
Object.keys(metadata).length > 0 ? POSITIVE_CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS,
)
return metadata
})()
inFlightByRelay.set(relayUrl, loader)
try {
return await loader
} finally {
inFlightByRelay.delete(relayUrl)
}
}
const isJoinInvitePath = pathname => pathname === "/join" || pathname === "/join/"
const parseInvite = requestUrl => {
const relayParam = requestUrl.searchParams.get("r") || ""
const relayUrl = normalizeRelayInput(relayParam)
if (!relayUrl) {
return undefined
}
const claim = sanitizeText(requestUrl.searchParams.get("c") || "", 256) || ""
return {relayUrl, claim}
}
const loadIndexTemplate = async () => {
try {
return await fs.readFile(INDEX_PATH, "utf8")
} catch (error) {
console.error(`Unable to read ${INDEX_PATH}. Run \"pnpm run build\" first.`)
throw error
}
}
const INDEX_TEMPLATE = await loadIndexTemplate()
const DEFAULT_PLATFORM_NAME =
sanitizeText(process.env.VITE_PLATFORM_NAME, 80) ||
sanitizeText(readMetaContent(INDEX_TEMPLATE, "og:title"), 80) ||
sanitizeText(readMetaContent(INDEX_TEMPLATE, "twitter:title"), 80) ||
"Flotilla"
const DEFAULT_PLATFORM_DESCRIPTION =
sanitizeText(process.env.VITE_PLATFORM_DESCRIPTION, 180) ||
sanitizeText(readMetaContent(INDEX_TEMPLATE, "description"), 180) ||
"Flotilla is nostr - for communities."
const buildInviteDescription = ({spaceName, relayHost, relayDescription}) => {
const parts = []
if (spaceName) {
parts.push(`You are invited to join ${spaceName} on ${DEFAULT_PLATFORM_NAME}.`)
} else {
parts.push(`You are invited to join a space on ${DEFAULT_PLATFORM_NAME}.`)
}
if (relayHost) {
parts.push(`Relay: ${relayHost}.`)
}
if (relayDescription) {
parts.push(relayDescription)
} else {
parts.push(DEFAULT_PLATFORM_DESCRIPTION)
}
return sanitizeText(parts.join(" "), 220) || DEFAULT_PLATFORM_DESCRIPTION
}
const buildInviteMeta = (requestUrl, invite, relayMetadata) => {
let relayHost = ""
try {
relayHost = new URL(invite.relayUrl).host
} catch {
relayHost = ""
}
const spaceName = sanitizeText(relayMetadata.name, 80)
const relayDescription = sanitizeText(relayMetadata.description, 180)
const title = spaceName
? `Invite to ${spaceName} on ${DEFAULT_PLATFORM_NAME}`
: `Invite to a Space on ${DEFAULT_PLATFORM_NAME}`
const description = buildInviteDescription({spaceName, relayHost, relayDescription})
const image =
relayMetadata.icon || absoluteUrlFromRequest(requestUrl, "/maskable-icon-512x512.png")
const url = requestUrl.toString()
const site = `${requestUrl.protocol}//${requestUrl.host}`
return {title, description, image, url, site}
}
const injectInviteMeta = (html, metadata) => {
let output = html
output = upsertTitle(output, metadata.title)
output = upsertCanonical(output, metadata.url)
output = upsertMetaTag(output, "description", metadata.description, "name")
output = upsertMetaTag(output, "og:type", "website", "property")
output = upsertMetaTag(output, "og:url", metadata.url, "property")
output = upsertMetaTag(output, "og:title", metadata.title, "property")
output = upsertMetaTag(output, "og:description", metadata.description, "property")
output = upsertMetaTag(output, "og:image", metadata.image, "property")
output = upsertMetaTag(output, "twitter:card", "summary_large_image", "name")
output = upsertMetaTag(output, "twitter:site", metadata.site, "name")
output = upsertMetaTag(output, "twitter:url", metadata.url, "name")
output = upsertMetaTag(output, "twitter:title", metadata.title, "name")
output = upsertMetaTag(output, "twitter:description", metadata.description, "name")
output = upsertMetaTag(output, "twitter:image", metadata.image, "name")
return output
}
const renderIndex = async requestUrl => {
if (!isJoinInvitePath(requestUrl.pathname)) {
return INDEX_TEMPLATE
}
const invite = parseInvite(requestUrl)
if (!invite) {
return INDEX_TEMPLATE
}
const relayMetadata = await fetchRelayMetadata(invite.relayUrl)
const inviteMeta = buildInviteMeta(requestUrl, invite, relayMetadata)
return injectInviteMeta(INDEX_TEMPLATE, inviteMeta)
}
const notFound = (response, message = "Not found") => {
response.statusCode = 404
response.setHeader("Content-Type", "text/plain; charset=utf-8")
response.end(message)
}
const sendHtml = (response, html, method, dynamic) => {
response.statusCode = 200
response.setHeader("Content-Type", "text/html; charset=utf-8")
response.setHeader("Cache-Control", dynamic ? "no-store" : "no-cache")
if (method === "HEAD") {
response.end()
return
}
response.end(html)
}
const sendStaticFile = (response, filePath, size, method) => {
const extension = path.extname(filePath).toLowerCase()
const mimeType = MIME_TYPES[extension] || "application/octet-stream"
const immutableAsset = filePath.includes(`${path.sep}_app${path.sep}immutable${path.sep}`)
response.statusCode = 200
response.setHeader("Content-Type", mimeType)
response.setHeader("Content-Length", String(size))
response.setHeader(
"Cache-Control",
immutableAsset ? "public, max-age=31536000, immutable" : "public, max-age=3600",
)
if (method === "HEAD") {
response.end()
return
}
const stream = createReadStream(filePath)
stream.on("error", () => {
if (!response.headersSent) {
response.statusCode = 500
response.end("Internal Server Error")
return
}
response.destroy()
})
stream.pipe(response)
}
const server = http.createServer(async (request, response) => {
try {
const method = request.method || "GET"
if (!["GET", "HEAD"].includes(method)) {
response.statusCode = 405
response.setHeader("Allow", "GET, HEAD")
response.end("Method Not Allowed")
return
}
const origin = requestOrigin(request)
const requestUrl = new URL(request.url || "/", origin)
const staticFile = await findStaticFile(requestUrl.pathname)
if (staticFile) {
sendStaticFile(response, staticFile.filePath, staticFile.size, method)
return
}
if (path.extname(requestUrl.pathname)) {
notFound(response)
return
}
const dynamicInvite = isJoinInvitePath(requestUrl.pathname) && requestUrl.searchParams.has("r")
const html = await renderIndex(requestUrl)
sendHtml(response, html, method, dynamicInvite)
} catch {
response.statusCode = 500
response.setHeader("Content-Type", "text/plain; charset=utf-8")
response.end("Internal Server Error")
}
})
server.listen(PORT, HOST, () => {
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
})
+4 -4
View File
@@ -7,10 +7,10 @@
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="{ACCENT}" />
<meta name="description" content="{DESCRIPTION}" />
<meta name="og:url" content="{URL}" />
<meta name="og:type" content="website" />
<meta name="og:title" content="{NAME}" />
<meta name="og:description" content="{DESCRIPTION}" />
<meta property="og:url" content="{URL}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{NAME}" />
<meta property="og:description" content="{DESCRIPTION}" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="{URL}" />
<meta name="twitter:title" content="{NAME}" />