refactor: extract nextcloud proxy handlers

This commit is contained in:
2026-06-13 08:17:49 +02:00
parent 62b1745c0a
commit 9c1a6acbb5
2 changed files with 152 additions and 160 deletions

View File

@@ -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<void> {
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 =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<d:propfind xmlns:d="DAV:">' +
"<d:prop>" +
"<d:displayname/>" +
"<d:getlastmodified/>" +
"<d:getcontenttype/>" +
"<d:getcontentlength/>" +
"<d:resourcetype/>" +
"</d:prop>" +
"</d:propfind>";
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<void> {
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"
})
);
}
}

View File

@@ -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 =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<d:propfind xmlns:d="DAV:">' +
"<d:prop>" +
"<d:displayname/>" +
"<d:getlastmodified/>" +
"<d:getcontenttype/>" +
"<d:getcontentlength/>" +
"<d:resourcetype/>" +
"</d:prop>" +
"</d:propfind>";
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;