| 1 | import { createReadStream, statSync } from "node:fs"; |
| 2 | import { extname, join, resolve, sep } from "node:path"; |
| 3 | import { Readable } from "node:stream"; |
| 4 | import { errorText, type Logger } from "./log.js"; |
| 5 | |
| 6 | export const APP_SCHEME = "reasonix"; |
| 7 | export const APP_ORIGIN = "reasonix://app"; |
| 8 | export const APP_INDEX_URL = "reasonix://app/index.html"; |
| 9 | |
| 10 | // Must match desktop/workspace_media.go, desktop/theme_assets.go and |
| 11 | // desktop/remote_markdown_image.go; only these reach the resource origin. |
| 12 | export const FORWARDED_PATHS = [ |
| 13 | "/__reasonix_workspace_media/", |
| 14 | "/__reasonix_theme_asset/", |
| 15 | "/__reasonix_remote_markdown_image", |
| 16 | ] as const; |
| 17 | |
| 18 | export type AppRoute = |
| 19 | | { kind: "file"; path: string; mime: string } |
| 20 | | { kind: "forward"; target: string } |
| 21 | | { kind: "notFound"; reason: string }; |
| 22 | |
| 23 | const MIME: Record<string, string> = { |
| 24 | ".html": "text/html; charset=utf-8", |
| 25 | ".js": "text/javascript; charset=utf-8", |
| 26 | ".mjs": "text/javascript; charset=utf-8", |
| 27 | ".css": "text/css; charset=utf-8", |
| 28 | ".json": "application/json; charset=utf-8", |
| 29 | ".map": "application/json; charset=utf-8", |
| 30 | ".webmanifest": "application/manifest+json", |
| 31 | ".svg": "image/svg+xml", |
| 32 | ".png": "image/png", |
| 33 | ".jpg": "image/jpeg", |
| 34 | ".jpeg": "image/jpeg", |
| 35 | ".gif": "image/gif", |
| 36 | ".webp": "image/webp", |
| 37 | ".ico": "image/x-icon", |
| 38 | ".woff": "font/woff", |
| 39 | ".woff2": "font/woff2", |
| 40 | ".ttf": "font/ttf", |
| 41 | ".txt": "text/plain; charset=utf-8", |
| 42 | ".wasm": "application/wasm", |
| 43 | ".mp3": "audio/mpeg", |
| 44 | ".ogg": "audio/ogg", |
| 45 | ".wav": "audio/wav", |
| 46 | ".mp4": "video/mp4", |
| 47 | ".webm": "video/webm", |
| 48 | }; |
| 49 | |
| 50 | export function mimeFor(path: string): string { |
| 51 | return MIME[extname(path).toLowerCase()] ?? "application/octet-stream"; |
| 52 | } |
| 53 | |
| 54 | export function isForwardedPath(pathname: string): boolean { |
| 55 | return FORWARDED_PATHS.some((prefix) => |
| 56 | prefix.endsWith("/") ? pathname.startsWith(prefix) : pathname === prefix || pathname.startsWith(prefix + "/")); |
| 57 | } |
| 58 | |
| 59 | const notFound = (reason: string): AppRoute => ({ kind: "notFound", reason }); |
| 60 | |
| 61 | export function routeAppRequest(rawURL: string, distRoot: string, isFile: (path: string) => boolean): AppRoute { |
| 62 | let url: URL; |
| 63 | try { |
| 64 | url = new URL(rawURL); |
| 65 | } catch { |
| 66 | return notFound("malformed URL"); |
| 67 | } |
| 68 | if (url.protocol !== `${APP_SCHEME}:`) return notFound("unsupported scheme"); |
| 69 | if (url.host !== "app") return notFound("unknown host"); |
| 70 | if (isForwardedPath(url.pathname)) return { kind: "forward", target: url.pathname + url.search }; |
| 71 | let decoded: string; |
| 72 | try { |
| 73 | decoded = decodeURIComponent(url.pathname); |
| 74 | } catch { |
| 75 | return notFound("malformed path"); |
| 76 | } |
| 77 | if (decoded.includes("\0") || decoded.includes("\\")) return notFound("illegal characters"); |
| 78 | const pathname = decoded === "/" || decoded === "" ? "/index.html" : decoded; |
| 79 | const segments = pathname.split("/").slice(1); |
| 80 | if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return notFound("path traversal"); |
| 81 | const root = resolve(distRoot); |
| 82 | const file = join(root, ...segments); |
| 83 | if (!file.startsWith(root + sep)) return notFound("outside dist root"); |
| 84 | if (!isFile(file)) return notFound("no such file"); |
| 85 | return { kind: "file", path: file, mime: mimeFor(file) }; |
| 86 | } |
| 87 | |
| 88 | export function resolveDistRoot(input: { env: NodeJS.ProcessEnv; appPath: string; resourcesPath: string; packaged: boolean }): string { |
| 89 | const override = (input.env.REASONIX_FRONTEND_DIST ?? "").trim(); |
| 90 | if (override !== "") return resolve(override); |
| 91 | return input.packaged ? join(input.resourcesPath, "app") : resolve(input.appPath, "..", "frontend", "dist"); |
| 92 | } |
| 93 | |
| 94 | export interface ResourceOrigin { |
| 95 | origin: string; |
| 96 | token: string; |
| 97 | } |
| 98 | |
| 99 | export interface AppProtocolDeps { |
| 100 | protocol: { handle(scheme: string, handler: (request: Request) => Promise<Response> | Response): void }; |
| 101 | fetch(input: string, init: RequestInit & { bypassCustomProtocolHandlers?: boolean }): Promise<Response>; |
| 102 | distRoot: string; |
| 103 | resources(): ResourceOrigin | null; |
| 104 | log: Logger; |
| 105 | } |
| 106 | |
| 107 | const FORWARDED_REQUEST_HEADERS = ["accept", "range", "if-none-match", "if-modified-since"]; |
| 108 | |
| 109 | function fileExists(path: string): boolean { |
| 110 | try { |
| 111 | return statSync(path).isFile(); |
| 112 | } catch { |
| 113 | return false; |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | function text(status: number, body: string): Response { |
| 118 | return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } }); |
| 119 | } |
| 120 | |
| 121 | export function registerAppProtocol(deps: AppProtocolDeps): void { |
| 122 | deps.protocol.handle(APP_SCHEME, async (request) => { |
| 123 | const route = routeAppRequest(request.url, deps.distRoot, fileExists); |
| 124 | if (route.kind === "notFound") { |
| 125 | deps.log.warn(`404 ${request.url}: ${route.reason}`); |
| 126 | return text(404, "Not found"); |
| 127 | } |
| 128 | if (route.kind === "forward") { |
| 129 | const resources = deps.resources(); |
| 130 | if (!resources) return text(503, "Desktop service unavailable"); |
| 131 | const headers = new Headers({ authorization: `Bearer ${resources.token}` }); |
| 132 | for (const name of FORWARDED_REQUEST_HEADERS) { |
| 133 | const value = request.headers.get(name); |
| 134 | if (value) headers.set(name, value); |
| 135 | } |
| 136 | try { |
| 137 | return await deps.fetch(resources.origin + route.target, { method: request.method, headers, bypassCustomProtocolHandlers: true }); |
| 138 | } catch (error) { |
| 139 | deps.log.warn(`resource forward failed for ${route.target}: ${errorText(error)}`); |
| 140 | return text(502, "Resource origin unreachable"); |
| 141 | } |
| 142 | } |
| 143 | const body = Readable.toWeb(createReadStream(route.path)) as unknown as ReadableStream; |
| 144 | return new Response(body, { status: 200, headers: { "content-type": route.mime, "cache-control": "no-cache" } }); |
| 145 | }); |
| 146 | } |
| 147 |