diff --git a/package.json b/package.json index 7eb4e6a..46e05ca 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "tsc -p tsconfig.json", "dev": "node --watch dist/index.js", "start": "node dist/index.js", + "test": "npm run build && node --test dist/server/nextcloud-proxy.test.js", "check": "tsc -p tsconfig.json --noEmit" }, "engines": { diff --git a/src/server/nextcloud-proxy.test.ts b/src/server/nextcloud-proxy.test.ts new file mode 100644 index 0000000..754071e --- /dev/null +++ b/src/server/nextcloud-proxy.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + parseHttpUrl, + resolveDavUrlFromShareUrl, + resolveValidatedBlobUrl +} from "./nextcloud-proxy.js"; + +describe("Nextcloud public share URL handling", () => { + it("resolves public share URLs to DAV roots", () => { + assert.equal( + resolveDavUrlFromShareUrl("https://cloud.example.com/index.php/s/share-token"), + "https://cloud.example.com/public.php/dav/files/share-token/" + ); + }); + + it("keeps DAV share URLs normalized", () => { + assert.equal( + resolveDavUrlFromShareUrl("https://cloud.example.com/public.php/dav/files/share-token/photos"), + "https://cloud.example.com/public.php/dav/files/share-token/" + ); + }); + + it("rejects non-HTTP URLs and embedded credentials", () => { + assert.throws( + () => parseHttpUrl("file:///tmp/photo.jpg", "Invalid image URL."), + /Invalid image URL/ + ); + assert.throws( + () => parseHttpUrl("https://user:pass@cloud.example.com/index.php/s/share-token", "Invalid image URL."), + /URL credentials are not allowed/ + ); + }); + + it("allows image URLs inside the selected share", () => { + assert.equal( + resolveValidatedBlobUrl( + "https://cloud.example.com/public.php/dav/files/share-token/folder/photo.jpg", + "https://cloud.example.com/index.php/s/share-token" + ), + "https://cloud.example.com/public.php/dav/files/share-token/folder/photo.jpg" + ); + }); + + it("rejects image URLs outside the selected share", () => { + assert.throws( + () => resolveValidatedBlobUrl( + "https://cloud.example.com/public.php/dav/files/other-token/photo.jpg", + "https://cloud.example.com/index.php/s/share-token" + ), + /outside the requested Nextcloud share/ + ); + assert.throws( + () => resolveValidatedBlobUrl( + "https://other.example.com/public.php/dav/files/share-token/photo.jpg", + "https://cloud.example.com/index.php/s/share-token" + ), + /outside the requested Nextcloud share/ + ); + }); +}); diff --git a/src/server/nextcloud-proxy.ts b/src/server/nextcloud-proxy.ts index de2e293..3784318 100644 --- a/src/server/nextcloud-proxy.ts +++ b/src/server/nextcloud-proxy.ts @@ -1,6 +1,6 @@ import type { ServerResponse } from "node:http"; -function parseHttpUrl(value: string, errorMessage: string): URL { +export function parseHttpUrl(value: string, errorMessage: string): URL { const url = new URL(value.trim()); if (url.protocol !== "https:" && url.protocol !== "http:") { @@ -14,7 +14,7 @@ function parseHttpUrl(value: string, errorMessage: string): URL { return url; } -function resolveDavUrlFromShareUrl(shareUrl: string): string { +export function resolveDavUrlFromShareUrl(shareUrl: string): string { const url = parseHttpUrl(shareUrl, "Please enter a public Nextcloud share link."); const publicShare = url.pathname.match(/\/s\/([^/?#]+)/); @@ -31,7 +31,7 @@ function resolveDavUrlFromShareUrl(shareUrl: string): string { throw new Error("Please enter a public Nextcloud share link."); } -function resolveValidatedBlobUrl(targetUrl: string, shareUrl: string): string { +export function resolveValidatedBlobUrl(targetUrl: string, shareUrl: string): string { const target = parseHttpUrl(targetUrl, "Invalid image URL."); const shareDavUrl = new URL(resolveDavUrlFromShareUrl(shareUrl));