Serve images from R2 via files.iddaai.com
Deploy Iddaai Backend / build-and-deploy (push) Successful in 54s

Add IMAGE_BASE_URL to the deploy env so the backend builds image URLs
against the Cloudflare image-proxy Worker instead of mackolik, and check
in the Worker source (already live on files.iddaai.com).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:24:38 +03:00
parent e0fbde2fde
commit 9a8f9941b6
7 changed files with 206 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
/**
* iddaai image proxy — Cloudflare Worker in front of an R2 bucket.
*
* Request flow for GET /teams/<id>, /competitions/<id>, /areas/<id>:
* 1. Cloudflare edge cache
* 2. R2 bucket (permanent mirror)
* 3. Upstream (file.mackolikfeeds.com) → stored in R2 on the way out
*
* The bucket fills itself lazily; once an image lands in R2 it is served
* from there forever, even if the upstream removes it.
*
* NOTE: deployed manually via the Cloudflare dashboard on 2026-06-10
* (worker name: iddaai-image-proxy, domain: files.iddaai.com). Keep this
* file in sync with the dashboard copy, or deploy from here with
* `npm run deploy`.
*/
export interface Env {
BUCKET: R2Bucket;
}
const UPSTREAM_BASE = "https://file.mackolikfeeds.com";
const VALID_KEY = /^(teams|competitions|areas)\/[A-Za-z0-9_-]{1,64}$/;
// Browsers revalidate daily; the edge keeps hits for a week. Misses are
// cached briefly so a missing logo doesn't hammer the upstream.
const HIT_CACHE_CONTROL = "public, max-age=86400, s-maxage=604800";
const MISS_CACHE_CONTROL = "public, max-age=3600";
function imageResponse(
body: BodyInit | null,
contentType: string | undefined,
etag?: string,
): Response {
const headers = new Headers({
"Content-Type": contentType ?? "image/png",
"Cache-Control": HIT_CACHE_CONTROL,
"Access-Control-Allow-Origin": "*",
});
if (etag) headers.set("ETag", etag);
return new Response(body, { headers });
}
function notFound(): Response {
return new Response("Not found", {
status: 404,
headers: { "Cache-Control": MISS_CACHE_CONTROL },
});
}
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
if (request.method !== "GET" && request.method !== "HEAD") {
return new Response("Method not allowed", { status: 405 });
}
const key = new URL(request.url).pathname.slice(1);
if (!VALID_KEY.test(key)) return notFound();
// The Cache API only stores GET entries; use a GET key for HEAD too.
const cache = caches.default;
const cacheKey = new Request(new URL(request.url).toString());
const cached = await cache.match(cacheKey);
if (cached) {
return request.method === "HEAD"
? new Response(null, cached)
: cached;
}
// 2. Permanent mirror in R2
const object = await env.BUCKET.get(key);
if (object) {
const response = imageResponse(
object.body,
object.httpMetadata?.contentType,
object.httpEtag,
);
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return request.method === "HEAD"
? new Response(null, response)
: response;
}
// 3. Upstream fetch + mirror into R2 (images are small, buffer them)
const upstream = await fetch(`${UPSTREAM_BASE}/${key}`, {
cf: { cacheTtl: 0 },
});
if (!upstream.ok) {
const response = notFound();
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}
const contentType =
upstream.headers.get("Content-Type") ?? "image/png";
const buffer = await upstream.arrayBuffer();
ctx.waitUntil(
env.BUCKET.put(key, buffer, {
httpMetadata: { contentType },
}),
);
const response = imageResponse(buffer, contentType);
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return request.method === "HEAD"
? new Response(null, response)
: response;
},
} satisfies ExportedHandler<Env>;