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

Merged
hodlbod merged 4 commits from Khushvendra/flotilla:issue/131-invite-link-preview into dev 2026-05-04 21:02:57 +00:00
21 changed files with 694 additions and 6 deletions
Showing only changes of commit 01b5d990a6 - Show all commits
+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",
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

These will always be defined (because .env is checked in to version control), no need for a fallback

These will always be defined (because .env is checked in to version control), no need for a fallback
".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
}
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

This routing logic is still incomplete and is pretty brittle. We should do something like this:

const routes = , [
  [/^\/join\/?$/, getMetadataForInvite],
  [/^\/spaces\/(RELAY_REGEX)\/?$/, getMetadataForSpace],
  [/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/?$/, getMetadataForRoom],
  [/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/calendar\/?$/, getMetadataForCalendar],
  [/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/calendar\/(ADDRESS_REGEX)\/?$/, getMetadataForCalendarEvent],
]
const getMetadataForRoute = (url: URL) => {
  for (const [regex, getMetadata] of routes) {
    const match = url.pathname.match(regex)
    
    if (match) {
      return getMetadata(url, match)
    }
  }
}
const meta = getMetadataForRoute(requestUrl)

This way it's clear which function is responsible for which route. Common utilities can be factored out (e.g. relay fetching, relay title generation, etc).

This routing logic is still incomplete and is pretty brittle. We should do something like this: ```typescript const routes = , [ [/^\/join\/?$/, getMetadataForInvite], [/^\/spaces\/(RELAY_REGEX)\/?$/, getMetadataForSpace], [/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/?$/, getMetadataForRoom], [/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/calendar\/?$/, getMetadataForCalendar], [/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/calendar\/(ADDRESS_REGEX)\/?$/, getMetadataForCalendarEvent], ] const getMetadataForRoute = (url: URL) => { for (const [regex, getMetadata] of routes) { const match = url.pathname.match(regex) if (match) { return getMetadata(url, match) } } } const meta = getMetadataForRoute(requestUrl) ``` This way it's clear which function is responsible for which route. Common utilities can be factored out (e.g. relay fetching, relay title generation, etc).
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
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

We should not be swallowing errors, add a console.error statement here

We should not be swallowing errors, add a console.error statement here
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 {
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

This PR is full of just in case things like this, why do we need so much verbose stuff when we have full control over the html template? Just assume it's there, because it is. This would cut the PR in half at least.

This PR is full of just in case things like this, why do we need so much verbose stuff when we have full control over the html template? Just assume it's there, because it is. This would cut the PR in half at least.
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 = ""
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

The invite path isn't the only one we need to render. This file should cover pretty much anything: calendar events, polls, chat rooms, etc. We might not always be able to fetch the data, but we should try to render it. Continuing with the current approach will result in 2k LOC probably; if we import @welshman/app, it will make all of it much easier (and solve caching at the same time).

The invite path isn't the only one we need to render. This file should cover pretty much anything: calendar events, polls, chat rooms, etc. We might not always be able to fetch the data, but we should try to render it. Continuing with the current approach will result in 2k LOC probably; if we import @welshman/app, it will make all of it much easier (and solve caching at the same time).
Outdated
Review

if we import @welshman/app, it will make all of it much easier (and solve caching at the same time).

i have tried this, did decrease the LOC by ~75%. Also have made the corresponding changes according to you feed back. Lmk if you think there could be more improvements?

> if we import @welshman/app, it will make all of it much easier (and solve caching at the same time). i have tried this, did decrease the LOC by ~75%. Also have made the corresponding changes according to you feed back. Lmk if you think there could be more improvements?
}
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}" />