diff --git a/README.md b/README.md index 32852a2..2b2cf99 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Literal slashes (`/`) and dots (`.`) in the URL path are **rejected** to prevent - **Fragments (`#...`)** are never sent to the server by browsers - **Double-encoded values** are decoded once — `%2520` becomes `%20` (literal), not a space - Two queries that normalize to the same string share the same cached image -- **Nothing found?** If a search finds no image (or fewer than `i` working ones), an [AI image](#ai-images) of the query is served instead of `bad.webp`. It's kept for 24h, so a later search can still find a real one. `bad.webp` is only served if the AI image fails too +- **Nothing found?** If a search finds no image (or fewer than `i` working ones), an [AI image](#ai-images) of the query is served instead of `bad.webp`, and cached like any other image. `bad.webp` is only served if the AI image fails too ## Free Images @@ -150,12 +150,12 @@ Limits change over time, so they aren't listed here. **Check the Rate Limits sec - **New searches (cache misses)** are limited per IP per day, then `limit.webp` is served. `direct-img.link`, `free.direct-img.link` and `ai.direct-img.link` have separate counters, all reset at midnight UTC - **Cache hits are unlimited** (within the burst limit) -- Searches that find nothing count toward the limit. The AI image served instead (or `bad.webp`, if that fails too) is kept for 24h +- Searches that find nothing count toward the limit. If the AI image served instead fails too, the failure is remembered for 24h (`bad.webp`) - A Cloudflare WAF burst limit applies to all requests (HTTP 429). Its counters are approximate (not shared perfectly across Cloudflare servers), so bursts may slip slightly past it ## Caching -- Images are cached for **90 days**, except AI images served because a search found nothing, which are kept for **24 hours** +- Images are cached for **90 days**, including AI images - After expiry, the next request triggers a fresh search - Images are stored in their original format as fetched from source (AI images are WebP) - A matching R2 lifecycle rule deletes stored objects after 90 days @@ -267,7 +267,7 @@ Other keys share the namespace. They use uppercase prefixes, which can never col | `AI:::` (e.g. `AI:auroraimg-6m:2:frog wizard`) | Same as above | 90 days | | `OPENVERSE_TOKEN` | Openverse access token | Token lifetime minus 10 min | -An AI image served because a search found nothing is stored under the search's own key with `"ttl":86400` added to the value, and kept for 24h. A search where that also fails stores `{"t":…,"err":true}` for 24h. +An AI image served because a search found nothing is stored under the search's own key, like any other image. A search where that also fails stores `{"t":…,"err":true}` for 24h. ### Database: `SurrealDB` (Rate Limiting + Search Locks) diff --git a/functions/[[path]].js b/functions/[[path]].js index 68cc060..3607810 100644 --- a/functions/[[path]].js +++ b/functions/[[path]].js @@ -6,8 +6,6 @@ import { aiImage, parseAiSource, DEFAULT_AI_SOURCE } from "./_utils/ai.js"; import * as LIMITS from "../assets/rate-limit.js"; const TTL_SECONDS = 90 * 24 * 60 * 60; -// Failed searches, and the AI images served in their place, are kept for a day so a later search can still succeed -const MISS_TTL_SECONDS = 24 * 60 * 60; const MAX_INDEX = 20; // How long a search may hold its query's lock; longer than the 20s image deadline plus the search itself const LOCK_SECONDS = 45; @@ -60,7 +58,7 @@ export async function onRequest(context) { const obj = await env.R2_IMAGES.get(r2Key); if (obj) { const nowSec = Math.floor(Date.now() / 1000); - const remainingSec = Math.max(0, (cached.t + (cached.ttl || TTL_SECONDS)) - nowSec); + const remainingSec = Math.max(0, (cached.t + TTL_SECONDS) - nowSec); return new Response(obj.body, { headers: imageHeaders(cached.ct, remainingSec * 1000) }); } } @@ -162,7 +160,7 @@ COMMIT TRANSACTION;`; if (!await db(`RETURN lock:\`${keyHash}\`.exp > time::now();`).then(r => r.json()).then(d => d[0].result).catch(() => true)) break; } const obj = await env.R2_IMAGES.get(r2Key); - return obj ? new Response(obj.body, { headers: imageHeaders(obj.httpMetadata.contentType, (+obj.customMetadata?.ttl || TTL_SECONDS) * 1000) }) : asset("bad.webp"); + return obj ? new Response(obj.body, { headers: imageHeaders(obj.httpMetadata.contentType, TTL_SECONDS * 1000) }) : asset("bad.webp"); } if (count > limit) { @@ -175,20 +173,20 @@ COMMIT TRANSACTION;`; // Called once the result (image or error) is stored, so waiters serve it right away instead of at lock expiry const release = () => locked && context.waitUntil(db(`DELETE lock:\`${keyHash}\`;`).catch(() => {})); - // ttl is stored with the image (KV and R2) so cache hits and deduped waiters send a matching max-age - const store = async ({ buffer, contentType }, ttl = TTL_SECONDS) => { - await env.R2_IMAGES.put(r2Key, buffer, { httpMetadata: { contentType }, customMetadata: { ttl: `${ttl}` } }); - await env.DIRECT_IMG_CACHE.put(cacheKey, JSON.stringify({ t: Math.floor(Date.now() / 1000), ct: contentType, ...(ttl !== TTL_SECONDS && { ttl }) }), { expirationTtl: ttl }); + const store = async ({ buffer, contentType }) => { + await env.R2_IMAGES.put(r2Key, buffer, { httpMetadata: { contentType } }); + await env.DIRECT_IMG_CACHE.put(cacheKey, JSON.stringify({ t: Math.floor(Date.now() / 1000), ct: contentType }), { expirationTtl: TTL_SECONDS }); release(); - return new Response(buffer, { headers: imageHeaders(contentType, ttl * 1000) }); + return new Response(buffer, { headers: imageHeaders(contentType, TTL_SECONDS * 1000) }); }; - // Nothing found: serve an AI image of the query instead of bad.webp. bad.webp is only left for when that fails too. + // Nothing found: serve an AI image of the query instead, cached like any other image. bad.webp (remembered for 24h) + // is only left for when that fails too. const fail = async (t, m, tag, p) => { const fallback = await aiImage(DEFAULT_AI_SOURCE, query, i, env); context.waitUntil(notify(env, { title: fallback.success ? `${t}, Served AI Image` : t, message: fallback.success ? m : `${m}\nAI fallback failed: ${fallback.reason}`, tags: tag, priority: p })); - if (fallback.success) return await store(fallback, MISS_TTL_SECONDS); - await env.DIRECT_IMG_CACHE.put(cacheKey, JSON.stringify({ t: Math.floor(Date.now() / 1000), err: true }), { expirationTtl: MISS_TTL_SECONDS }); + if (fallback.success) return await store(fallback); + await env.DIRECT_IMG_CACHE.put(cacheKey, JSON.stringify({ t: Math.floor(Date.now() / 1000), err: true }), { expirationTtl: 86400 }); release(); return asset("bad.webp"); }; diff --git a/index.html b/index.html index b62188b..ae2baed 100644 --- a/index.html +++ b/index.html @@ -128,7 +128,7 @@ Use images to complement your responses, powered by Brave.

Caching

-

Images are cached for 90 days. Subsequent requests for the same query are served instantly. Failed searches are remembered for 24 hours.

+

Images are cached for 90 days. Subsequent requests for the same query are served instantly. Searches that find nothing serve an AI image instead, cached the same way.