Proxy local ZXDB/WoS mirror downloads through application API

- Created `src/app/api/zxdb/download/route.ts` to serve local files.
- Updated `resolveLocalLink` in `src/server/repo/zxdb.ts` to return
  API-relative URLs with `source` and `path` parameters.
- Encodes the relative subpath to ensure correct URL construction.
- Includes security checks in the API route to prevent path traversal.
- Updated `docs/ZXDB.md` to reflect the proxy mechanism.

Signed-off: junie@lucy.xalior.com
This commit is contained in:
2026-02-17 12:43:26 +00:00
parent 2e47b598c1
commit 32985c33b9
3 changed files with 65 additions and 7 deletions

View File

@@ -63,7 +63,7 @@ WOS_FILE_PREFIX=/pub/sinclair/
2. It strips the prefix from the database link. 2. It strips the prefix from the database link.
3. It joins the remaining relative path to the corresponding `*_LOCAL_FILEPATH`. 3. It joins the remaining relative path to the corresponding `*_LOCAL_FILEPATH`.
4. It checks if the file exists on the local disk. 4. It checks if the file exists on the local disk.
5. If the file exists and the environment variable is set, a "Local Mirror" link is displayed in the UI. 5. If the file exists and the environment variable is set, a "Local Mirror" link is displayed in the UI, pointing to a proxy download API (`/api/zxdb/download`).
Note: Obtaining these mirrors is left as an exercise to the host. The paths do not need to share a common parent directory. Both mirrors are optional and independent; you can configure one, both, or neither. Note: Obtaining these mirrors is left as an exercise to the host. The paths do not need to share a common parent directory. Both mirrors are optional and independent; you can configure one, both, or neither.

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { env } from "@/env";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const source = searchParams.get("source");
const filePath = searchParams.get("path");
if (!source || !filePath) {
return new NextResponse("Missing source or path", { status: 400 });
}
let baseDir: string | undefined;
if (source === "zxdb") {
baseDir = env.ZXDB_LOCAL_FILEPATH;
} else if (source === "wos") {
baseDir = env.WOS_LOCAL_FILEPATH;
}
if (!baseDir) {
return new NextResponse("Invalid source or mirroring not enabled", { status: 400 });
}
// Security: Ensure path doesn't escape baseDir
const absolutePath = path.normalize(path.join(baseDir, filePath));
if (!absolutePath.startsWith(path.normalize(baseDir))) {
return new NextResponse("Forbidden", { status: 403 });
}
if (!fs.existsSync(absolutePath)) {
return new NextResponse("File not found", { status: 404 });
}
const stat = fs.statSync(absolutePath);
if (!stat.isFile()) {
return new NextResponse("Not a file", { status: 400 });
}
const fileBuffer = fs.readFileSync(absolutePath);
const fileName = path.basename(absolutePath);
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Length": stat.size.toString(),
},
});
}
export const runtime = "nodejs";

View File

@@ -116,20 +116,25 @@ export interface EntryFacets {
*/ */
function resolveLocalLink(fileLink: string): string | null { function resolveLocalLink(fileLink: string): string | null {
let localPath: string | null = null; let localPath: string | null = null;
let source: "zxdb" | "wos" | null = null;
let subPath: string | null = null;
const zxdbPrefix = env.ZXDB_FILE_PREFIX || ""; const zxdbPrefix = env.ZXDB_FILE_PREFIX || "";
const wosPrefix = env.WOS_FILE_PREFIX || ""; const wosPrefix = env.WOS_FILE_PREFIX || "";
if (fileLink.startsWith(zxdbPrefix) && env.ZXDB_LOCAL_FILEPATH) { if (fileLink.startsWith(zxdbPrefix) && env.ZXDB_LOCAL_FILEPATH) {
const sub = fileLink.slice(zxdbPrefix.replace(/\/$/, "").length); subPath = fileLink.slice(zxdbPrefix.replace(/\/$/, "").length);
localPath = path.join(env.ZXDB_LOCAL_FILEPATH, sub); localPath = path.join(env.ZXDB_LOCAL_FILEPATH, subPath);
source = "zxdb";
} else if (fileLink.startsWith(wosPrefix) && env.WOS_LOCAL_FILEPATH) { } else if (fileLink.startsWith(wosPrefix) && env.WOS_LOCAL_FILEPATH) {
const sub = fileLink.slice(wosPrefix.replace(/\/$/, "").length); subPath = fileLink.slice(wosPrefix.replace(/\/$/, "").length);
localPath = path.join(env.WOS_LOCAL_FILEPATH, sub); localPath = path.join(env.WOS_LOCAL_FILEPATH, subPath);
source = "wos";
} }
if (localPath && fs.existsSync(localPath)) { if (localPath && fs.existsSync(localPath) && source && subPath) {
return localPath; // Return an application-relative URL instead of the absolute filesystem path
return `/api/zxdb/download?source=${source}&path=${encodeURIComponent(subPath)}`;
} }
return null; return null;