fix(metadata): add case-insensitive HTML title fallback parsing for invite links
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
@@ -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",
|
||||
|
||||
@@ -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("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
|
||||
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("&", "&")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
|
||||
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}`)
|
||||
})
|
||||
@@ -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}" />
|
||||
|
||||