forked from coracle/flotilla
685 lines
18 KiB
JavaScript
685 lines
18 KiB
JavaScript
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}`)
|
|
})
|