Serve images from R2 via files.iddaai.com
Deploy Iddaai Backend / build-and-deploy (push) Successful in 54s
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:
@@ -46,6 +46,7 @@ jobs:
|
||||
-e AI_ENGINE_URL='http://iddaai-ai-engine:8000' \
|
||||
-e JWT_SECRET='${{ secrets.JWT_SECRET }}' \
|
||||
-e JWT_ACCESS_EXPIRATION='1d' \
|
||||
-e IMAGE_BASE_URL='https://files.iddaai.com' \
|
||||
iddaai-be:latest /bin/sh -c "npx prisma migrate deploy && node dist/src/main.js"
|
||||
|
||||
- name: Saglik Kontrolu
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.wrangler/
|
||||
@@ -0,0 +1,51 @@
|
||||
# iddaai image proxy (Cloudflare Worker + R2)
|
||||
|
||||
Takım / lig / ülke görsellerini R2'den servis eden lazy-fill proxy.
|
||||
İstek akışı: **edge cache → R2 → upstream (mackolik) → R2'ye yaz**.
|
||||
Bucket kendi kendine dolar; bir görsel R2'ye girdikten sonra upstream
|
||||
kaldırsa bile kalıcıdır.
|
||||
|
||||
## Canlı kurulum (2026-06-10)
|
||||
|
||||
- Worker: `iddaai-image-proxy` (dashboard üzerinden deploy edildi)
|
||||
- Bucket: `iddaai-images` (binding adı: `BUCKET`)
|
||||
- Domain: `https://files.iddaai.com`
|
||||
- BE env: `IMAGE_BASE_URL=https://files.iddaai.com` (.gitea/workflows/deploy.yml)
|
||||
|
||||
Dashboard'daki kod ile `src/index.ts` aynı mantıktır (dashboard'da JS,
|
||||
burada TS). Kod değişikliği gerekirse ikisini senkron tut veya
|
||||
`npm run deploy` ile buradan deploy et.
|
||||
|
||||
## URL şeması (upstream ile birebir aynı)
|
||||
|
||||
```
|
||||
GET /teams/<teamId> → takım logosu
|
||||
GET /competitions/<leagueId> → lig logosu
|
||||
GET /areas/<countryId> → ülke bayrağı
|
||||
```
|
||||
|
||||
## CLI ile geliştirme / deploy
|
||||
|
||||
```bash
|
||||
cd workers/image-proxy
|
||||
npm install
|
||||
npm run dev # lokal test (miniflare, lokal R2 simülasyonu)
|
||||
npm run typecheck
|
||||
npx wrangler login # ilk seferde
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
## Bucket'ı önceden doldurma (opsiyonel)
|
||||
|
||||
```bash
|
||||
../../scripts/warm-image-cache.sh https://files.iddaai.com
|
||||
```
|
||||
|
||||
Prod sunucuda çalışır; DB'deki tüm takım/lig/ülke ID'lerini Worker
|
||||
üzerinden bir kez ister, Worker her birini R2'ye yazar (~20K istek,
|
||||
~200 MB). Çalıştırılmasa da bucket trafikle kendi kendine dolar.
|
||||
|
||||
## Maliyet
|
||||
|
||||
R2 free tier: 10 GB depolama + sınırsız egress. Workers free tier:
|
||||
100K istek/gün. Bu ölçekte aylık maliyet: 0.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "iddaai-image-proxy",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260601.0",
|
||||
"typescript": "^5.6.0",
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
name = "iddaai-image-proxy"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-06-01"
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "BUCKET"
|
||||
bucket_name = "iddaai-images"
|
||||
|
||||
# Custom domain (configured in the Cloudflare dashboard on 2026-06-10;
|
||||
# kept here so CLI deploys stay in sync).
|
||||
[[routes]]
|
||||
pattern = "files.iddaai.com"
|
||||
custom_domain = true
|
||||
Reference in New Issue
Block a user