fix(metadata): add case-insensitive HTML title fallback parsing for invite links #248
@@ -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",
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
|
||||
".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
|
||||
}
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
This routing logic is still incomplete and is pretty brittle. We should do something like this: 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
hodlbod
commented
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("&", "&")
|
||||
.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 {
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
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
hodlbod
commented
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).
Khushvendra
commented
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}`)
|
||||
})
|
||||
@@ -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}" />
|
||||
|
||||
These will always be defined (because .env is checked in to version control), no need for a fallback