feat: rebuild CSP practice workflow, UX and automation

这个提交包含在:
Codex CLI
2026-02-13 15:49:05 +08:00
父节点 d33deed4c5
当前提交 e2ab522b78
修改 105 个文件,包含 15669 行新增428 行删除

查看文件

@@ -0,0 +1,136 @@
import { createHash } from "crypto";
import { promises as fs } from "fs";
import path from "path";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const CACHE_DIR = process.env.CSP_IMAGE_CACHE_DIR ?? "/tmp/csp-image-cache";
const MAX_BYTES = 5 * 1024 * 1024;
function toArrayBuffer(view: Uint8Array): ArrayBuffer {
return view.buffer.slice(
view.byteOffset,
view.byteOffset + view.byteLength
) as ArrayBuffer;
}
function pickExt(urlObj: URL, contentType: string): string {
const fromPath = path.extname(urlObj.pathname || "").toLowerCase();
if (fromPath && fromPath.length <= 10) return fromPath;
if (contentType.includes("image/png")) return ".png";
if (contentType.includes("image/jpeg")) return ".jpg";
if (contentType.includes("image/webp")) return ".webp";
if (contentType.includes("image/gif")) return ".gif";
if (contentType.includes("image/svg+xml")) return ".svg";
return ".img";
}
async function readCachedByKey(
key: string
): Promise<{ data: Uint8Array; contentType: string } | null> {
try {
const files = await fs.readdir(CACHE_DIR);
const hit = files.find((name) => name.startsWith(`${key}.`));
if (!hit) return null;
const ext = path.extname(hit).toLowerCase();
let contentType = "application/octet-stream";
if (ext === ".png") contentType = "image/png";
else if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
else if (ext === ".webp") contentType = "image/webp";
else if (ext === ".gif") contentType = "image/gif";
else if (ext === ".svg") contentType = "image/svg+xml";
const data = new Uint8Array(await fs.readFile(path.join(CACHE_DIR, hit)));
return { data, contentType };
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
const raw = req.nextUrl.searchParams.get("url") ?? "";
if (!raw) {
return NextResponse.json({ ok: false, error: "missing url" }, { status: 400 });
}
let target: URL;
try {
target = new URL(raw);
} catch {
return NextResponse.json({ ok: false, error: "invalid url" }, { status: 400 });
}
if (target.protocol !== "http:" && target.protocol !== "https:") {
return NextResponse.json({ ok: false, error: "only http/https allowed" }, { status: 400 });
}
await fs.mkdir(CACHE_DIR, { recursive: true });
const key = createHash("sha1").update(target.toString()).digest("hex");
const probe = await readCachedByKey(key);
if (probe) {
const body = new Blob([toArrayBuffer(probe.data)], { type: probe.contentType });
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": probe.contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 12000);
try {
const resp = await fetch(target.toString(), {
signal: controller.signal,
headers: {
"User-Agent": "csp-platform-image-cache/1.0",
},
cache: "no-store",
});
if (!resp.ok) {
return NextResponse.json(
{ ok: false, error: `fetch image failed: HTTP ${resp.status}` },
{ status: 502 }
);
}
const contentType = (resp.headers.get("content-type") ?? "").toLowerCase();
if (!contentType.startsWith("image/")) {
return NextResponse.json({ ok: false, error: "url is not an image" }, { status: 400 });
}
const data = new Uint8Array(await resp.arrayBuffer());
if (data.length <= 0 || data.length > MAX_BYTES) {
return NextResponse.json(
{ ok: false, error: "image is empty or too large" },
{ status: 400 }
);
}
const ext = pickExt(target, contentType);
const finalFile = path.join(CACHE_DIR, `${key}${ext}`);
await fs.writeFile(finalFile, data);
const body = new Blob([toArrayBuffer(data)], { type: contentType });
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (e: unknown) {
return NextResponse.json(
{ ok: false, error: `fetch image failed: ${String(e)}` },
{ status: 502 }
);
} finally {
clearTimeout(timer);
}
}