refactor: extract nextcloud proxy handlers
This commit is contained in:
144
src/server/nextcloud-proxy.ts
Normal file
144
src/server/nextcloud-proxy.ts
Normal 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"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,78 +1,10 @@
|
|||||||
import { htmlPage } from "./page-template.js";
|
import { htmlPage } from "./page-template.js";
|
||||||
|
import {
|
||||||
|
handleNextcloudBlobRequest,
|
||||||
|
handleNextcloudListRequest
|
||||||
|
} from "./nextcloud-proxy.js";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
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() {
|
export function createRequestHandler() {
|
||||||
return async function handleRequest(req: IncomingMessage, res: ServerResponse) {
|
return async function handleRequest(req: IncomingMessage, res: ServerResponse) {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
@@ -85,97 +17,13 @@ export function createRequestHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/nextcloud/list") {
|
if (url.pathname === "/api/nextcloud/list") {
|
||||||
const share = url.searchParams.get("share");
|
await handleNextcloudListRequest(url, res);
|
||||||
|
return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/nextcloud/blob") {
|
if (url.pathname === "/api/nextcloud/blob") {
|
||||||
const target = url.searchParams.get("url");
|
await handleNextcloudBlobRequest(url, res);
|
||||||
const share = url.searchParams.get("share");
|
return;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
|
|||||||
Reference in New Issue
Block a user