Feat: Cache search results for an hour so other ?i= are free and fast

A search's result URLs are kept in SurrealDB (results table) for 1 hour,
fetched in the same transaction as the rate limit. Other ?i= of the query
then skip the Brave/Bing search and don't count toward the daily limit.
The record also tracks which results worked (pos) and how far it got
(next), so an already-known ?i= downloads only its own image and a new one
carries on from where the last left off. free.* searches Openverse and
Wikimedia together so its whole list can be cached. Empty results aren't
cached, since they can mean Brave and Bing failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 14:07:51 +00:00
co-authored by Claude Opus 5.5
parent bd8ef51591
commit 223150650c
4 changed files with 66 additions and 43 deletions
+12 -8
View File
@@ -43,9 +43,9 @@ https://direct-img.link/orange+cat?i=2
- `i` is `1`–`20`, default `1`. Invalid values serve `bad.webp`
- Leading zeros are ignored (`?i=02` = `?i=2`)
- Only images that download count, so broken links and deleted photos are skipped and every `i` is a different image. `?i=2` is the 2nd working image
- Higher `i` values take a little longer, since the working images before it are downloaded to count them
- If there aren't `i` working images, `bad.webp` is served
- Each `i` is searched and cached separately, and counts as its own search
- Higher `i` values take a little longer the first time, since the working images before it are downloaded to count them
- If there aren't `i` working images, an [AI image](#ai-images) is served instead
- Each `i` is cached separately. A search's results are kept for **1 hour**, so other `i` values of the same query within that hour don't search again and **don't count toward the limit**. One that was already counted downloads only its own image
## Query Normalization
@@ -99,12 +99,12 @@ Need images you can publish? `free.direct-img.link` only serves images marked **
| Param | Values | Default |
|---|---|---|
| `src` | `openverse`, `wikimedia` (case-insensitive) | Openverse, then Wikimedia once Openverse's results run out |
| `src` | `openverse`, `wikimedia` (case-insensitive) | Openverse's results, then Wikimedia's |
| `i` | `1`–`20` — works the same as [on the main site](#picking-a-result) | `1` |
- Both are optional and can be used alone or together, in any order (`?i=2&src=wikimedia` = `?src=wikimedia&i=2`)
- Invalid values serve `bad.webp`
- Each `src` + `i` + query combination is searched and cached separately, and counts as its own search
- Each `src` + `i` + query combination is cached separately. Like the main site, other `i` values within an hour of a search reuse its results and don't count as searches
**Sources:**
@@ -150,6 +150,7 @@ 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)
- Other `?i=` values of a query searched in the last hour reuse its results, so they don't count (except on `ai.`, where each `i` is a new image)
- 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
@@ -157,6 +158,7 @@ Limits change over time, so they aren't listed here. **Check the Rate Limits sec
- Images are cached for **90 days**, including AI images
- After expiry, the next request triggers a fresh search
- A search's list of results is kept for **1 hour** (in SurrealDB), so the query's other `?i=` values don't search again
- 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
@@ -269,15 +271,17 @@ Other keys share the namespace. They use uppercase prefixes, which can never col
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)
### Database: `SurrealDB` (Rate Limiting, Search Locks + Result Lists)
Using atomic database transactions over HTTP to track per-IP/per-day search frequencies securely and rapidly.
The same transaction also claims a per-query lock (`lock:<sha256-of-KV-key>`, expires after 45s), so when several requests miss the cache for the same query at once (on any Cloudflare server), only one searches. The others wait for it to finish, then serve the image it stored in R2, and aren't counted toward the daily limit. The lock is deleted as soon as the result is stored.
Each search also saves its result URLs as `results:<sha256 of site, src and query>` for 1 hour, with the indexes of the images that worked so far (`pos`) and the first URL not yet tried (`next`). The transaction returns that record when it exists, and then doesn't count the request. A `?i=` already in `pos` downloads only its own image, and a new one carries on from `next`, extending the record. Expired records are deleted by the same occasional cleanup as old rate counters.
**Note:** This implementation is tested and verified for **SurrealDB v2.6.5**.
After deploying, visit `https://<your-domain>/_setup` once. It creates the `direct_img` namespace, `rate_limit` database, `rate` table, an `updated_at` index (used by the cleanup query) and the `lock` table. It's idempotent, so visiting again is harmless, and it returns `{"ok":true}` on success.
After deploying, visit `https://<your-domain>/_setup` once. It creates the `direct_img` namespace, `rate_limit` database, `rate` table, an `updated_at` index (used by the cleanup query), the `lock` table and the `results` table. It's idempotent, so visiting again is harmless, and it returns `{"ok":true}` on success.
---
@@ -286,7 +290,7 @@ After deploying, visit `https://<your-domain>/_setup` once. It creates the `dire
- **Cloudflare Pages** — hosting + edge functions
- **Cloudflare R2** — image storage
- **Cloudflare KV** — generic lookups
- **SurrealDB (v2.6.5)** — atomic rate limiting + search locks
- **SurrealDB (v2.6.5)** — atomic rate limiting, search locks + cached result lists
- **Cloudflare WAF** — layer 7 mitigation
- **Brave Image Search API** — image sourcing
- **Openverse API + Wikimedia Commons API** — free image sourcing
+48 -28
View File
@@ -9,6 +9,8 @@ const TTL_SECONDS = 90 * 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;
// How long a search's results are kept for the query's other ?i=, which then don't search again or count toward limits
const LIST_SECONDS = 60 * 60;
// Image candidates downloaded at once, so one slow or dead host doesn't stall the rest for its full 5s timeout
const PARALLEL = 4;
@@ -53,6 +55,8 @@ export async function onRequest(context) {
const cacheKey = site ? `${site.toUpperCase()}:${src}:${i}:${query}` : i === 1 ? query : `WEB:${i}:${query}`;
const keyHash = await sha256(cacheKey);
const r2Key = `${site ? `${site}/` : ""}${keyHash}`;
// A search's results are kept for LIST_SECONDS, so other ?i= of the same query skip the search. ai.* has no list.
const listHash = !ai && await sha256(["LIST", site, src, query].join(":"));
const cached = await env.DIRECT_IMG_CACHE.get(cacheKey, "json");
if (cached) {
@@ -70,7 +74,7 @@ export async function onRequest(context) {
// free.* and ai.* get their own counters (prefixed so main-site ids stay unchanged) since they never spend Brave credits
const rateId = await sha256(`${site ? `${site}:` : ""}${ip}:${today}`);
const limit = free ? LIMITS.FREE_DAILY_LIMIT : ai ? LIMITS.AI_DAILY_LIMIT : LIMITS.DAILY_LIMIT;
let count = 1, wait = false, locked = false;
let count = 1, wait = false, locked = false, list = null;
const db = env.SURREAL_URL && env.SURREAL_USER && env.SURREAL_PASS && (body => fetch(`${env.SURREAL_URL}/sql`, {
method: "POST",
@@ -87,11 +91,13 @@ export async function onRequest(context) {
// Counts the search and claims this query's lock in the same round trip, so concurrent misses (on any isolate or
// colo) search once. A request that finds the lock held writes nothing and waits for the holder's result instead.
// It's one transaction because a write conflict (e.g. same-IP misses at once) rolls it all back, so retrying is safe.
// It also fetches this query's cached results, if any, in which case the search is free and not counted.
const sql = `BEGIN TRANSACTION;
LET $l = ${listHash ? `(SELECT urls, pos, next FROM ONLY results:\`${listHash}\` WHERE exp > time::now())` : "NONE"};
LET $w = lock:\`${keyHash}\`.exp > time::now();
LET $c = IF !$w THEN (UPSERT ONLY rate:\`${rateId}\` SET count = IF count IS NONE THEN 1 ELSE count + 1 END, updated_at = time::now() RETURN VALUE count) END;
IF !$w AND $c <= ${limit} THEN (UPSERT lock:\`${keyHash}\` SET exp = time::now() + ${LOCK_SECONDS}s) END;
RETURN { count: $c, wait: $w };
LET $c = IF !$w AND !$l THEN (UPSERT ONLY rate:\`${rateId}\` SET count = IF count IS NONE THEN 1 ELSE count + 1 END, updated_at = time::now() RETURN VALUE count) END;
IF !$w AND ($l OR $c <= ${limit}) THEN (UPSERT lock:\`${keyHash}\` SET exp = time::now() + ${LOCK_SECONDS}s) END;
RETURN { count: $c, wait: $w, list: $l };
COMMIT TRANSACTION;`;
try {
@@ -111,8 +117,8 @@ COMMIT TRANSACTION;`;
try {
const data = JSON.parse(rawText);
if (data.every(s => s.status === "OK") && data.at(-1)?.result) {
({ count, wait } = data.at(-1).result);
locked = !wait && count <= limit;
({ count, wait, list } = data.at(-1).result);
locked = !wait && (!!list || count <= limit);
break;
}
if (attempt < 3 && /conflict/i.test(rawText)) continue;
@@ -134,7 +140,7 @@ COMMIT TRANSACTION;`;
}
if (Math.random() < 0.05) {
context.waitUntil(db(`DELETE rate WHERE updated_at < time::now() - 25h; DELETE lock WHERE exp < time::now() - 1h;`).catch(() => {}));
context.waitUntil(db(`DELETE rate WHERE updated_at < time::now() - 25h; DELETE lock WHERE exp < time::now() - 1h; DELETE results WHERE exp < time::now();`).catch(() => {}));
}
} catch (err) {
note({
@@ -170,7 +176,9 @@ COMMIT TRANSACTION;`;
return asset("limit.webp");
}
note({ title: `New ${label}Search`, message: `Query: ${query} (Search #${count} for ${ip})`, tags: "mag", priority: 2 });
note(list
? { title: `New ${label}Search`, message: `Query: ${query} (from cached results, not counted, ${ip})`, tags: "mag,zap", priority: 1 }
: { title: `New ${label}Search`, message: `Query: ${query} (Search #${count} for ${ip})`, tags: "mag", priority: 2 });
// 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(() => {}));
@@ -206,35 +214,39 @@ COMMIT TRANSACTION;`;
return asset("bad.webp");
}
const imageUrls = free ? freeImageUrls(query, src, env) : webImageUrls(query, env, note);
// The query's cached results, or a fresh search. pos holds the indexes of the working images found so far and next
// the first URL not tried yet, so a known ?i= downloads only its own image and a new one carries on from next.
let { urls, pos, next } = list || { urls: await (free ? freeImageUrls(query, src, env) : webImageUrls(query, env, note)), pos: [], next: 0 };
// Starting at a known image skips the ones before it, so it can't add to pos
const known = pos.length >= i;
let at = known ? pos[i - 1] : next, found = known ? i - 1 : pos.length, imgResult = null, timedOut = false;
const GLOBAL_DEADLINE = Date.now() + 20000;
let imgResult = null, tried = 0, found = 0, more = true, timedOut = false;
const failReasons = [], queue = [], abort = new AbortController(), it = imageUrls[Symbol.asyncIterator]();
const failReasons = [], queue = [], abort = new AbortController();
// Keeps up to PARALLEL downloads in flight, queued in result order
const fill = async () => {
while (more && queue.length < PARALLEL) {
const { value: imgUrl, done } = await it.next();
if (done) return void (more = false);
tried++;
// Keeps up to PARALLEL downloads in flight, queued in result order. A known ?i= expects its image to work, so it
// only downloads others once something fails.
const fill = () => {
while (!timedOut && at < urls.length && queue.length < (known && !failReasons.length ? 1 : PARALLEL)) {
const remaining = GLOBAL_DEADLINE - Date.now();
if (remaining <= 500) return void (more = false, timedOut = true);
queue.push({ imgUrl, res: fetchImage(imgUrl, Math.min(remaining, 5000), free ? FREE_UA : undefined, abort.signal) });
if (remaining <= 500) timedOut = true;
else queue.push({ n: at, res: fetchImage(urls[at++], Math.min(remaining, 5000), free ? FREE_UA : undefined, abort.signal) });
}
};
// Results are checked in order, so i still counts only images that download, and dead links and placeholders never
// take up an index
while ((await fill(), queue.length)) {
const { imgUrl, res: pending } = queue.shift(), res = await pending;
while ((fill(), queue.length)) {
const { n, res: pending } = queue.shift(), res = await pending;
if (!known) next = n + 1;
if (res.success) {
if (!known) pos.push(n);
if (++found === i) {
imgResult = res;
break;
}
} else {
try {
const host = new URL(imgUrl).hostname.replace(/^www\./, '');
const host = new URL(urls[n]).hostname.replace(/^www\./, '');
failReasons.push(`${host}=${res.reason}`);
} catch {
failReasons.push(`invalid_url=${res.reason}`);
@@ -244,7 +256,14 @@ COMMIT TRANSACTION;`;
abort.abort();
if (timedOut) failReasons.push("Global timeout reached");
if (!tried) return await fail("Search Failed", `${free ? "No free results" : "Both Brave and Bing returned no results"} for: ${cacheKey}`, "question", 3);
// Saved even if no image worked, so other ?i= of the query don't search again, but not if the search found nothing,
// since that can also mean Brave and Bing failed. A cached list is only updated if this got further than its saver.
if (db && urls.length && next > (list ? list.next : -1)) context.waitUntil(db(list
? `UPDATE results:\`${listHash}\` SET pos = ${JSON.stringify(pos)}, next = ${next} WHERE next < ${next};`
: `UPSERT results:\`${listHash}\` SET urls = ${JSON.stringify(urls)}, pos = ${JSON.stringify(pos)}, next = ${next}, exp = time::now() + ${LIST_SECONDS}s;`
).catch(() => {}));
if (!urls.length) return await fail("Search Failed", `${free ? "No free results" : "Both Brave and Bing returned no results"} for: ${cacheKey}`, "question", 3);
if (!imgResult) {
const reasonStr = failReasons.slice(0, 6).join(", ") + (failReasons.length > 6 ? ", ..." : "");
return await fail("Fetch Error (502)", `Found ${found} of ${i} working images for: ${cacheKey}\nReasons: ${reasonStr}`, "boom,x", 4);
@@ -262,12 +281,12 @@ function parseIndex(searchParams) {
return /^\d+$/.test(i) && +i >= 1 && +i <= MAX_INDEX ? +i : null;
}
// Yields Brave results, or Bing's if Brave has none
async function* webImageUrls(query, env, note) {
// Brave results, or Bing's if Brave has none
async function webImageUrls(query, env, note) {
const urls = await braveImageSearch(query, env.BRAVE_API_KEY);
if (urls?.length) return yield* urls;
if (urls?.length) return urls;
note({ title: "Brave Search Empty", message: `No results for: ${query}. Trying Bing Fallback.`, tags: "warning,mag", priority: 3 });
yield* (await bingImageSearchFallback(query)) || [];
return (await bingImageSearchFallback(query)) || [];
}
async function setupSurreal(env) {
@@ -278,7 +297,8 @@ DEFINE DATABASE IF NOT EXISTS rate_limit;
USE DB rate_limit;
DEFINE TABLE IF NOT EXISTS rate SCHEMALESS;
DEFINE INDEX IF NOT EXISTS rate_updated_at ON rate FIELDS updated_at;
DEFINE TABLE IF NOT EXISTS lock SCHEMALESS;`;
DEFINE TABLE IF NOT EXISTS lock SCHEMALESS;
DEFINE TABLE IF NOT EXISTS results SCHEMALESS;`;
try {
const res = await fetch(`${env.SURREAL_URL}/sql`, {
method: "POST",
+4 -5
View File
@@ -13,9 +13,8 @@ export function parseFreeSource(searchParams) {
return src === "auto" || Object.hasOwn(SOURCES, src) ? src : null;
}
// Yields each source's image URLs in turn; a source is only searched once the previous one runs out
export async function* freeImageUrls(query, src, env) {
for (const name of src === "auto" ? Object.keys(SOURCES) : [src]) {
yield* (await SOURCES[name](query, env, FREE_UA)) || [];
}
// Every source's image URLs, one source after another. All are searched at once, since the whole list gets cached.
export async function freeImageUrls(query, src, env) {
const names = src === "auto" ? Object.keys(SOURCES) : [src];
return (await Promise.all(names.map(n => SOURCES[n](query, env, FREE_UA)))).flatMap(urls => urls || []);
}
+2 -2
View File
@@ -129,13 +129,13 @@ Use images to complement your responses, powered by Brave.</code></pre>
<tr><td>New AI Images</td><td><span data-limit="AI_DAILY_LIMIT">100</span> per day per IP</td><td>Limit image served</td></tr>
</tbody>
</table>
<p style="font-size:0.85rem;color:var(--text-muted);">* free.direct-img.link and ai.direct-img.link each have their own daily limit, separate from the main site's. Cached images don't count toward it and keep loading after you hit it. The burst limit applies to all image requests and is enforced approximately. The daily limit resets at 00:00 UTC.</p>
<p style="font-size:0.85rem;color:var(--text-muted);">* free.direct-img.link and ai.direct-img.link each have their own daily limit, separate from the main site's. Cached images don't count toward it and keep loading after you hit it. Neither do other <code>?i=</code> values of a query searched in the last hour. The burst limit applies to all image requests and is enforced approximately. The daily limit resets at 00:00 UTC.</p>
</section>
<section>
<div class="card">
<h3>Caching</h3>
<p>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.</p>
<p>Images are cached for 90 days. Subsequent requests for the same query are served instantly. A search's results are kept for an hour, so trying other <code>?i=</code> values right after is free and fast. Searches that find nothing serve an AI image instead, cached the same way.</p>
</div>
</section>