From 9c1a6acbb5c458b18ca1a843b77dd7520e9f65cf Mon Sep 17 00:00:00 2001 From: Arne Baeumler Date: Sat, 13 Jun 2026 08:17:49 +0200 Subject: [PATCH] refactor: extract nextcloud proxy handlers --- src/server/nextcloud-proxy.ts | 144 +++++++++++++++++++++++++++++ src/server/request-handler.ts | 168 ++-------------------------------- 2 files changed, 152 insertions(+), 160 deletions(-) create mode 100644 src/server/nextcloud-proxy.ts diff --git a/src/server/nextcloud-proxy.ts b/src/server/nextcloud-proxy.ts new file mode 100644 index 0000000..de2e293 --- /dev/null +++ b/src/server/nextcloud-proxy.ts @@ -0,0 +1,144 @@ +import type { ServerResponse } from "node:http"; + +function parseHttpUrl(value: string, errorMessage: string): URL { + const url = new URL(value.trim()); + + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error(errorMessage); + } + + if (url.username || url.password) { + throw new Error("URL credentials are not allowed."); + } + + return url; +} + +function resolveDavUrlFromShareUrl(shareUrl: string): string { + const url = parseHttpUrl(shareUrl, "Please enter a public Nextcloud share link."); + const publicShare = url.pathname.match(/\/s\/([^/?#]+)/); + + if (publicShare) { + return `${url.origin}/public.php/dav/files/${publicShare[1]}/`; + } + + const davShare = url.pathname.match(/\/public\.php\/dav\/files\/([^/?#]+)\/?/); + + if (davShare) { + return `${url.origin}/public.php/dav/files/${davShare[1]}/`; + } + + throw new Error("Please enter a public Nextcloud share link."); +} + +function resolveValidatedBlobUrl(targetUrl: string, shareUrl: string): string { + const target = parseHttpUrl(targetUrl, "Invalid image URL."); + const shareDavUrl = new URL(resolveDavUrlFromShareUrl(shareUrl)); + + if (target.origin !== shareDavUrl.origin || !target.pathname.startsWith(shareDavUrl.pathname)) { + throw new Error("Image URL is outside the requested Nextcloud share."); + } + + return target.toString(); +} + +async function proxyUpstream(url: string, init?: RequestInit) { + const upstream = await fetch(url, init); + const body = await upstream.arrayBuffer(); + + return { + body: Buffer.from(body), + contentType: upstream.headers.get("content-type") ?? "application/octet-stream", + status: upstream.status + }; +} + +export async function handleNextcloudListRequest(url: URL, res: ServerResponse): Promise { + const share = url.searchParams.get("share"); + + if (!share) { + res.statusCode = 400; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ error: "missing `share` parameter" })); + return; + } + + try { + const davUrl = resolveDavUrlFromShareUrl(share); + const xml = + '' + + '' + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; + + const upstream = await proxyUpstream(davUrl, { + method: "PROPFIND", + headers: { + Depth: "1", + "Content-Type": "application/xml; charset=utf-8", + Accept: "application/xml, text/xml" + }, + body: xml + }); + + res.statusCode = upstream.status; + res.setHeader("content-type", upstream.contentType); + res.end(upstream.body); + } catch (error) { + res.statusCode = 502; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end( + JSON.stringify({ + error: error instanceof Error ? error.message : "upstream request failed" + }) + ); + } +} + +export async function handleNextcloudBlobRequest(url: URL, res: ServerResponse): Promise { + const target = url.searchParams.get("url"); + const share = url.searchParams.get("share"); + + if (!target || !share) { + res.statusCode = 400; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ error: "missing `share` or `url` parameter" })); + return; + } + + let validatedTarget: string; + + try { + validatedTarget = resolveValidatedBlobUrl(target, share); + } catch (error) { + res.statusCode = 400; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end( + JSON.stringify({ + error: error instanceof Error ? error.message : "invalid image URL" + }) + ); + return; + } + + try { + const upstream = await proxyUpstream(validatedTarget); + res.statusCode = upstream.status; + res.setHeader("content-type", upstream.contentType); + res.end(upstream.body); + } catch (error) { + res.statusCode = 502; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end( + JSON.stringify({ + error: error instanceof Error ? error.message : "upstream request failed" + }) + ); + } +} diff --git a/src/server/request-handler.ts b/src/server/request-handler.ts index bdf9936..236ff4d 100644 --- a/src/server/request-handler.ts +++ b/src/server/request-handler.ts @@ -1,78 +1,10 @@ import { htmlPage } from "./page-template.js"; +import { + handleNextcloudBlobRequest, + handleNextcloudListRequest +} from "./nextcloud-proxy.js"; import type { IncomingMessage, ServerResponse } from "node:http"; -type Photo = { - id: string; - name: string; - latitude: number | null; - longitude: number | null; - capturedAt: string | null; - thumbUrl: string; - fullUrl: string; - source: "demo" | "nextcloud"; -}; - -type ShareListingEntry = { - href: string; - name: string; - lastModified: string | null; - contentType: string; - isCollection: boolean; -}; - -function parseHttpUrl(value: string, errorMessage: string): URL { - const url = new URL(value.trim()); - - if (url.protocol !== "https:" && url.protocol !== "http:") { - throw new Error(errorMessage); - } - - if (url.username || url.password) { - throw new Error("URL credentials are not allowed."); - } - - return url; -} - -function resolveDavUrlFromShareUrl(shareUrl: string): string { - const url = parseHttpUrl(shareUrl, "Please enter a public Nextcloud share link."); - const publicShare = url.pathname.match(/\/s\/([^/?#]+)/); - - if (publicShare) { - return `${url.origin}/public.php/dav/files/${publicShare[1]}/`; - } - - const davShare = url.pathname.match(/\/public\.php\/dav\/files\/([^/?#]+)\/?/); - - if (davShare) { - return `${url.origin}/public.php/dav/files/${davShare[1]}/`; - } - - throw new Error("Please enter a public Nextcloud share link."); -} - -function resolveValidatedBlobUrl(targetUrl: string, shareUrl: string): string { - const target = parseHttpUrl(targetUrl, "Invalid image URL."); - const shareDavUrl = new URL(resolveDavUrlFromShareUrl(shareUrl)); - - if (target.origin !== shareDavUrl.origin || !target.pathname.startsWith(shareDavUrl.pathname)) { - throw new Error("Image URL is outside the requested Nextcloud share."); - } - - return target.toString(); -} - -async function proxyUpstream(url: string, init?: RequestInit) { - const upstream = await fetch(url, init); - const body = await upstream.arrayBuffer(); - - return { - body: Buffer.from(body), - contentType: upstream.headers.get("content-type") ?? "application/octet-stream", - status: upstream.status - }; -} - export function createRequestHandler() { return async function handleRequest(req: IncomingMessage, res: ServerResponse) { const url = new URL(req.url ?? "/", "http://localhost"); @@ -85,97 +17,13 @@ export function createRequestHandler() { } if (url.pathname === "/api/nextcloud/list") { - const share = url.searchParams.get("share"); - - if (!share) { - res.statusCode = 400; - res.setHeader("content-type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ error: "missing `share` parameter" })); - return; - } - - try { - const davUrl = resolveDavUrlFromShareUrl(share); - const xml = - '' + - '' + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - ""; - - const upstream = await proxyUpstream(davUrl, { - method: "PROPFIND", - headers: { - Depth: "1", - "Content-Type": "application/xml; charset=utf-8", - Accept: "application/xml, text/xml" - }, - body: xml - }); - - res.statusCode = upstream.status; - res.setHeader("content-type", upstream.contentType); - res.end(upstream.body); - return; - } catch (error) { - res.statusCode = 502; - res.setHeader("content-type", "application/json; charset=utf-8"); - res.end( - JSON.stringify({ - error: error instanceof Error ? error.message : "upstream request failed" - }) - ); - return; - } + await handleNextcloudListRequest(url, res); + return; } if (url.pathname === "/api/nextcloud/blob") { - const target = url.searchParams.get("url"); - const share = url.searchParams.get("share"); - - if (!target || !share) { - res.statusCode = 400; - res.setHeader("content-type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ error: "missing `share` or `url` parameter" })); - return; - } - - let validatedTarget: string; - - try { - validatedTarget = resolveValidatedBlobUrl(target, share); - } catch (error) { - res.statusCode = 400; - res.setHeader("content-type", "application/json; charset=utf-8"); - res.end( - JSON.stringify({ - error: error instanceof Error ? error.message : "invalid image URL" - }) - ); - return; - } - - try { - const upstream = await proxyUpstream(validatedTarget); - res.statusCode = upstream.status; - res.setHeader("content-type", upstream.contentType); - res.end(upstream.body); - return; - } catch (error) { - res.statusCode = 502; - res.setHeader("content-type", "application/json; charset=utf-8"); - res.end( - JSON.stringify({ - error: error instanceof Error ? error.message : "upstream request failed" - }) - ); - return; - } + await handleNextcloudBlobRequest(url, res); + return; } res.statusCode = 200;