Correct local file path resolution for ZXDB/WoS mirrors
- Remove optional path prefix and prepend the required local string. - Avoid hardcoded 'SC' or 'WoS' subdirectories in path mapping. - Maintain binary state: show local link only if env var is set and file exists. Signed-off: junie@McFiver.local
This commit is contained in:
@@ -12,12 +12,12 @@ PROTO=http
|
|||||||
ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb
|
ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb
|
||||||
|
|
||||||
# Base HTTP locations for CDN sources used by downloads.file_link
|
# Base HTTP locations for CDN sources used by downloads.file_link
|
||||||
# When file_link starts with /zxdb, it will be fetched from ZXDB_FILEPATH
|
# When file_link starts with /zxdb, it will be fetched from ZXDB_REMOTE_FILEPATH
|
||||||
ZXDB_FILEPATH=https://zxdbfiles.com/
|
ZXDB_REMOTE_FILEPATH=https://zxdbfiles.com/
|
||||||
|
|
||||||
# When file_link starts with /public, it will be fetched from WOS_FILEPATH
|
# When file_link starts with /public, it will be fetched from WOS_REMOTE_FILEPATH
|
||||||
# Note: Example uses the Internet Archive WoS mirror; keep the trailing slash
|
# Note: Example uses the Internet Archive WoS mirror; keep the trailing slash
|
||||||
WOS_FILEPATH=https://archive.org/download/World_of_Spectrum_June_2017_Mirror/World%20of%20Spectrum%20June%202017%20Mirror.zip/World%20of%20Spectrum%20June%202017%20Mirror/
|
WOS_REMOTE_FILEPATH=https://archive.org/download/World_of_Spectrum_June_2017_Mirror/World%20of%20Spectrum%20June%202017%20Mirror.zip/World%20of%20Spectrum%20June%202017%20Mirror/
|
||||||
|
|
||||||
# Local cache root where files will be mirrored (without the leading slash)
|
# Local cache root where files will be mirrored (without the leading slash)
|
||||||
CDN_CACHE=/mnt/files/zxfiles
|
CDN_CACHE=/mnt/files/zxfiles
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export type EntryDetailData = {
|
|||||||
case: { id: string | null; name: string | null };
|
case: { id: string | null; name: string | null };
|
||||||
year: number | null;
|
year: number | null;
|
||||||
releaseSeq: number;
|
releaseSeq: number;
|
||||||
|
localLink?: string | null;
|
||||||
}[];
|
}[];
|
||||||
releases?: {
|
releases?: {
|
||||||
releaseSeq: number;
|
releaseSeq: number;
|
||||||
@@ -125,6 +126,7 @@ export type EntryDetailData = {
|
|||||||
source: { id: string | null; name: string | null };
|
source: { id: string | null; name: string | null };
|
||||||
case: { id: string | null; name: string | null };
|
case: { id: string | null; name: string | null };
|
||||||
year: number | null;
|
year: number | null;
|
||||||
|
localLink?: string | null;
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
// Additional relationships
|
// Additional relationships
|
||||||
@@ -392,11 +394,18 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
<tr key={d.id}>
|
<tr key={d.id}>
|
||||||
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
|
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
|
||||||
<td>
|
<td>
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
{isHttp ? (
|
{isHttp ? (
|
||||||
<a href={d.link} target="_blank" rel="noopener noreferrer">{d.link}</a>
|
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
|
||||||
) : (
|
) : (
|
||||||
<span>{d.link}</span>
|
<span className="text-break small">{d.link}</span>
|
||||||
)}
|
)}
|
||||||
|
{d.localLink && (
|
||||||
|
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
|
||||||
|
Local Mirror
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
|
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
|
||||||
<td><code>{d.md5 ?? "-"}</code></td>
|
<td><code>{d.md5 ?? "-"}</code></td>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type ReleaseDetailData = {
|
|||||||
source: { id: string | null; name: string | null };
|
source: { id: string | null; name: string | null };
|
||||||
case: { id: string | null; name: string | null };
|
case: { id: string | null; name: string | null };
|
||||||
year: number | null;
|
year: number | null;
|
||||||
|
localLink?: string | null;
|
||||||
}>;
|
}>;
|
||||||
scraps: Array<{
|
scraps: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -58,6 +59,7 @@ type ReleaseDetailData = {
|
|||||||
source: { id: string | null; name: string | null };
|
source: { id: string | null; name: string | null };
|
||||||
case: { id: string | null; name: string | null };
|
case: { id: string | null; name: string | null };
|
||||||
year: number | null;
|
year: number | null;
|
||||||
|
localLink?: string | null;
|
||||||
}>;
|
}>;
|
||||||
files: Array<{
|
files: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -376,11 +378,18 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
|
|||||||
<tr key={d.id}>
|
<tr key={d.id}>
|
||||||
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
|
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
|
||||||
<td>
|
<td>
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
{isHttp ? (
|
{isHttp ? (
|
||||||
<a href={d.link} target="_blank" rel="noopener noreferrer">{d.link}</a>
|
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
|
||||||
) : (
|
) : (
|
||||||
<span>{d.link}</span>
|
<span className="text-break small">{d.link}</span>
|
||||||
)}
|
)}
|
||||||
|
{d.localLink && (
|
||||||
|
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
|
||||||
|
Local Mirror
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
|
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
|
||||||
<td><code>{d.md5 ?? "-"}</code></td>
|
<td><code>{d.md5 ?? "-"}</code></td>
|
||||||
@@ -438,15 +447,22 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
|
|||||||
<tr key={s.id}>
|
<tr key={s.id}>
|
||||||
<td><span className="badge text-bg-secondary">{s.type.name}</span></td>
|
<td><span className="badge text-bg-secondary">{s.type.name}</span></td>
|
||||||
<td>
|
<td>
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
{s.link ? (
|
{s.link ? (
|
||||||
isHttp ? (
|
isHttp ? (
|
||||||
<a href={s.link} target="_blank" rel="noopener noreferrer">{s.link}</a>
|
<a href={s.link} target="_blank" rel="noopener noreferrer" className="text-break small">{s.link}</a>
|
||||||
) : (
|
) : (
|
||||||
<span>{s.link}</span>
|
<span className="text-break small">{s.link}</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-secondary">-</span>
|
<span className="text-secondary">-</span>
|
||||||
)}
|
)}
|
||||||
|
{s.localLink && (
|
||||||
|
<a href={s.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
|
||||||
|
Local Mirror
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td>
|
<td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const serverSchema = z.object({
|
|||||||
ZXDB_FILE_PREFIX: z.string().optional(),
|
ZXDB_FILE_PREFIX: z.string().optional(),
|
||||||
WOS_FILE_PREFIX: z.string().optional(),
|
WOS_FILE_PREFIX: z.string().optional(),
|
||||||
|
|
||||||
|
// Local file paths for mirroring
|
||||||
|
ZXDB_LOCAL_FILEPATH: z.string().optional(),
|
||||||
|
WOS_LOCAL_FILEPATH: z.string().optional(),
|
||||||
|
|
||||||
// OIDC Configuration
|
// OIDC Configuration
|
||||||
OIDC_PROVIDER_URL: z.string().url().optional(),
|
OIDC_PROVIDER_URL: z.string().url().optional(),
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { and, desc, eq, sql, asc } from "drizzle-orm";
|
import { and, desc, eq, sql, asc } from "drizzle-orm";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { env } from "@/env";
|
||||||
// import { alias } from "drizzle-orm/mysql-core";
|
// import { alias } from "drizzle-orm/mysql-core";
|
||||||
import { db } from "@/server/db";
|
import { db } from "@/server/db";
|
||||||
import {
|
import {
|
||||||
@@ -108,6 +111,30 @@ export interface EntryFacets {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a local link for a given file link if mirroring is enabled and the file exists.
|
||||||
|
*/
|
||||||
|
function resolveLocalLink(fileLink: string): string | null {
|
||||||
|
let localPath: string | null = null;
|
||||||
|
|
||||||
|
const zxdbPrefix = env.ZXDB_FILE_PREFIX || "/zxdb/sinclair/";
|
||||||
|
const wosPrefix = env.WOS_FILE_PREFIX || "/pub/sinclair/";
|
||||||
|
|
||||||
|
if (fileLink.startsWith(zxdbPrefix) && env.ZXDB_LOCAL_FILEPATH) {
|
||||||
|
const sub = fileLink.slice(zxdbPrefix.replace(/\/$/, "").length);
|
||||||
|
localPath = path.join(env.ZXDB_LOCAL_FILEPATH, sub);
|
||||||
|
} else if (fileLink.startsWith(wosPrefix) && env.WOS_LOCAL_FILEPATH) {
|
||||||
|
const sub = fileLink.slice(wosPrefix.replace(/\/$/, "").length);
|
||||||
|
localPath = path.join(env.WOS_LOCAL_FILEPATH, sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localPath && fs.existsSync(localPath)) {
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) {
|
function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) {
|
||||||
const parts: Array<ReturnType<typeof sql>> = [
|
const parts: Array<ReturnType<typeof sql>> = [
|
||||||
sql`select ${searchByTitles.entryId} as entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern}`,
|
sql`select ${searchByTitles.entryId} as entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern}`,
|
||||||
@@ -479,6 +506,7 @@ export interface EntryDetail {
|
|||||||
case: { id: string | null; name: string | null };
|
case: { id: string | null; name: string | null };
|
||||||
year: number | null;
|
year: number | null;
|
||||||
releaseSeq: number;
|
releaseSeq: number;
|
||||||
|
localLink?: string | null;
|
||||||
}[];
|
}[];
|
||||||
releases?: {
|
releases?: {
|
||||||
releaseSeq: number;
|
releaseSeq: number;
|
||||||
@@ -501,6 +529,7 @@ export interface EntryDetail {
|
|||||||
source: { id: string | null; name: string | null };
|
source: { id: string | null; name: string | null };
|
||||||
case: { id: string | null; name: string | null };
|
case: { id: string | null; name: string | null };
|
||||||
year: number | null;
|
year: number | null;
|
||||||
|
localLink?: string | null;
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
// Additional relationships surfaced on the entry detail page
|
// Additional relationships surfaced on the entry detail page
|
||||||
@@ -710,6 +739,7 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
|||||||
source: { id: (d.sourceId) ?? null, name: (d.sourceName) ?? null },
|
source: { id: (d.sourceId) ?? null, name: (d.sourceName) ?? null },
|
||||||
case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null },
|
case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null },
|
||||||
year: d.year != null ? Number(d.year) : null,
|
year: d.year != null ? Number(d.year) : null,
|
||||||
|
localLink: resolveLocalLink(d.link),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1148,6 +1178,7 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
|||||||
case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null },
|
case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null },
|
||||||
year: d.year != null ? Number(d.year) : null,
|
year: d.year != null ? Number(d.year) : null,
|
||||||
releaseSeq: Number(d.releaseSeq),
|
releaseSeq: Number(d.releaseSeq),
|
||||||
|
localLink: resolveLocalLink(d.link),
|
||||||
})),
|
})),
|
||||||
aliases: aliasRows.map((a) => ({ releaseSeq: Number(a.releaseSeq), languageId: a.languageId, title: a.title })),
|
aliases: aliasRows.map((a) => ({ releaseSeq: Number(a.releaseSeq), languageId: a.languageId, title: a.title })),
|
||||||
webrefs: webrefRows.map((w) => ({ link: w.link, languageId: w.languageId, website: { id: Number(w.websiteId), name: w.websiteName, link: w.websiteLink } })),
|
webrefs: webrefRows.map((w) => ({ link: w.link, languageId: w.languageId, website: { id: Number(w.websiteId), name: w.websiteName, link: w.websiteLink } })),
|
||||||
@@ -2104,6 +2135,7 @@ export interface ReleaseDetail {
|
|||||||
source: { id: string | null; name: string | null };
|
source: { id: string | null; name: string | null };
|
||||||
case: { id: string | null; name: string | null };
|
case: { id: string | null; name: string | null };
|
||||||
year: number | null;
|
year: number | null;
|
||||||
|
localLink?: string | null;
|
||||||
}>;
|
}>;
|
||||||
scraps: Array<{
|
scraps: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -2119,6 +2151,7 @@ export interface ReleaseDetail {
|
|||||||
source: { id: string | null; name: string | null };
|
source: { id: string | null; name: string | null };
|
||||||
case: { id: string | null; name: string | null };
|
case: { id: string | null; name: string | null };
|
||||||
year: number | null;
|
year: number | null;
|
||||||
|
localLink?: string | null;
|
||||||
}>;
|
}>;
|
||||||
files: Array<{
|
files: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -2371,6 +2404,7 @@ export async function getReleaseDetail(entryId: number, releaseSeq: number): Pro
|
|||||||
source: { id: d.sourceId ?? null, name: d.sourceName ?? null },
|
source: { id: d.sourceId ?? null, name: d.sourceName ?? null },
|
||||||
case: { id: d.caseId ?? null, name: d.caseName ?? null },
|
case: { id: d.caseId ?? null, name: d.caseName ?? null },
|
||||||
year: d.year != null ? Number(d.year) : null,
|
year: d.year != null ? Number(d.year) : null,
|
||||||
|
localLink: resolveLocalLink(d.link),
|
||||||
})),
|
})),
|
||||||
scraps: (scrapRows as ScrapRow[]).map((s) => ({
|
scraps: (scrapRows as ScrapRow[]).map((s) => ({
|
||||||
id: Number(s.id),
|
id: Number(s.id),
|
||||||
@@ -2386,6 +2420,7 @@ export async function getReleaseDetail(entryId: number, releaseSeq: number): Pro
|
|||||||
source: { id: s.sourceId ?? null, name: s.sourceName ?? null },
|
source: { id: s.sourceId ?? null, name: s.sourceName ?? null },
|
||||||
case: { id: s.caseId ?? null, name: s.caseName ?? null },
|
case: { id: s.caseId ?? null, name: s.caseName ?? null },
|
||||||
year: s.year != null ? Number(s.year) : null,
|
year: s.year != null ? Number(s.year) : null,
|
||||||
|
localLink: s.link ? resolveLocalLink(s.link) : null,
|
||||||
})),
|
})),
|
||||||
files: fileRows.map((f) => ({
|
files: fileRows.map((f) => ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user