You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
80 lines
2.9 KiB
80 lines
2.9 KiB
import { resolve, join, relative } from "node:path";
|
|
import { promises as fsp } from "node:fs";
|
|
import { decodePath, withLeadingSlash, withoutTrailingSlash, parseURL } from "ufo";
|
|
import mime from "mime";
|
|
import { STATIC_DIR, STATIC_PUBLIC_PREFIX } from "../constants/upload";
|
|
|
|
if (import.meta.dev) {
|
|
console.log("plugin: 00.static");
|
|
}
|
|
|
|
const METHODS = new Set(["HEAD", "GET"]);
|
|
const SAFE_BASE_DIR = resolve(STATIC_DIR);
|
|
|
|
const CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
|
|
export default defineNitroPlugin((nitroApp) => {
|
|
nitroApp.hooks.hook("request", async (event) => {
|
|
if (!event.path.startsWith(STATIC_PUBLIC_PREFIX)) return;
|
|
|
|
const { req } = event.node;
|
|
const method = req.method;
|
|
|
|
if (method && !METHODS.has(method)) return;
|
|
|
|
try {
|
|
const url = event.path.replace(STATIC_PUBLIC_PREFIX, "");
|
|
const pathname = decodePath(
|
|
withLeadingSlash(withoutTrailingSlash(parseURL(url).pathname))
|
|
);
|
|
|
|
const targetPath = join(SAFE_BASE_DIR, pathname);
|
|
const resolvedPath = resolve(targetPath);
|
|
|
|
// 安全校验
|
|
const rel = relative(SAFE_BASE_DIR, resolvedPath);
|
|
if (rel.startsWith("..") || rel === "..") {
|
|
event.respondWith(new Response("Forbidden", { status: 403 }));
|
|
return;
|
|
}
|
|
|
|
const stat = await fsp.stat(resolvedPath);
|
|
if (!stat.isFile()) {
|
|
event.respondWith(new Response("Not Found", { status: 404 }));
|
|
return;
|
|
}
|
|
|
|
const mtime = stat.mtime.toUTCString();
|
|
const etag = `"${stat.mtime.getTime().toString(16)}-${stat.size.toString(16)}"`;
|
|
const contentType = mime.getType(resolvedPath) || "application/octet-stream";
|
|
|
|
const headers: Record<string, string> = {
|
|
"Cache-Control": CACHE_CONTROL,
|
|
ETag: etag,
|
|
"Last-Modified": mtime,
|
|
Connection: "close",
|
|
};
|
|
|
|
// 304 协商缓存
|
|
const ifNoneMatch = req.headers["if-none-match"];
|
|
const ifModifiedSince = req.headers["if-modified-since"];
|
|
if (ifNoneMatch === etag || (ifModifiedSince && ifModifiedSince === mtime)) {
|
|
event.respondWith(new Response(null, { status: 304, headers }));
|
|
return;
|
|
}
|
|
|
|
headers["Content-Type"] = contentType;
|
|
headers["Content-Length"] = String(stat.size);
|
|
|
|
const body = method === "HEAD" ? null : await fsp.readFile(resolvedPath);
|
|
|
|
event.respondWith(new Response(body, { status: 200, headers }));
|
|
} catch (err: any) {
|
|
if (err.code === "ENOENT") {
|
|
event.respondWith(new Response("Not Found", { status: 404 }));
|
|
return;
|
|
}
|
|
event.respondWith(new Response("Server Error", { status: 500 }));
|
|
}
|
|
});
|
|
});
|
|
|