1export default {
2 async fetch(request, env) {
3 const url = new URL(request.url);
4
5 if (url.pathname === "/" || url.pathname === "") {
6 return new Response(
7 "This is Tangled's Camo service. It proxies images served from knots via Cloudflare.",
8 );
9 }
10
11 const cache = caches.default;
12
13 const pathParts = url.pathname.slice(1).split("/");
14 if (pathParts.length < 2) {
15 return new Response("Bad URL", { status: 400 });
16 }
17
18 const [signatureHex, ...hexUrlParts] = pathParts;
19 const hexUrl = hexUrlParts.join("");
20 const urlBytes = Uint8Array.from(
21 hexUrl.match(/.{2}/g).map((b) => parseInt(b, 16)),
22 );
23 const targetUrl = new TextDecoder().decode(urlBytes);
24
25 // check if we have an entry in the cache with the target url
26 let cacheKey = new Request(targetUrl);
27 let response = await cache.match(cacheKey);
28 if (response) {
29 return response;
30 }
31
32 // else compute the signature
33 const key = await crypto.subtle.importKey(
34 "raw",
35 new TextEncoder().encode(env.CAMO_SHARED_SECRET),
36 { name: "HMAC", hash: "SHA-256" },
37 false,
38 ["sign", "verify"],
39 );
40
41 const computedSigBuffer = await crypto.subtle.sign("HMAC", key, urlBytes);
42 const computedSig = Array.from(new Uint8Array(computedSigBuffer))
43 .map((b) => b.toString(16).padStart(2, "0"))
44 .join("");
45
46 console.log({
47 level: "debug",
48 message: "camo target: " + targetUrl,
49 computedSignature: computedSig,
50 providedSignature: signatureHex,
51 targetUrl: targetUrl,
52 });
53
54 const sigBytes = Uint8Array.from(
55 signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)),
56 );
57 const valid = await crypto.subtle.verify("HMAC", key, sigBytes, urlBytes);
58
59 if (!valid) {
60 return new Response("Invalid signature", { status: 403 });
61 }
62
63 let parsedUrl;
64 try {
65 parsedUrl = new URL(targetUrl);
66 if (!["https:", "http:"].includes(parsedUrl.protocol)) {
67 return new Response("Only HTTP(S) allowed", { status: 400 });
68 }
69 } catch {
70 return new Response("Malformed URL", { status: 400 });
71 }
72
73 // fetch from the parsed URL
74 const res = await fetch(parsedUrl.toString(), {
75 headers: { "User-Agent": "Tangled Camo v0.1.0" },
76 });
77
78 const allowedMimeTypes = require("./mimetypes.json");
79
80 const contentType =
81 res.headers.get("Content-Type") || "application/octet-stream";
82
83 if (!allowedMimeTypes.includes(contentType.split(";")[0].trim())) {
84 return new Response("Unsupported media type", { status: 415 });
85 }
86
87 const headers = new Headers();
88 headers.set("Content-Type", contentType);
89 headers.set("Cache-Control", "public, max-age=86400, immutable");
90
91 // serve and cache it with cf
92 response = new Response(await res.arrayBuffer(), {
93 status: res.status,
94 headers,
95 });
96
97 await cache.put(cacheKey, response.clone());
98
99 return response;
100 },
101};