Proxies images against a HMAC signature -- this prevents knot URLs from directly being hit and possibly spammed. Also caches images in Cloudflare's global CDN.
···11+# camo
22+33+Camo is Tangled's "camouflage" service much like that of [GitHub's](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-anonymized-urls).
44+55+Camo uses a shared secret `CAMO_SHARED_SECRET` to verify HMAC signatures. URLs are of the form:
66+77+```
88+https://camo.tangled.sh/<signature>/<hex-encoded-origin-url>
99+```
1010+1111+It's pretty barebones for the moment and doesn't support a whole lot of what the
1212+big G's does. Ours is a Cloudflare Worker, deployed using `wrangler` like so:
1313+1414+```
1515+npx wrangler deploy
1616+npx wrangler secrets put CAMO_SHARED_SECRET
1717+```
+101
camo/src/index.js
···11+export default {
22+ async fetch(request, env) {
33+ const url = new URL(request.url);
44+55+ if (url.pathname === "/" || url.pathname === "") {
66+ return new Response(
77+ "This is Tangled's Camo service. It proxies images served from knots via Cloudflare.",
88+ );
99+ }
1010+1111+ const cache = caches.default;
1212+1313+ const pathParts = url.pathname.slice(1).split("/");
1414+ if (pathParts.length < 2) {
1515+ return new Response("Bad URL", { status: 400 });
1616+ }
1717+1818+ const [signatureHex, ...hexUrlParts] = pathParts;
1919+ const hexUrl = hexUrlParts.join("");
2020+ const urlBytes = Uint8Array.from(
2121+ hexUrl.match(/.{2}/g).map((b) => parseInt(b, 16)),
2222+ );
2323+ const targetUrl = new TextDecoder().decode(urlBytes);
2424+2525+ // check if we have an entry in the cache with the target url
2626+ let cacheKey = new Request(targetUrl);
2727+ let response = await cache.match(cacheKey);
2828+ if (response) {
2929+ return response;
3030+ }
3131+3232+ // else compute the signature
3333+ const key = await crypto.subtle.importKey(
3434+ "raw",
3535+ new TextEncoder().encode(env.CAMO_SHARED_SECRET),
3636+ { name: "HMAC", hash: "SHA-256" },
3737+ false,
3838+ ["sign", "verify"],
3939+ );
4040+4141+ const computedSigBuffer = await crypto.subtle.sign("HMAC", key, urlBytes);
4242+ const computedSig = Array.from(new Uint8Array(computedSigBuffer))
4343+ .map((b) => b.toString(16).padStart(2, "0"))
4444+ .join("");
4545+4646+ console.log({
4747+ level: "debug",
4848+ message: "camo target: " + targetUrl,
4949+ computedSignature: computedSig,
5050+ providedSignature: signatureHex,
5151+ targetUrl: targetUrl,
5252+ });
5353+5454+ const sigBytes = Uint8Array.from(
5555+ signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)),
5656+ );
5757+ const valid = await crypto.subtle.verify("HMAC", key, sigBytes, urlBytes);
5858+5959+ if (!valid) {
6060+ return new Response("Invalid signature", { status: 403 });
6161+ }
6262+6363+ let parsedUrl;
6464+ try {
6565+ parsedUrl = new URL(targetUrl);
6666+ if (!["https:", "http:"].includes(parsedUrl.protocol)) {
6767+ return new Response("Only HTTP(S) allowed", { status: 400 });
6868+ }
6969+ } catch {
7070+ return new Response("Malformed URL", { status: 400 });
7171+ }
7272+7373+ // fetch from the parsed URL
7474+ const res = await fetch(parsedUrl.toString(), {
7575+ headers: { "User-Agent": "Tangled Camo v0.1.0" },
7676+ });
7777+7878+ const allowedMimeTypes = require("./mimetypes.json");
7979+8080+ const contentType =
8181+ res.headers.get("Content-Type") || "application/octet-stream";
8282+8383+ if (!allowedMimeTypes.includes(contentType.split(";")[0].trim())) {
8484+ return new Response("Unsupported media type", { status: 415 });
8585+ }
8686+8787+ const headers = new Headers();
8888+ headers.set("Content-Type", contentType);
8989+ headers.set("Cache-Control", "public, max-age=86400, immutable");
9090+9191+ // serve and cache it with cf
9292+ response = new Response(await res.arrayBuffer(), {
9393+ status: res.status,
9494+ headers,
9595+ });
9696+9797+ await cache.put(cacheKey, response.clone());
9898+9999+ return response;
100100+ },
101101+};