This repository has no description
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 signature before we lookup cache, if we do the other way
26 // then any random signature for the same url will be let through after
27 // a single successful request
28 const key = await crypto.subtle.importKey(
29 "raw",
30 new TextEncoder().encode(env.CAMO_SHARED_SECRET),
31 { name: "HMAC", hash: "SHA-256" },
32 false,
33 ["sign", "verify"],
34 );
35
36 const computedSigBuffer = await crypto.subtle.sign("HMAC", key, urlBytes);
37 const computedSig = Array.from(new Uint8Array(computedSigBuffer))
38 .map((b) => b.toString(16).padStart(2, "0"))
39 .join("");
40
41 console.log({
42 level: "debug",
43 message: "camo target: " + targetUrl,
44 computedSignature: computedSig,
45 providedSignature: signatureHex,
46 targetUrl: targetUrl,
47 });
48
49 const sigBytes = Uint8Array.from(
50 signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)),
51 );
52 const valid = await crypto.subtle.verify("HMAC", key, sigBytes, urlBytes);
53
54 if (!valid) {
55 return new Response("Invalid signature", { status: 403 });
56 }
57
58 // check if we have an entry in the cache with the target url
59 let cacheKey = new Request(targetUrl);
60 let response = await cache.match(cacheKey);
61 if (response) {
62 return response;
63 }
64
65 let parsedUrl;
66 try {
67 parsedUrl = new URL(targetUrl);
68 if (!["https:", "http:"].includes(parsedUrl.protocol)) {
69 return new Response("Only HTTP(S) allowed", { status: 400 });
70 }
71 } catch {
72 return new Response("Malformed URL", { status: 400 });
73 }
74
75 // fetch from the parsed URL
76 const res = await fetch(parsedUrl.toString(), {
77 headers: { "User-Agent": "Tangled Camo v0.1.0" },
78 });
79
80 const allowedMimeTypes = require("./mimetypes.json");
81
82 const contentType =
83 res.headers.get("Content-Type") || "application/octet-stream";
84
85 if (!allowedMimeTypes.includes(contentType.split(";")[0].trim())) {
86 return new Response("Unsupported media type", { status: 415 });
87 }
88
89 const headers = new Headers();
90 headers.set("Content-Type", contentType);
91 headers.set("Cache-Control", "public, max-age=86400, immutable");
92
93 // serve and cache it with cf
94 response = new Response(await res.arrayBuffer(), {
95 status: res.status,
96 headers,
97 });
98
99 await cache.put(cacheKey, response.clone());
100
101 return response;
102 },
103};