feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
/** Cloudflare Worker entry point for the vinext-starter template. */
|
||||
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
|
||||
import handler from "vinext/server/app-router-entry";
|
||||
import { runScheduledRetentionMaintenance } from "../db/retention-runner";
|
||||
import { withSecurityHeaders } from "./security-headers";
|
||||
|
||||
interface Env {
|
||||
ASSETS: Fetcher;
|
||||
DB: D1Database;
|
||||
IMAGES: {
|
||||
input(stream: ReadableStream): {
|
||||
transform(options: Record<string, unknown>): {
|
||||
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ExecutionContext {
|
||||
waitUntil(promise: Promise<unknown>): void;
|
||||
passThroughOnException(): void;
|
||||
}
|
||||
|
||||
// Image security config. SVG sources with .svg extension auto-skip the
|
||||
// optimization endpoint on the client side (served directly, no proxy).
|
||||
// To route SVGs through the optimizer (with security headers), set
|
||||
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
|
||||
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
|
||||
|
||||
const worker = {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/_vinext/image") {
|
||||
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
|
||||
const response = await handleImageOptimization(request, {
|
||||
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
|
||||
transformImage: async (body, { width, format, quality }) => {
|
||||
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
|
||||
return result.response();
|
||||
},
|
||||
}, allowedWidths);
|
||||
return withSecurityHeaders(request, response);
|
||||
}
|
||||
|
||||
const response = await handler.fetch(request, env, ctx);
|
||||
return withSecurityHeaders(request, response);
|
||||
},
|
||||
async scheduled(controller: ScheduledController, env: Env): Promise<void> {
|
||||
await runScheduledRetentionMaintenance(env.DB, controller.scheduledTime);
|
||||
},
|
||||
};
|
||||
|
||||
export default worker;
|
||||
@@ -0,0 +1,81 @@
|
||||
export const CONTENT_SECURITY_POLICY = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"frame-src 'none'",
|
||||
"form-action 'self'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
"worker-src 'self' blob:",
|
||||
"media-src 'none'",
|
||||
].join("; ");
|
||||
|
||||
export const PERMISSIONS_POLICY = [
|
||||
"accelerometer=()",
|
||||
"autoplay=()",
|
||||
"camera=()",
|
||||
"display-capture=()",
|
||||
"encrypted-media=()",
|
||||
"geolocation=()",
|
||||
"gyroscope=()",
|
||||
"magnetometer=()",
|
||||
"microphone=()",
|
||||
"midi=()",
|
||||
"payment=()",
|
||||
"picture-in-picture=()",
|
||||
"screen-wake-lock=()",
|
||||
"serial=()",
|
||||
"usb=()",
|
||||
].join(", ");
|
||||
|
||||
const SECURITY_HEADERS = {
|
||||
"Content-Security-Policy": CONTENT_SECURITY_POLICY,
|
||||
"Permissions-Policy": PERMISSIONS_POLICY,
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-DNS-Prefetch-Control": "off",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Permitted-Cross-Domain-Policies": "none",
|
||||
} as const;
|
||||
|
||||
export function withSecurityHeaders(
|
||||
request: Request,
|
||||
response: Response,
|
||||
): Response {
|
||||
// A WebSocket upgrade response cannot be reconstructed with the standard
|
||||
// Response constructor. The current Cloud control plane has no WS route;
|
||||
// preserve future upgrades rather than accidentally stripping the socket.
|
||||
if (response.status === 101) return response;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const headers = new Headers(response.headers);
|
||||
for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
if (
|
||||
url.pathname === "/api" ||
|
||||
url.pathname.startsWith("/api/") ||
|
||||
url.pathname === "/dashboard" ||
|
||||
url.pathname.startsWith("/dashboard/") ||
|
||||
url.pathname === "/admin" ||
|
||||
url.pathname.startsWith("/admin/")
|
||||
) {
|
||||
headers.set("Cache-Control", "private, no-store");
|
||||
}
|
||||
if (url.protocol === "https:") {
|
||||
headers.set("Strict-Transport-Security", "max-age=31536000");
|
||||
} else {
|
||||
headers.delete("Strict-Transport-Security");
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user