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
4 changed files with 485 additions and 405 deletions
Showing only changes of commit ae8c4aeb89 - Show all commits
+6 -1
View File
@@ -26,7 +26,12 @@ FROM node:20-alpine
WORKDIR /app
# Copy only the built output - no source, no .env, no dev deps
# Install production dependencies needed by the Node server runtime
RUN npm install -g pnpm@10.33.0
COPY package.json pnpm-lock.yaml ./
RUN pnpm i --prod --frozen-lockfile --ignore-scripts
# Copy only the built output and server source - no app source, no .env, no dev deps
COPY --from=builder /app/build ./build
COPY --from=builder /app/server.js ./server.js
+3
View File
@@ -61,6 +61,7 @@
"@capawesome/capacitor-badge": "^8.0.0",
"@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2",
"@hono/node-server": "^2.0.0",
"@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.3",
"@poppanator/sveltekit-svg": "^4.2.1",
@@ -81,6 +82,7 @@
"@welshman/signer": "^0.8.13",
"@welshman/store": "^0.8.13",
"@welshman/util": "^0.8.13",
"cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2",
"daisyui": "^5.5.19",
"date-picker-svelte": "^2.17.0",
@@ -88,6 +90,7 @@
"emoji-picker-element": "^1.28.1",
"emoji-picker-element-data": "^1.8.0",
"fuse.js": "^7.1.0",
"hono": "^4.12.15",
"husky": "^9.1.7",
"idb": "^8.0.3",
"livekit-client": "^2.17.2",
+141
View File
@@ -62,6 +62,9 @@ importers:
'@getalby/sdk':
specifier: ^5.1.2
version: 5.1.2(typescript@5.9.3)
'@hono/node-server':
specifier: ^2.0.0
version: 2.0.0(hono@4.12.15)
'@noble/curves':
specifier: ^1.9.7
version: 1.9.7
@@ -122,6 +125,9 @@ importers:
'@welshman/util':
specifier: ^0.8.13
version: 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))
cheerio:
specifier: ^1.2.0
version: 1.2.0
compressorjs-next:
specifier: ^1.1.2
version: 1.1.2
@@ -143,6 +149,9 @@ importers:
fuse.js:
specifier: ^7.1.0
version: 7.1.0
hono:
specifier: ^4.12.15
version: 4.12.15
husky:
specifier: ^9.1.7
version: 9.1.7
@@ -1099,6 +1108,12 @@ packages:
resolution: {integrity: sha512-yUF9LhuvdIFOwjV1aG0ryzfwDiGBFk/CRLkRvrrM9dsE38SUjKsf1FDga5jxsKMu80nWcPZR9TiGGASWedoYPA==}
engines: {node: '>=14'}
'@hono/node-server@2.0.0':
resolution: {integrity: sha512-n3GfHwwCvHCkGmOwKfxUPOlbfzuO64Sbc5XC4NGPIXxkuOnJrdgExdRKmHfF924r914WRJPT397GdqLvdYTeyQ==}
engines: {node: '>=20'}
peerDependencies:
hono: ^4
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -2468,6 +2483,13 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
cheerio@1.2.0:
resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
engines: {node: '>=20.18.1'}
chevrotain@7.1.1:
resolution: {integrity: sha512-wy3mC1x4ye+O+QkEinVJkPf5u2vsrDIYW9G7ZuwFl6v/Yu0LwUuT2POsb+NUWApebyxfkQq6+yDfRExbnI5rcw==}
@@ -2836,6 +2858,9 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
encoding-sniffer@0.2.1:
resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
enhanced-resolve@5.20.1:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
@@ -2847,6 +2872,14 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -3240,6 +3273,10 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
hono@4.12.15:
resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==}
engines: {node: '>=16.9.0'}
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
@@ -3247,6 +3284,9 @@ packages:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
@@ -3255,6 +3295,10 @@ packages:
ico-endec@0.1.6:
resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
@@ -4021,6 +4065,15 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse5-htmlparser2-tree-adapter@7.1.0:
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
parse5-parser-stream@7.1.2:
resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
path-exists@3.0.0:
resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==}
engines: {node: '>=4'}
@@ -4462,6 +4515,9 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sax@1.1.4:
resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==}
@@ -4897,6 +4953,10 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@7.25.0:
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
engines: {node: '>=20.18.1'}
unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
engines: {node: '>=4'}
@@ -5015,6 +5075,15 @@ packages:
resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==}
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -6234,6 +6303,10 @@ snapshots:
transitivePeerDependencies:
- typescript
'@hono/node-server@2.0.0(hono@4.12.15)':
dependencies:
hono: 4.12.15
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -7648,6 +7721,29 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
cheerio-select@2.1.0:
dependencies:
boolbase: 1.0.0
css-select: 5.2.2
css-what: 6.2.2
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
cheerio@1.2.0:
dependencies:
cheerio-select: 2.1.0
dom-serializer: 2.0.0
domhandler: 5.0.3
domutils: 3.2.2
encoding-sniffer: 0.2.1
htmlparser2: 10.1.0
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 7.1.0
parse5-parser-stream: 7.1.2
undici: 7.25.0
whatwg-mimetype: 4.0.0
chevrotain@7.1.1:
dependencies:
regexp-to-ast: 0.5.0
@@ -8040,6 +8136,11 @@ snapshots:
emoji-regex@8.0.0: {}
encoding-sniffer@0.2.1:
dependencies:
iconv-lite: 0.6.3
whatwg-encoding: 3.1.1
enhanced-resolve@5.20.1:
dependencies:
graceful-fs: 4.2.11
@@ -8049,6 +8150,10 @@ snapshots:
entities@4.5.0: {}
entities@6.0.1: {}
entities@7.0.1: {}
env-paths@2.2.1: {}
env-paths@3.0.0: {}
@@ -8558,16 +8663,29 @@ snapshots:
he@1.2.0: {}
hono@4.12.15: {}
hosted-git-info@2.8.9: {}
hosted-git-info@4.1.0:
dependencies:
lru-cache: 6.0.0
htmlparser2@10.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 7.0.1
husky@9.1.7: {}
ico-endec@0.1.6: {}
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
idb@7.1.1: {}
idb@8.0.3: {}
@@ -9274,6 +9392,19 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse5-htmlparser2-tree-adapter@7.1.0:
dependencies:
domhandler: 5.0.3
parse5: 7.3.0
parse5-parser-stream@7.1.2:
dependencies:
parse5: 7.3.0
parse5@7.3.0:
dependencies:
entities: 6.0.1
path-exists@3.0.0: {}
path-exists@4.0.0: {}
@@ -9712,6 +9843,8 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
safer-buffer@2.1.2: {}
sax@1.1.4: {}
sax@1.4.4: {}
@@ -10237,6 +10370,8 @@ snapshots:
undici-types@7.16.0: {}
undici@7.25.0: {}
unicode-canonical-property-names-ecmascript@2.0.1: {}
unicode-match-property-ecmascript@2.0.0:
@@ -10317,6 +10452,12 @@ snapshots:
dependencies:
sdp: 3.2.1
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3
whatwg-mimetype@4.0.0: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
+335 -404
View File
@@ -1,9 +1,14 @@
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"
import {serve} from "@hono/node-server"
import {serveStatic} from "@hono/node-server/serve-static"
import {fetchRelay} from "@welshman/app"
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {load} from "cheerio"
import {Hono} from "hono"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -30,35 +35,9 @@ const NEGATIVE_CACHE_TTL_MS = readPositiveInt(
2 * 60 * 1000,
)
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
const MIME_TYPES = Object.freeze({
".avif": "image/avif",
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".txt": "text/plain; charset=utf-8",
".webmanifest": "application/manifest+json; charset=utf-8",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
})
const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const escapeHtml = value =>
value
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
const ALLOWED_METHODS = new Set(["GET", "HEAD"])
const DEFAULT_IMAGE_PATH = "/maskable-icon-512x512.png"
const HTML_HEADERS = {"Content-Type": "text/html; charset=utf-8"}
const truncate = (value, limit) => {
if (value.length <= limit) {
@@ -88,98 +67,41 @@ const sanitizeText = (value, 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)
const hasMetadata = metadata => Object.keys(metadata).length > 0
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) {
const firstHeaderValue = value => {
if (typeof value !== "string") {
return undefined
}
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `wss://${trimmed}`
return sanitizeText(value.split(",")[0], 256)
}
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 requestUrlFromContext = context => {
const requestUrl = new URL(context.req.url)
const forwardedProto = firstHeaderValue(context.req.header("x-forwarded-proto"))
const forwardedHost = firstHeaderValue(context.req.header("x-forwarded-host"))
try {
const relayUrl = new URL(withScheme)
if (!["ws:", "wss:", "http:", "https:"].includes(relayUrl.protocol)) {
return undefined
if (forwardedProto === "http" || forwardedProto === "https") {
requestUrl.protocol = `${forwardedProto}:`
}
relayUrl.hash = ""
relayUrl.search = ""
if (relayUrl.pathname !== "/") {
relayUrl.pathname = relayUrl.pathname.replace(/\/+$/, "")
if (forwardedHost) {
requestUrl.host = forwardedHost
}
return relayUrl.toString()
} catch {
return undefined
return requestUrl
}
return requestUrl
}
const relayToInfoUrl = relayUrl => {
const absoluteUrlFromRequest = (requestUrl, value) => {
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()
return new URL(value, requestUrl.origin).toString()
} catch {
return undefined
return value
}
}
1
@@ -207,128 +129,188 @@ const normalizeImageUrl = (value, baseUrl) => {
}
}
const decodeHtmlEntities = value =>
value
.replaceAll("&amp;", "&")
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
const normalizeRelayInput = value => {
const trimmed = value.trim()
const readHtmlAttribute = (tag, key) => {
const pattern = new RegExp(`${escapeRegExp(key)}=["']([^"']*)["']`, "i")
const match = tag.match(pattern)
if (!trimmed) {
return undefined
}
return match?.[1]
}
let relayInput = trimmed
const readHtmlTagContent = (html, tag) => {
const pattern = new RegExp(`<${escapeRegExp(tag)}[^>]*>([\\s\\S]*?)<\\/${escapeRegExp(tag)}>`, "i")
const match = html.match(pattern)
try {
const parsed = new URL(trimmed)
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
if (parsed.protocol === "http:") {
parsed.protocol = "ws:"
relayInput = parsed.toString()
}
const href = readHtmlAttribute(link, "href")
if (parsed.protocol === "https:") {
parsed.protocol = "wss:"
relayInput = parsed.toString()
}
} catch {
relayInput = trimmed
}
if (!isRelayUrl(relayInput)) {
return undefined
}
try {
const relayUrl = new URL(normalizeRelayUrl(relayInput))
relayUrl.hash = ""
relayUrl.search = ""
return relayUrl.toString()
} catch {
return undefined
}
}
const relayToInfoUrl = relayUrl => {
try {
const relayHttpUrl = new URL(relayUrl)
if (relayHttpUrl.protocol === "ws:") {
relayHttpUrl.protocol = "http:"
} else if (relayHttpUrl.protocol === "wss:") {
relayHttpUrl.protocol = "https:"
} else {
return undefined
}
relayHttpUrl.hash = ""
relayHttpUrl.search = ""
return relayHttpUrl.toString()
} catch {
return undefined
}
}
const withTimeout = async (promise, timeoutMs) =>
new Promise(resolve => {
if (timeoutMs <= 0) {
resolve(undefined)
return
}
const timeout = setTimeout(() => resolve(undefined), timeoutMs)
promise
.then(resolve)
.catch(() => resolve(undefined))
.finally(() => clearTimeout(timeout))
})
const findMeta = ($, key) => {
const normalizedKey = key.toLowerCase()
return $("meta")
.filter((_, element) => {
const meta = $(element)
const name = meta.attr("name")?.toLowerCase()
const property = meta.attr("property")?.toLowerCase()
return name === normalizedKey || property === normalizedKey
})
.first()
}
const readMetaContent = ($, key) => findMeta($, key).attr("content")
const readIconHref = $ => {
let href
$("link[rel]").each((_, element) => {
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}
return
}
if (stats.isDirectory()) {
const nestedIndex = path.join(candidatePath, "index.html")
const indexStats = await fs.stat(nestedIndex)
const link = $(element)
const rel = link.attr("rel")?.toLowerCase() || ""
if (indexStats.isFile()) {
return {filePath: nestedIndex, size: indexStats.size}
}
if (rel.includes("icon")) {
href = link.attr("href")
}
} catch {
return undefined
})
return href
}
const findLinkRel = ($, rel) =>
$("link[rel]")
.filter((_, element) => ($(element).attr("rel")?.toLowerCase() || "").includes(rel))
.first()
const extractHtmlMetadata = (html, baseUrl) => {
const $ = load(html)
const name = sanitizeText($("title").first().text(), 80)
const description = sanitizeText(readMetaContent($, "description") || "", 180)
const icon = normalizeImageUrl(
readMetaContent($, "og:image") || readMetaContent($, "twitter:image") || readIconHref($) || "",
baseUrl,
)
return {
...(name ? {name} : {}),
...(description ? {description} : {}),
...(icon ? {icon} : {}),
}
}
const normalizeRelayMetadata = (relayMetadata, relayUrl) => {
if (!isRecord(relayMetadata)) {
return {}
}
return undefined
const infoUrl = relayToInfoUrl(relayUrl) || relayUrl
const name = sanitizeText(relayMetadata.name || relayMetadata.title, 80)
const description = sanitizeText(relayMetadata.description, 180)
const icon = normalizeImageUrl(
relayMetadata.icon || relayMetadata.picture || relayMetadata.image,
infoUrl,
)
return {
...(name ? {name} : {}),
...(description ? {description} : {}),
...(icon ? {icon} : {}),
}
}
const fetchHtmlRelayMetadata = async (relayUrl, timeoutMs) => {
const infoUrl = relayToInfoUrl(relayUrl)
if (!infoUrl || timeoutMs <= 0) {
return {}
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(infoUrl, {
headers: {
Accept: "text/html, application/xhtml+xml, */*;q=0.1",
},
redirect: "follow",
signal: controller.signal,
})
if (!response.ok) {
return {}
}
return extractHtmlMetadata(await response.text(), infoUrl)
} catch {
return {}
} finally {
clearTimeout(timeout)
}
}
const cacheByRelay = new Map()
@@ -381,84 +363,19 @@ const fetchRelayMetadata = async relayUrl => {
}
const loader = (async () => {
const infoUrl = relayToInfoUrl(relayUrl)
const deadline = Date.now() + REQUEST_TIMEOUT_MS
const remainingTimeout = () => Math.max(0, deadline - Date.now())
const relay = await withTimeout(fetchRelay(relayUrl), remainingTimeout())
let metadata = normalizeRelayMetadata(relay, 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)
if (!hasMetadata(metadata)) {
metadata = await fetchHtmlRelayMetadata(relayUrl, remainingTimeout())
}
setCachedRelayData(
relayUrl,
metadata,
Object.keys(metadata).length > 0 ? POSITIVE_CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS,
hasMetadata(metadata) ? POSITIVE_CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS,
)
return metadata
@@ -498,17 +415,18 @@ const loadIndexTemplate = async () => {
}
const INDEX_TEMPLATE = await loadIndexTemplate()
const INDEX_DOCUMENT = load(INDEX_TEMPLATE)
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) ||
sanitizeText(readMetaContent(INDEX_DOCUMENT, "og:title"), 80) ||
sanitizeText(readMetaContent(INDEX_DOCUMENT, "twitter:title"), 80) ||
"Flotilla"
const DEFAULT_PLATFORM_DESCRIPTION =
sanitizeText(process.env.VITE_PLATFORM_DESCRIPTION, 180) ||
sanitizeText(readMetaContent(INDEX_TEMPLATE, "description"), 180) ||
sanitizeText(readMetaContent(INDEX_DOCUMENT, "description"), 180) ||
"Flotilla is nostr - for communities."
const buildInviteDescription = ({spaceName, relayHost, relayDescription}) => {
const buildInviteDescription = ({spaceName, relayDisplay, relayDescription}) => {
const parts = []
if (spaceName) {
@@ -517,8 +435,8 @@ const buildInviteDescription = ({spaceName, relayHost, relayDescription}) => {
parts.push(`You are invited to join a space on ${DEFAULT_PLATFORM_NAME}.`)
}
if (relayHost) {
parts.push(`Relay: ${relayHost}.`)
if (relayDisplay) {
parts.push(`Relay: ${relayDisplay}.`)
}
if (relayDescription) {
@@ -531,47 +449,90 @@ const buildInviteDescription = ({spaceName, relayHost, relayDescription}) => {
}
const buildInviteMeta = (requestUrl, invite, relayMetadata) => {
let relayHost = ""
try {
relayHost = new URL(invite.relayUrl).host
} catch {
relayHost = ""
}
const relayDisplay = displayRelayUrl(invite.relayUrl)
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 description = buildInviteDescription({spaceName, relayDisplay, relayDescription})
const image = relayMetadata.icon || absoluteUrlFromRequest(requestUrl, DEFAULT_IMAGE_PATH)
const url = requestUrl.toString()
const site = `${requestUrl.protocol}//${requestUrl.host}`
const site = requestUrl.origin
return {title, description, image, url, site}
}
const ensureHead = $ => {
const existingHead = $("head").first()
if (existingHead.length > 0) {
return existingHead
}
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.
$("html").prepend("<head></head>")
return $("head").first()
}
const upsertTitle = ($, title) => {
const head = ensureHead($)
let titleTag = head.children("title").first()
if (titleTag.length === 0) {
titleTag = $("<title></title>")
head.prepend(titleTag)
}
titleTag.text(title)
}
const upsertMetaTag = ($, key, content, attribute) => {
const head = ensureHead($)
let tag = findMeta($, key)
if (tag.length === 0) {
tag = $("<meta>")
head.append(tag)
}
tag.removeAttr(attribute === "name" ? "property" : "name")
tag.attr(attribute, key)
tag.attr("content", content)
}
const upsertCanonical = ($, href) => {
const head = ensureHead($)
let tag = findLinkRel($, "canonical")
if (tag.length === 0) {
tag = $("<link>")
head.append(tag)
}
tag.attr("rel", "canonical")
tag.attr("href", href)
}
const injectInviteMeta = (html, metadata) => {
let output = html
const $ = load(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")
upsertTitle($, metadata.title)
upsertCanonical($, metadata.url)
upsertMetaTag($, "description", metadata.description, "name")
upsertMetaTag($, "og:type", "website", "property")
upsertMetaTag($, "og:url", metadata.url, "property")
upsertMetaTag($, "og:title", metadata.title, "property")
upsertMetaTag($, "og:description", metadata.description, "property")
upsertMetaTag($, "og:image", metadata.image, "property")
upsertMetaTag($, "twitter:card", "summary_large_image", "name")
upsertMetaTag($, "twitter:site", metadata.site, "name")
upsertMetaTag($, "twitter:url", metadata.url, "name")
upsertMetaTag($, "twitter:title", metadata.title, "name")
upsertMetaTag($, "twitter:description", metadata.description, "name")
upsertMetaTag($, "twitter:image", metadata.image, "name")
return output
return $.html()
}
const renderIndex = async requestUrl => {
2
@@ -591,94 +552,64 @@ const renderIndex = async requestUrl => {
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 isImmutableAsset = filePath => filePath.split(path.sep).join("/").includes("/_app/immutable/")
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")
const getStaticCacheControl = filePath =>
path.basename(filePath) === "index.html"
? "no-cache"
: isImmutableAsset(filePath)
? "public, max-age=31536000, immutable"
: "public, max-age=3600"
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()
const respondHtml = (html, isHeadRequest, cacheControl) =>
new Response(isHeadRequest ? undefined : html, {
headers: {
...HTML_HEADERS,
"Cache-Control": cacheControl,
},
status: 200,
})
stream.pipe(response)
}
const app = new Hono()
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")
app.use("*", async (context, next) => {
if (!ALLOWED_METHODS.has(context.req.method)) {
return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"})
}
await next()
})
server.listen(PORT, HOST, () => {
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
app.use(
"*",
serveStatic({
root: BUILD_DIR,
onFound: (filePath, context) => {
context.header("Cache-Control", getStaticCacheControl(filePath))
},
}),
)
app.on(["GET", "HEAD"], "*", async context => {
const requestUrl = requestUrlFromContext(context)
if (path.extname(requestUrl.pathname)) {
return context.text("Not found", 404)
}
const dynamicInvite = isJoinInvitePath(requestUrl.pathname) && requestUrl.searchParams.has("r")
const html = await renderIndex(requestUrl)
return respondHtml(html, context.req.method === "HEAD", dynamicInvite ? "no-store" : "no-cache")
})
serve(
{
fetch: app.fetch,
hostname: HOST,
port: PORT,
},
() => {
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
},
)