Feat: Add free.direct-img.link and ?i= result picker (#1)

- free.direct-img.link serves only unrestricted (public domain/CC0) images
  from Openverse (license=cc0,pdm) and Wikimedia Commons
  (haslicense:unrestricted), with optional ?src=openverse|wikimedia
- ?i=1..20 picks the i-th working image on both sites; broken links are
  skipped so every index is a different image
- Openverse OAuth token is cached in KV and refreshed automatically
- Skip Flickr "photo unavailable" placeholders (served with HTTP 200)
- Free/indexed cache keys use uppercase prefixes; plain main-site keys
  are unchanged so existing cache entries stay valid
- Update README, homepage, privacy policy and terms

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 12:00:54 -07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 023f4d132c
commit 029b259f05
9 changed files with 247 additions and 36 deletions
+3 -3
View File
@@ -12,9 +12,9 @@ direct-img.link has no accounts and doesn't sell data. This page explains what i
## Search queries and images
- When a query isn't already cached, it's sent to Brave Search, and to Bing if Brave returns nothing. Searches and image downloads are made from our servers, so your IP address isn't shared with Brave, Bing, or the sites images come from.
- Each query and the image it returned are cached for up to 90 days. Failed searches are remembered for 24 hours.
- Cached images are shared: anyone requesting the same query gets the same image.
- When a query isn't already cached, it's sent to Brave Search, and to Bing if Brave returns nothing. On free.direct-img.link, it's sent to Openverse and/or Wikimedia Commons instead. Searches and image downloads are made from our servers, so your IP address isn't shared with these search providers or the sites images come from.
- Each query (with its `i` and `src` options) and the image it returned are cached for up to 90 days. Failed searches are remembered for 24 hours.
- Cached images are shared: anyone requesting the same query and options gets the same image.
## Operational alerts
+79 -8
View File
@@ -32,6 +32,21 @@ https://direct-img.link/new+york+city
| u.s. president | `/u.s.+president` |
| 90's fashion | `/90%27s+fashion` |
### Picking a Result
Add `?i=` to serve a later search result instead of the first, e.g. when the first image isn't the one you want:
```
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
## Query Normalization
All queries are normalized before caching and searching:
@@ -66,11 +81,37 @@ Literal slashes (`/`) and dots (`.`) in the URL path are **rejected** to prevent
### Things to know
- **Query parameters (`?...`)** are ignored — `/orange+cat?size=large` → `orange cat`
- **Query parameters (`?...`)** other than `i` (and `src` on [free.direct-img.link](#free-images)) are ignored — `/orange+cat?size=large` → `orange cat`
- **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
## Free Images
Need images you can publish? `free.direct-img.link` only serves images marked **free of restrictions** (public domain or CC0), so no credit is needed. Same URL format, plus an optional `src`:
```markdown
![orange cat](https://free.direct-img.link/orange+cat)
![orange cat](https://free.direct-img.link/orange+cat?i=2)
![orange cat](https://free.direct-img.link/orange+cat?src=wikimedia&i=2)
```
| Param | Values | Default |
|---|---|---|
| `src` | `openverse`, `wikimedia` (case-insensitive) | Openverse, then Wikimedia once Openverse's results run out |
| `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
**Sources:**
- [Openverse](https://openverse.org) — searched with `license=cc0,pdm`, JPG/PNG/GIF/WebP only. Made using the Openverse API, but not endorsed or certified by Openverse.
- [Wikimedia Commons](https://commons.wikimedia.org) — searched with `filetype:bitmap haslicense:unrestricted` (Commons' own "no restrictions" filter). Served at up to 1920px wide.
Licenses come from each source's metadata and can be wrong. Check the original before publishing anything important.
## For AI System Prompts
Add this to your system instructions:
@@ -92,11 +133,12 @@ Use images to complement your responses, powered by Brave.
### New Searches (Cache Misses)
- **20 new searches per day per IP** (resets at midnight UTC), then `limit.webp` is served
- **20 new searches per day per IP** (resets at midnight UTC), then `limit.webp` is served — shared between `direct-img.link` and `free.direct-img.link`
- **Cache hits are unlimited** (within WAF limits above)
- Failed searches count toward the limit and are remembered for 24h (`bad.webp`)
- WAF counters are approximate (not shared perfectly across Cloudflare servers), so bursts may slip slightly past 10
- **Brave API quota:** $5 free monthly credits (1,000 queries), then $5/1k requests
- **Openverse & Wikimedia:** free, no quota costs
## Caching
@@ -126,7 +168,21 @@ Free community service. Donations help cover API and infrastructure costs.
5. Go to your [API dashboard](https://api.search.brave.com/app/#/subscriptions)
6. Copy your **API key** (starts with `BSA...`)
### 2. Cloudflare Resources
### 2. Openverse API Credentials (Optional)
Free images work without credentials, but anonymous Openverse requests are limited to 200/day. Register once to get higher limits:
```bash
curl -X POST -H "Content-Type: application/json" -d '{"name":"<unique app name>","description":"<what you use it for>","email":"<your email>"}' https://api.openverse.org/v1/auth_tokens/register/
```
1. Save the returned `client_id` and `client_secret` — they can't be retrieved later
2. Click the verification link Openverse emails you (until then, anonymous limits apply)
3. Add them as the `OPENVERSE_CLIENT_ID` and `OPENVERSE_CLIENT_SECRET` secrets below
The function exchanges them for an access token (valid ~12h) and caches it in KV, fetching a new one automatically when it expires.
### 3. Cloudflare Resources
Create in your Cloudflare dashboard:
@@ -135,7 +191,7 @@ Create in your Cloudflare dashboard:
| R2 Bucket | `direct-img-store` | Stores cached images |
| KV Namespace | `DIRECT_IMG_CACHE` | Cache existence + content type + timestamp |
### 3. Pages Bindings
### 4. Pages Bindings
**Settings → Functions → Bindings:**
@@ -144,13 +200,15 @@ Create in your Cloudflare dashboard:
| R2 Bucket | `R2_IMAGES` | `direct-img-store` |
| KV Namespace | `DIRECT_IMG_CACHE` | `DIRECT_IMG_CACHE` |
### 4. Secrets
### 5. Secrets
**Settings → Environment variables:**
| Variable | Description | Required |
|---|---|---|
| `BRAVE_API_KEY` | Brave Search API key | Yes |
| `OPENVERSE_CLIENT_ID` | Openverse app client ID (free images) | Optional |
| `OPENVERSE_CLIENT_SECRET` | Openverse app client secret (free images) | Optional |
| `SURREAL_URL` | SurrealDB URL (e.g. `https://db.site.com`) | Yes |
| `SURREAL_USER` | SurrealDB username | Yes |
| `SURREAL_PASS` | SurrealDB password | Yes |
@@ -158,23 +216,27 @@ Create in your Cloudflare dashboard:
| `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 |
### 5. WAF Rules
### 6. WAF Rules
**Security → WAF → Rate limiting rules:**
1. **Rate Limit** — 10 req/10s per IP → Block 10s
### 6. Deploy
### 7. Deploy
Fork this repo, connect to Cloudflare Pages, deploy.
### 8. Free Images Domain
**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.
---
## Infrastructure Details
### R2: `direct-img-store`
**Key:** `<sha256-of-normalized-query>` — derived from query, 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>`. 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.
@@ -182,6 +244,14 @@ Add an object lifecycle rule in **R2 → direct-img-store → Settings** that ap
**Key:** normalized query (lowercase, trimmed, max 200 chars) → **Value:** `{"t":1719000000,"ct":"image/jpeg"}` — **TTL:** 90 days
Other keys share the namespace. They use uppercase prefixes, which can never collide with normalized (always lowercase) queries:
| Key | Value | TTL |
|---|---|---|
| `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 |
| `OPENVERSE_TOKEN` | Openverse access token | Token lifetime minus 10 min |
### Database: `SurrealDB` (Rate Limiting)
Using atomic database transactions over HTTP to track per-IP/per-day search frequencies securely and rapidly.
@@ -200,6 +270,7 @@ After deploying, visit `https://<your-domain>/_setup` once. It creates the `dire
- **SurrealDB (v2.3.10)** — atomic rate limiting
- **Cloudflare WAF** — layer 7 mitigation
- **Brave Image Search API** — image sourcing
- **Openverse API + Wikimedia Commons API** — free image sourcing
---
+1
View File
@@ -8,6 +8,7 @@ By using direct-img.link ("the service"), you agree to these terms. If you don't
- Images come from third-party websites and belong to their owners. We don't own, license, or endorse them.
- You are responsible for making sure you have the rights to any image you display or publish.
- free.direct-img.link only serves images that Openverse or Wikimedia Commons mark as free of restrictions (public domain or CC0). These labels come from the sources and can be wrong, so check the original before relying on them.
## Results can be wrong, change, or be inappropriate
+47 -24
View File
@@ -1,7 +1,10 @@
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";
const TTL_SECONDS = 90 * 24 * 60 * 60;
const MAX_INDEX = 20;
export async function onRequest(context) {
const { request, env, params } = context;
@@ -23,10 +26,18 @@ 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)" });
context.waitUntil(countHit(env, request, query));
// ?i= picks which result to serve; free.direct-img.link also takes ?src= and serves only unrestricted images
const free = url.hostname.startsWith("free.");
const i = parseIndex(url.searchParams);
const src = free ? parseFreeSource(url.searchParams) : null;
if (!i || (free && !src)) return asset("bad.webp");
const cacheKey = query;
const r2Key = await sha256(query);
context.waitUntil(countHit(env, request, query, free));
// 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 r2Key = `${free ? "free/" : ""}${await sha256(cacheKey)}`;
const cached = await env.DIRECT_IMG_CACHE.get(cacheKey, "json");
if (cached) {
@@ -125,7 +136,7 @@ export async function onRequest(context) {
return asset("limit.webp");
}
context.waitUntil(notify(env, { title: "New Search", message: `Query: ${query} (Search #${count} for ${ip})\n${url.origin}/${path}`, tags: "mag", priority: 2 }));
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 }));
const fail = async (t, m, tag, p) => {
context.waitUntil(notify(env, { title: t, message: m, tags: tag, priority: p }));
@@ -133,29 +144,25 @@ export async function onRequest(context) {
return asset("bad.webp");
};
let imageUrls = await braveImageSearch(query, env.BRAVE_API_KEY);
if (!imageUrls || imageUrls.length === 0) {
context.waitUntil(notify(env, { title: "Brave Search Empty", message: `No results for: ${query}. Trying Bing Fallback.`, tags: "warning,mag", priority: 3 }));
imageUrls = await bingImageSearchFallback(query);
}
if (!imageUrls || imageUrls.length === 0) return await fail("Search Failed", `Both Brave and Bing returned no results for: ${query}`, "question", 3);
const imageUrls = free ? freeImageUrls(query, src, env) : webImageUrls(context, query);
const GLOBAL_DEADLINE = Date.now() + 20000;
let imgResult = null;
let imgResult = null, tried = 0, found = 0;
const failReasons = [];
for (const imgUrl of imageUrls) {
// i counts only images that download, so dead links and placeholders never take up an index
for await (const imgUrl of imageUrls) {
tried++;
const remaining = GLOBAL_DEADLINE - Date.now();
if (remaining <= 500) {
failReasons.push("Global timeout reached");
break;
}
const res = await fetchImage(imgUrl, Math.min(remaining, 5000));
const res = await fetchImage(imgUrl, Math.min(remaining, 5000), free ? FREE_UA : undefined);
if (res.success) {
imgResult = res;
break;
if (++found === i) {
imgResult = res;
break;
}
} else {
try {
const host = new URL(imgUrl).hostname.replace(/^www\./, '');
@@ -166,9 +173,10 @@ export async function onRequest(context) {
}
}
if (!tried) 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)", `All sources failed for: ${query}\nReasons: ${reasonStr}`, "boom,x", 4);
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;
@@ -179,6 +187,20 @@ export async function onRequest(context) {
return new Response(imgBuffer, { headers: imageHeaders(finalContentType, TTL_SECONDS * 1000) });
}
// ?i=1..MAX_INDEX (default 1). Returns null if invalid.
function parseIndex(searchParams) {
const i = searchParams.get("i") || "1";
return /^\d+$/.test(i) && +i >= 1 && +i <= MAX_INDEX ? +i : null;
}
// Yields Brave results, or Bing's if Brave has none
async function* webImageUrls(context, query) {
const urls = await braveImageSearch(query, context.env.BRAVE_API_KEY);
if (urls?.length) return yield* urls;
context.waitUntil(notify(context.env, { title: "Brave Search Empty", message: `No results for: ${query}. Trying Bing Fallback.`, tags: "warning,mag", priority: 3 }));
yield* (await bingImageSearchFallback(query)) || [];
}
async function setupSurreal(env) {
if (!env.SURREAL_URL || !env.SURREAL_USER || !env.SURREAL_PASS) return jsonResponse(500, { error: "SurrealDB env vars missing" });
const sql = `DEFINE NAMESPACE IF NOT EXISTS direct_img;
@@ -211,14 +233,14 @@ async function notify(env,{ title, message, tags, priority }) {
} catch {}
}
async function countHit(env, request, query) {
async function countHit(env, request, query, free) {
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: `/${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: `${free ? "/free" : ""}/${query.replace(/ /g, "+")}`, title: query, ref: h("referer"), user_agent: h("user-agent"), ip: h("cf-connecting-ip") }] }),
signal: AbortSignal.timeout(5000)
});
} catch {}
@@ -237,10 +259,10 @@ async function sha256(str) {
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("");
}
async function fetchImage(imageUrl, timeoutMs = 5000) {
async function fetchImage(imageUrl, timeoutMs = 5000, ua = "Mozilla/5.0") {
try {
const res = await fetch(imageUrl, {
headers: { "User-Agent": "Mozilla/5.0", "Accept": "image/avif,image/webp,image/*,*/*;q=0.8" },
headers: { "User-Agent": ua, "Accept": "image/avif,image/webp,image/*,*/*;q=0.8" },
redirect: "follow", signal: AbortSignal.timeout(timeoutMs), cf: { cacheTtl: 0 }
});
if (!res.ok) return { success: false, reason: `HTTP ${res.status}` };
@@ -253,7 +275,8 @@ async function fetchImage(imageUrl, timeoutMs = 5000) {
const buffer = await res.arrayBuffer();
if (buffer.byteLength > 10485760) return { success: false, reason: `Buffer >10MB` };
if (await isFlickrPlaceholder(res.url || imageUrl, buffer)) return { success: false, reason: "Flickr placeholder" };
return { success: true, buffer, contentType: ct };
} catch (err) {
return { success: false, reason: err.name === 'TimeoutError' ? 'Timeout' : err.message };
+19
View File
@@ -0,0 +1,19 @@
// Flickr answers deleted photos with HTTP 200 and a "photo no longer available" image.
// SHA-256 of that placeholder at every size suffix (same bytes for every deleted photo):
const PLACEHOLDERS = new Set([
"26b52b156ef2459dc8963a7f565a99f47ddeac36b8005443457fee419637ce85", // none (500x375)
"e5028de2367c10d29ea66e45f7889085682316b2c8c52198c89e72c256de70a0", // _b _c _h _k _o (768x576)
"4a071ae0f3b051a28b0d56c9987a3d18ee5f3839bfc996cc2369a4636cefb2e2", // _z (640x480)
"44378872c6cd32ecb0525e9c10eaade1b8a0c7ccdbc57bdc22f108ad9642606c", // _w (400x300)
"37f48b8788c06e2092a281bf852454918d7eac946bcb21012d6fd030609bd8b1", // _n (320x240)
"b85fb922ab3515241a1cd666bc274523c1c9784022eeed880e9fb3d4174104ae", // _m (240x180)
"632dffa72bf3c993e08e22ba938523b5a148ce5084ca07befda225adff0ef35c", // _q (150x150)
"ef12e410eed533981d3a0c1f447c8093c3d33ef05c1c583addc494b8a4369fd1", // _t (100x75)
"96c0e77be54767d1b7b9e2cc47df913fc23a9cc76dd4608e441dadaeffe057cb", // _s (75x75)
]);
export async function isFlickrPlaceholder(url, buffer) {
if (!new URL(url).hostname.endsWith("staticflickr.com")) return false;
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", buffer))].map(b => b.toString(16).padStart(2, "0")).join("");
return PLACEHOLDERS.has(hash);
}
+21
View File
@@ -0,0 +1,21 @@
import { openverseImageSearch } from "./openverse.js";
import { wikimediaImageSearch } from "./wikimedia.js";
// Wikimedia's User-Agent policy requires contact info; also used when downloading free images
export const FREE_UA = "direct-img.link/1.0 (https://direct-img.link; contact@direct-img.link)";
// Tried in this order when no ?src= is given
const SOURCES = { openverse: openverseImageSearch, wikimedia: wikimediaImageSearch };
// ?src=openverse|wikimedia, or "auto" (every source in order) when omitted. Returns null if invalid.
export function parseFreeSource(searchParams) {
const src = searchParams.get("src")?.toLowerCase() || "auto";
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)) || [];
}
}
+37
View File
@@ -0,0 +1,37 @@
const API = "https://api.openverse.org/v1";
// Uppercase so it can't collide with query cache keys, which are always lowercased
const TOKEN_KEY = "OPENVERSE_TOKEN";
// Tokens expire (~12h); client ID/secret are permanent, so fetch a new token whenever the cached one is gone
async function getToken(env) {
if (!env.OPENVERSE_CLIENT_ID || !env.OPENVERSE_CLIENT_SECRET) return null;
const cached = await env.DIRECT_IMG_CACHE.get(TOKEN_KEY);
if (cached) return cached;
const res = await fetch(`${API}/auth_tokens/token/`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "client_credentials", client_id: env.OPENVERSE_CLIENT_ID, client_secret: env.OPENVERSE_CLIENT_SECRET }),
});
if (!res.ok) return null;
const { access_token, expires_in } = await res.json();
await env.DIRECT_IMG_CACHE.put(TOKEN_KEY, access_token, { expirationTtl: Math.max(60, expires_in - 600) });
return access_token;
}
export async function openverseImageSearch(query, env, ua) {
const url = `${API}/images/?${new URLSearchParams({ q: query, license: "cc0,pdm", extension: "jpg,png,gif,webp", page_size: 20 })}`;
const search = token => fetch(url, { headers: { "User-Agent": ua, ...(token && { Authorization: `Bearer ${token}` }) } });
try {
const token = await getToken(env).catch(() => null);
let res = await search(token);
if (res.status === 401 && token) {
await env.DIRECT_IMG_CACHE.delete(TOKEN_KEY);
res = await search();
}
if (!res.ok) return null;
const data = await res.json();
return data.results?.map(r => r.url).filter(Boolean) || null;
} catch {
return null;
}
}
+19
View File
@@ -0,0 +1,19 @@
export async function wikimediaImageSearch(query, env, ua) {
const params = new URLSearchParams({
action: "query", format: "json", formatversion: 2,
generator: "search", gsrsearch: `${query} filetype:bitmap haslicense:unrestricted`, gsrnamespace: 6, gsrlimit: 30,
prop: "imageinfo", iiprop: "url", iiurlwidth: 1920,
});
try {
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { "User-Agent": ua } });
if (!res.ok) return null;
const pages = (await res.json()).query?.pages || [];
return pages
.sort((a, b) => a.index - b.index)
.map(p => p.imageinfo?.[0])
.filter(Boolean)
.map(ii => ii.thumburl || ii.url);
} catch {
return null;
}
}
+21 -1
View File
@@ -68,9 +68,29 @@ Use images to complement your responses, powered by Brave.</code></pre>
<h2>Usage</h2>
<p>Embed directly in your markdown files:</p>
<pre><code>![orange cat](https://direct-img.link/orange+cat)</code></pre>
<p>Not the image you wanted? Add <code>?i=2</code> (up to <code>20</code>) to serve the next one. Broken images are skipped, so every <code>i</code> is different:</p>
<pre><code>![orange cat](https://direct-img.link/orange+cat?i=2)</code></pre>
<p style="font-size:0.85rem;margin-top:12px;color:var(--text-muted);">* Note: Literal dots (.) and slashes (/) are rejected to prevent bot abuse. Use <code>%2E</code> or <code>%2F</code> if your query requires them.</p>
</section>
<section>
<h2>Free Images</h2>
<p>Need images you can publish? <code>free.direct-img.link</code> only serves images marked <strong>free of restrictions</strong> (public domain or CC0), so no credit is needed.</p>
<pre><code>![orange cat](https://free.direct-img.link/orange+cat)
![orange cat](https://free.direct-img.link/orange+cat?i=2)
![orange cat](https://free.direct-img.link/orange+cat?src=wikimedia&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>openverse</code>, <code>wikimedia</code></td><td>Openverse, then Wikimedia</td></tr>
<tr><td><code>i</code></td><td><code>1</code>–<code>20</code> (which result)</td><td><code>1</code></td></tr>
</tbody>
</table>
<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>Rate Limits</h2>
<table>
@@ -82,7 +102,7 @@ Use images to complement your responses, powered by Brave.</code></pre>
<tr><td>New Searches</td><td>20 per day per IP</td><td>Limit image served</td></tr>
</tbody>
</table>
<p style="font-size:0.85rem;color:var(--text-muted);">* Cached images don't count toward the daily limit 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);">* The daily limit is shared with free.direct-img.link. 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>