Feat: AI images on ai.direct-img.link, and as the fallback when a search finds nothing

ai.direct-img.link serves images from the AuroraIMG-6M API on our Dokploy
server (github.com/direct-img/AuroraIMG-6M) instead of searching. It takes
?src= (auroraimg-6m, case-insensitive, the default and only source for now)
and ?i=, where each i gets its own seed derived from the query, so it's a
different image of the same prompt. It has its own daily limit
(AI_DAILY_LIMIT) and AI: cache keys / ai/ R2 prefix. Failed generations
aren't cached, since they're likely temporary.

On direct-img.link and free.direct-img.link, a search that finds nothing (or
fewer than i working images) now serves an AI image of the query instead of
bad.webp. It's kept for 24h like failures were, so a later search can still
find a real image; bad.webp is only served if the AI image fails too. Its ttl
is stored in KV and R2 metadata so cache hits and deduped waiters send a
matching max-age.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 02:56:39 +00:00
co-authored by Claude Opus 5.5
parent 07564bade2
commit 6fb64e9d7b
5 changed files with 138 additions and 37 deletions
+34 -8
View File
@@ -81,10 +81,11 @@ Literal slashes (`/`) and dots (`.`) in the URL path are **rejected** to prevent
### Things to know
- **Query parameters (`?...`)** other than `i` (and `src` on [free.direct-img.link](#free-images)) serve `bad.webp` — `/orange+cat?size=large` ❌
- **Query parameters (`?...`)** other than `i` (and `src` on [free.](#free-images) and [ai.direct-img.link](#ai-images)) serve `bad.webp` — `/orange+cat?size=large` ❌
- **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
## Free Images
@@ -112,6 +113,26 @@ Need images you can publish? `free.direct-img.link` only serves images marked **
Licenses come from each source's metadata and can be wrong. Check the original before publishing anything important.
## AI Images
`ai.direct-img.link` generates an image of anything instead of searching, so it never comes up empty. Same URL format, plus an optional `src`:
```markdown
![frog wizard](https://ai.direct-img.link/frog+wizard)
![frog wizard](https://ai.direct-img.link/frog+wizard?i=2)
![frog wizard](https://ai.direct-img.link/frog+wizard?src=auroraimg-6m&i=2)
```
| Param | Values | Default |
|---|---|---|
| `src` | `auroraimg-6m` (case-insensitive) | `auroraimg-6m` |
| `i` | `1`–`20` — each is a different image of the same query | `1` |
- The same query, `src` and `i` always give the same image
- Invalid values serve `bad.webp`
- Images come from [AuroraIMG-6M](https://github.com/direct-img/AuroraIMG-6M), a tiny 6.8M-parameter model running on our own server. They're 512px, soft and painterly: the right colors, setting and mood, but often not the exact object, and faces come out distorted
- The same model makes the fallback images for searches that find nothing
## For AI System Prompts
Add this to your system instructions:
@@ -127,16 +148,16 @@ Use images to complement your responses, powered by Brave.
Limits change over time, so they aren't listed here. **Check the Rate Limits section on [direct-img.link](https://direct-img.link) for the current limits.**
- **New searches (cache misses)** are limited per IP per day, then `limit.webp` is served. `direct-img.link` and `free.direct-img.link` have separate counters, both reset at midnight UTC
- **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)
- Failed searches count toward the limit and are remembered for 24h (`bad.webp`)
- Searches that find nothing count toward the limit. The AI image served instead (or `bad.webp`, if that fails too) is kept for 24h
- 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**
- Images are cached for **90 days**, except AI images served because a search found nothing, which are kept for **24 hours**
- After expiry, the next request triggers a fresh search
- Images are stored in their original format as fetched from source
- 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
## Support
@@ -207,6 +228,7 @@ Create in your Cloudflare dashboard:
| `NTFY_URL` | ntfy.sh topic URL for alerts | Optional |
| `GOATCOUNTER_URL` | GoatCounter site URL for image hit analytics (e.g. `https://direct-img.goatcounter.com`) | Optional |
| `GOATCOUNTER_TOKEN` | GoatCounter API token with "Record pageviews" permission | Optional |
| `AURORA_API_KEY` | Key for the [AuroraIMG-6M API](https://github.com/direct-img/AuroraIMG-6M) (AI images and the nothing-found fallback). Without it, `ai.` serves `bad.webp` and searches that find nothing serve `bad.webp` | Optional |
### 6. WAF Rules
@@ -218,9 +240,9 @@ Create in your Cloudflare dashboard:
Fork this repo, connect to Cloudflare Pages, deploy.
### 8. Free Images Domain
### 8. Free and AI Images Domains
**Pages project → Custom domains → Set up a domain:** add `free.<your-domain>`. Any hostname starting with `free.` serves free images; everything else serves regular search.
**Pages project → Custom domains → Set up a domain:** add `free.<your-domain>` and `ai.<your-domain>`. Any hostname starting with `free.` serves free images, and any starting with `ai.` serves AI images; everything else serves regular search.
---
@@ -228,7 +250,7 @@ Fork this repo, connect to Cloudflare Pages, deploy.
### R2: `direct-img-store`
**Key:** `<sha256-of-KV-key>` (see below) — for a plain query with no `i`, that's the SHA-256 of the normalized query. Free images use `free/<sha256-of-KV-key>`. Derived from the request, no lookup needed. Stored with original content type from source.
**Key:** `<sha256-of-KV-key>` (see below) — for a plain query with no `i`, that's the SHA-256 of the normalized query. Free images use `free/<sha256-of-KV-key>`, AI images `ai/<sha256-of-KV-key>`. Derived from the request, no lookup needed. Stored with original content type from source.
Add an object lifecycle rule in **R2 → direct-img-store → Settings** that applies to all prefixes and deletes uploaded objects after **90 days**. This removes expired objects even when their query is never requested again.
@@ -242,8 +264,11 @@ Other keys share the namespace. They use uppercase prefixes, which can never col
|---|---|---|
| `WEB:<i>:<query>` — main site with `i` > 1 (e.g. `WEB:2:orange cat`) | Same as above | 90 days |
| `FREE:<src or auto>:<i>:<query>` (e.g. `FREE:wikimedia:2:orange cat`) | Same as above | 90 days |
| `AI:<src>:<i>:<query>` (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.
### Database: `SurrealDB` (Rate Limiting + Search Locks)
Using atomic database transactions over HTTP to track per-IP/per-day search frequencies securely and rapidly.
@@ -265,6 +290,7 @@ After deploying, visit `https://<your-domain>/_setup` once. It creates the `dire
- **Cloudflare WAF** — layer 7 mitigation
- **Brave Image Search API** — image sourcing
- **Openverse API + Wikimedia Commons API** — free image sourcing
- **[AuroraIMG-6M](https://github.com/direct-img/AuroraIMG-6M) on Dokploy** — AI images and the nothing-found fallback
---
+1
View File
@@ -3,3 +3,4 @@
// The burst limit (~10 req/10s) is a Cloudflare WAF rule, not set here.
export const DAILY_LIMIT = 25;
export const FREE_DAILY_LIMIT = 100;
export const AI_DAILY_LIMIT = 100;
+49 -28
View File
@@ -2,9 +2,12 @@ import { braveImageSearch } from "./_utils/brave.js";
import { bingImageSearchFallback } from "./_utils/bing.js";
import { freeImageUrls, parseFreeSource, FREE_UA } from "./_utils/free.js";
import { isFlickrPlaceholder } from "./_utils/flickr.js";
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;
@@ -32,21 +35,24 @@ export async function onRequest(context) {
if (!query) return jsonResponse(400, { error: "Empty query" });
if (query.length > 200) return jsonResponse(400, { error: "Query too long (max 200 characters)" });
// ?i= picks which result to serve; free.direct-img.link also takes ?src= and serves only unrestricted images.
// Any other param (e.g. scanner probes like ?path=../../.env) is rejected before it can cost a search.
const free = url.hostname.startsWith("free.");
// ?i= picks which result to serve. free.direct-img.link serves only unrestricted images and ai.direct-img.link only
// AI-generated ones; both also take ?src=. Any other param (e.g. scanner probes like ?path=../../.env) is rejected
// before it can cost a search.
const site = ["free", "ai"].find(s => url.hostname.startsWith(`${s}.`)) || "";
const free = site === "free", ai = site === "ai";
const label = { free: "Free ", ai: "AI " }[site] || "";
const i = parseIndex(url.searchParams);
const src = free ? parseFreeSource(url.searchParams) : null;
const extra = [...url.searchParams.keys()].some(k => k !== "i" && !(free && k === "src"));
if (!i || (free && !src) || extra) return asset("bad.webp");
const src = free ? parseFreeSource(url.searchParams) : ai ? parseAiSource(url.searchParams) : null;
const extra = [...url.searchParams.keys()].some(k => k !== "i" && !(site && k === "src"));
if (!i || (site && !src) || extra) return asset("bad.webp");
context.waitUntil(countHit(env, request, query, free));
context.waitUntil(countHit(env, request, query, site));
// Main-site i=1 keeps the bare query so existing cache entries stay valid.
// Other keys use uppercase prefixes, which can't collide with (always lowercased) queries.
const cacheKey = free ? `FREE:${src}:${i}:${query}` : i === 1 ? query : `WEB:${i}:${query}`;
const cacheKey = site ? `${site.toUpperCase()}:${src}:${i}:${query}` : i === 1 ? query : `WEB:${i}:${query}`;
const keyHash = await sha256(cacheKey);
const r2Key = `${free ? "free/" : ""}${keyHash}`;
const r2Key = `${site ? `${site}/` : ""}${keyHash}`;
const cached = await env.DIRECT_IMG_CACHE.get(cacheKey, "json");
if (cached) {
@@ -54,16 +60,16 @@ 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 + TTL_SECONDS) - nowSec);
const remainingSec = Math.max(0, (cached.t + (cached.ttl || TTL_SECONDS)) - nowSec);
return new Response(obj.body, { headers: imageHeaders(cached.ct, remainingSec * 1000) });
}
}
const ip = request.headers.get("cf-connecting-ip") || "unknown";
const today = new Date().toISOString().slice(0, 10);
// free.* gets its own counter (prefixed so main-site ids stay unchanged) since it never spends Brave credits
const rateId = await sha256(`${free ? "free:" : ""}${ip}:${today}`);
const limit = free ? LIMITS.FREE_DAILY_LIMIT : LIMITS.DAILY_LIMIT;
// 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;
const db = env.SURREAL_URL && env.SURREAL_USER && env.SURREAL_PASS && (body => fetch(`${env.SURREAL_URL}/sql`, {
@@ -150,28 +156,39 @@ COMMIT TRANSACTION;`;
// Another request is already searching this query: wait for it to release the lock (or the lock to expire), then
// serve what it stored. R2 is strongly consistent, unlike KV. Nothing stored means its search failed.
if (wait) {
context.waitUntil(notify(env, { title: free ? "Free Search Deduped" : "Search Deduped", message: `Query: ${query} (waited for in-flight search, not counted, ${ip})\n${url.origin}/${path}${url.search}`, tags: "link", priority: 1 }));
context.waitUntil(notify(env, { title: `${label}Search Deduped`, message: `Query: ${query} (waited for in-flight search, not counted, ${ip})\n${url.origin}/${path}${url.search}`, tags: "link", priority: 1 }));
for (const end = Date.now() + LOCK_SECONDS * 1000; Date.now() < end;) {
await new Promise(r => setTimeout(r, 500));
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, TTL_SECONDS * 1000) }) : asset("bad.webp");
return obj ? new Response(obj.body, { headers: imageHeaders(obj.httpMetadata.contentType, (+obj.customMetadata?.ttl || TTL_SECONDS) * 1000) }) : asset("bad.webp");
}
if (count > limit) {
context.waitUntil(notify(env, { title: free ? "Free Rate Limit Hit" : "Rate Limit Hit", message: `IP ${ip} hit limit for: ${query}`, tags: "warning,no_entry", priority: 2 }));
context.waitUntil(notify(env, { title: `${label}Rate Limit Hit`, message: `IP ${ip} hit limit for: ${query}`, tags: "warning,no_entry", priority: 2 }));
return asset("limit.webp");
}
context.waitUntil(notify(env, { title: free ? "New Free Search" : "New Search", message: `Query: ${query} (Search #${count} for ${ip})\n${url.origin}/${path}${url.search}`, tags: "mag", priority: 2 }));
context.waitUntil(notify(env, { title: `New ${label}Search`, message: `Query: ${query} (Search #${count} for ${ip})\n${url.origin}/${path}${url.search}`, 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(() => {}));
// 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 });
release();
return new Response(buffer, { headers: imageHeaders(contentType, ttl * 1000) });
};
// Nothing found: serve an AI image of the query instead of bad.webp. bad.webp is only left for when that fails too.
const fail = async (t, m, tag, p) => {
context.waitUntil(notify(env, { title: t, message: m, tags: tag, priority: p }));
await env.DIRECT_IMG_CACHE.put(cacheKey, JSON.stringify({ t: Math.floor(Date.now() / 1000), err: true }), { expirationTtl: 86400 });
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 });
release();
return asset("bad.webp");
};
@@ -180,6 +197,15 @@ COMMIT TRANSACTION;`;
// reloading a slow page). Otherwise the runtime cancels it mid-search with the lock still held, and the reload waits
// out LOCK_SECONDS only to find nothing stored and get bad.webp.
const job = (async () => {
if (ai) {
const res = await aiImage(src, query, i, env);
if (res.success) return await store(res);
// Not cached, since it's likely temporary (busy or down), so the next request tries again
context.waitUntil(notify(env, { title: "AI Image Failed", message: `${src} failed for: ${cacheKey}\nReason: ${res.reason}`, tags: "boom,x", priority: 4 }));
release();
return asset("bad.webp");
}
const imageUrls = free ? freeImageUrls(query, src, env) : webImageUrls(context, query);
const GLOBAL_DEADLINE = Date.now() + 20000;
let imgResult = null, tried = 0, found = 0, more = true, timedOut = false;
@@ -224,13 +250,7 @@ COMMIT TRANSACTION;`;
return await fail("Fetch Error (502)", `Found ${found} of ${i} working images for: ${cacheKey}\nReasons: ${reasonStr}`, "boom,x", 4);
}
const { buffer: imgBuffer, contentType: finalContentType } = imgResult;
await env.R2_IMAGES.put(r2Key, imgBuffer, { httpMetadata: { contentType: finalContentType } });
await env.DIRECT_IMG_CACHE.put(cacheKey, JSON.stringify({ t: Math.floor(Date.now() / 1000), ct: finalContentType }), { expirationTtl: TTL_SECONDS });
release();
return new Response(imgBuffer, { headers: imageHeaders(finalContentType, TTL_SECONDS * 1000) });
return await store(imgResult);
})();
context.waitUntil(job);
return job;
@@ -283,14 +303,15 @@ async function notify(env,{ title, message, tags, priority }) {
} catch {}
}
async function countHit(env, request, query, free) {
// site is "free", "ai" or "" (main site); each is counted under its own path prefix
async function countHit(env, request, query, site) {
if (!env.GOATCOUNTER_URL || !env.GOATCOUNTER_TOKEN) return;
const h = k => request.headers.get(k) || "";
try {
await fetch(`${env.GOATCOUNTER_URL}/api/v0/count`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${env.GOATCOUNTER_TOKEN}` },
body: JSON.stringify({ no_sessions: true, hits: [{ path: `${free ? "/free" : ""}/${query.replace(/ /g, "+")}`, title: query, ref: h("referer"), user_agent: h("user-agent"), ip: h("cf-connecting-ip") }] }),
body: JSON.stringify({ no_sessions: true, hits: [{ path: `${site ? `/${site}` : ""}/${query.replace(/ /g, "+")}`, title: query, ref: h("referer"), user_agent: h("user-agent"), ip: h("cf-connecting-ip") }] }),
signal: AbortSignal.timeout(5000)
});
} catch {}
+34
View File
@@ -0,0 +1,34 @@
// AI images for ai.direct-img.link, and the fallback when a search finds nothing
const SOURCES = { "auroraimg-6m": auroraImage };
export const DEFAULT_AI_SOURCE = "auroraimg-6m";
// ?src= on ai.*, case-insensitive. Returns null if invalid.
export function parseAiSource(searchParams) {
const src = searchParams.get("src")?.toLowerCase() || DEFAULT_AI_SOURCE;
return Object.hasOwn(SOURCES, src) ? src : null;
}
// Same result shape as fetchImage: { success, buffer, contentType } or { success: false, reason }
export async function aiImage(src, query, i, env) {
try {
return await SOURCES[src](query, await seedFor(query, i), env);
} catch (err) {
return { success: false, reason: err.name === "TimeoutError" ? "Timeout" : err.message };
}
}
// Deterministic per query and i, so each ?i= is a different image of the same prompt and repeats are identical
async function seedFor(query, i) {
return new DataView(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${query}\n${i}`))).getUint32(0);
}
// AuroraIMG-6M on our Dokploy server: https://github.com/direct-img/AuroraIMG-6M
async function auroraImage(query, seed, env) {
if (!env.AURORA_API_KEY) return { success: false, reason: "AURORA_API_KEY not set" };
const res = await fetch(`https://aurora.direct-img.link/generate?${new URLSearchParams({ prompt: query, seed })}`, {
headers: { Authorization: `Bearer ${env.AURORA_API_KEY}` },
signal: AbortSignal.timeout(15000),
});
if (!res.ok) return { success: false, reason: `HTTP ${res.status}` };
return { success: true, buffer: await res.arrayBuffer(), contentType: res.headers.get("content-type") };
}
+20 -1
View File
@@ -91,6 +91,24 @@ Use images to complement your responses, powered by Brave.</code></pre>
<p style="font-size:0.85rem;color:var(--text-muted);">* Both params are optional and work in any order. Licenses come from each source and can be wrong, so check the original before publishing. Images from <a href="https://openverse.org">Openverse</a> (made using the Openverse API, not endorsed or certified by Openverse) and <a href="https://commons.wikimedia.org">Wikimedia Commons</a>.</p>
</section>
<section>
<h2>AI Images</h2>
<p><code>ai.direct-img.link</code> generates an image of anything instead of searching, so it never comes up empty. Expect soft, painterly, abstract results: the right colors and mood, not always the exact thing.</p>
<pre><code>![frog wizard](https://ai.direct-img.link/frog+wizard)
![frog wizard](https://ai.direct-img.link/frog+wizard?i=2)
![frog wizard](https://ai.direct-img.link/frog+wizard?src=auroraimg-6m&amp;i=2)</code></pre>
<table>
<thead>
<tr><th>Param</th><th>Values</th><th>Default</th></tr>
</thead>
<tbody>
<tr><td><code>src</code></td><td><code>auroraimg-6m</code></td><td><code>auroraimg-6m</code></td></tr>
<tr><td><code>i</code></td><td><code>1</code>–<code>20</code> (a different image each)</td><td><code>1</code></td></tr>
</tbody>
</table>
<p style="font-size:0.85rem;color:var(--text-muted);">* The same query always gives the same image. When a search on direct-img.link or free.direct-img.link finds nothing, an AI image is served instead. Made with <a href="https://github.com/direct-img/AuroraIMG-6M">AuroraIMG-6M</a>.</p>
</section>
<section>
<h2>Rate Limits</h2>
<table>
@@ -101,9 +119,10 @@ Use images to complement your responses, powered by Brave.</code></pre>
<tr><td>Burst</td><td>~10 req/10s per IP</td><td>HTTP 429 (~10s)</td></tr>
<tr><td>New Searches</td><td><span data-limit="DAILY_LIMIT">35</span> per day per IP</td><td>Limit image served</td></tr>
<tr><td>New Free Searches</td><td><span data-limit="FREE_DAILY_LIMIT">100</span> per day per IP</td><td>Limit image served</td></tr>
<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 has its 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. The burst limit applies to all image requests and is enforced approximately. The daily limit resets at 00:00 UTC.</p>
</section>
<section>