export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === "/" || url.pathname === "") { return new Response( "This is Tangled's Camo service. It proxies images served from knots via Cloudflare.", ); } const cache = caches.default; const pathParts = url.pathname.slice(1).split("/"); if (pathParts.length < 2) { return new Response("Bad URL", { status: 400 }); } const [signatureHex, ...hexUrlParts] = pathParts; const hexUrl = hexUrlParts.join(""); const urlBytes = Uint8Array.from( hexUrl.match(/.{2}/g).map((b) => parseInt(b, 16)), ); const targetUrl = new TextDecoder().decode(urlBytes); // check if we have an entry in the cache with the target url let cacheKey = new Request(targetUrl); let response = await cache.match(cacheKey); if (response) { return response; } // else compute the signature const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(env.CAMO_SHARED_SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"], ); const computedSigBuffer = await crypto.subtle.sign("HMAC", key, urlBytes); const computedSig = Array.from(new Uint8Array(computedSigBuffer)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); console.log({ level: "debug", message: "camo target: " + targetUrl, computedSignature: computedSig, providedSignature: signatureHex, targetUrl: targetUrl, }); const sigBytes = Uint8Array.from( signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)), ); const valid = await crypto.subtle.verify("HMAC", key, sigBytes, urlBytes); if (!valid) { return new Response("Invalid signature", { status: 403 }); } let parsedUrl; try { parsedUrl = new URL(targetUrl); if (!["https:", "http:"].includes(parsedUrl.protocol)) { return new Response("Only HTTP(S) allowed", { status: 400 }); } } catch { return new Response("Malformed URL", { status: 400 }); } // fetch from the parsed URL const res = await fetch(parsedUrl.toString(), { headers: { "User-Agent": "Tangled Camo v0.1.0" }, }); const allowedMimeTypes = require("./mimetypes.json"); const contentType = res.headers.get("Content-Type") || "application/octet-stream"; if (!allowedMimeTypes.includes(contentType.split(";")[0].trim())) { return new Response("Unsupported media type", { status: 415 }); } const headers = new Headers(); headers.set("Content-Type", contentType); headers.set("Cache-Control", "public, max-age=86400, immutable"); // serve and cache it with cf response = new Response(await res.arrayBuffer(), { status: res.status, headers, }); await cache.put(cacheKey, response.clone()); return response; }, };