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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
53
src/app/api/zxdb/download/route.ts
Normal file
53
src/app/api/zxdb/download/route.ts
Normal 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";
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user