diff --git a/Dockerfile b/Dockerfile index 0a5b6c74..ed71b94f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/package.json b/package.json index 7f20624a..b5327329 100644 --- a/package.json +++ b/package.json @@ -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,12 +82,14 @@ "@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", "dotenv": "^16.6.1", "emoji-picker-element": "^1.28.1", "fuse.js": "^7.1.0", + "hono": "^4.12.15", "husky": "^9.1.7", "idb": "^8.0.3", "livekit-client": "^2.17.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e02625b7..03949fab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -140,6 +146,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 @@ -1096,6 +1105,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'} @@ -2465,6 +2480,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==} @@ -2830,6 +2852,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'} @@ -2841,6 +2866,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'} @@ -3234,6 +3267,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==} @@ -3241,6 +3278,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'} @@ -3249,6 +3289,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==} @@ -4015,6 +4059,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'} @@ -4456,6 +4509,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==} @@ -4891,6 +4947,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'} @@ -5009,6 +5069,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==} @@ -6228,6 +6297,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': @@ -7642,6 +7715,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 @@ -8032,6 +8128,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 @@ -8041,6 +8142,10 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + + entities@7.0.1: {} + env-paths@2.2.1: {} env-paths@3.0.0: {} @@ -8550,16 +8655,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: {} @@ -9266,6 +9384,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: {} @@ -9704,6 +9835,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: {} @@ -10229,6 +10362,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: @@ -10309,6 +10444,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 diff --git a/server.js b/server.js index 51cbfcc0..46ddf002 100644 --- a/server.js +++ b/server.js @@ -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, ) -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 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( - `]*(?: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>/is - - if (pattern.test(html)) { - return html.replace(pattern, `${escapedTitle}`) - } - - return html.replace("", ` ${escapedTitle}\n `) -} - -const upsertMetaTag = (html, key, content, attribute) => { - const pattern = new RegExp( - `]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*>`, - "i", - ) - const tag = `` - - if (pattern.test(html)) { - return html.replace(pattern, tag) - } - - return html.replace("", ` ${tag}\n `) -} - -const upsertCanonical = (html, href) => { - const pattern = /]*rel=["']canonical["'][^>]*>/i - const tag = `` - - if (pattern.test(html)) { - return html.replace(pattern, tag) - } - - return html.replace("", ` ${tag}\n `) -} - -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) +} + +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 } } @@ -207,128 +129,188 @@ const normalizeImageUrl = (value, baseUrl) => { } } -const decodeHtmlEntities = value => - value - .replaceAll("&", "&") - .replaceAll(""", '"') - .replaceAll("'", "'") - .replaceAll("<", "<") - .replaceAll(">", ">") +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( - `]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*content=["']([^"']*)["'][^>]*>`, - "i", - ) - const reversePattern = new RegExp( - `]*content=["']([^"']*)["'][^>]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*>`, - "i", - ) - - return html.match(forwardPattern)?.[1] || html.match(reversePattern)?.[1] -} - -const readHtmlIconHref = html => { - const links = html.match(/]*>/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 + } + + $("html").prepend("") + + return $("head").first() +} + +const upsertTitle = ($, title) => { + const head = ensureHead($) + let titleTag = head.children("title").first() + + if (titleTag.length === 0) { + titleTag = $("") + head.prepend(titleTag) + } + + titleTag.text(title) +} + +const upsertMetaTag = ($, key, content, attribute) => { + const head = ensureHead($) + let tag = findMeta($, key) + + if (tag.length === 0) { + tag = $("") + 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 = $("") + 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 => { @@ -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}`) + }, +)