feat: rebuild CSP practice workflow, UX and automation
这个提交包含在:
@@ -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);
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户