multipleof4andClaude Opus 5.5 2941397180 Fix: Serve bad.webp for unknown query params
Scanner probes like /userfiles?path=../../.env were treated as real
searches since extra params were ignored, wasting searches and rate limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:37:51 -07:00

🔗 direct-img.link

Live images in markdown, powered by search.

Give your AI a system instruction to embed images using direct-img.link and they just work — no uploads, no APIs, no tokens.

Usage

orange cat

![orange cat](https://direct-img.link/orange+cat)
![sunset at beach](https://direct-img.link/sunset+at+beach)
![current us president](https://direct-img.link/current+us+president)

That's it. The image is searched, cached, and served.

URL Format

Use + to separate words, like Google:

https://direct-img.link/orange+cat
https://direct-img.link/new+york+city
Query URL
orange cat /orange+cat
spider-man /spider-man
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:

Rule Example Result
+ and %20 are treated as spaces orange+cat, orange%20cat orange cat
Lowercased Orange+Cat orange cat
Trimmed +orange+cat+ orange cat
Multiple spaces collapsed orange++cat orange cat
Trailing slashes stripped orange+cat/ orange cat
Control characters removed orange\x00cat orangecat
Slashes & Dots rejected info.php, wp-admin/ bad.webp served
Max length: 200 characters — 400 error if exceeded

Characters that work fine

  • Letters, numbers, spaces — standard queries
  • Hyphens (spider-man), apostrophes (90's) — passed through
  • Unicode (café, 日本) — supported via URL encoding

Slashes and Dots must be encoded

Literal slashes (/) and dots (.) in the URL path are rejected to prevent bot abuse (e.g. info.php or wp-admin/ probes). If your query genuinely contains these characters, you must encode them:

Query URL
AC/DC /AC%2FDC ✅
node.js /node%2Ejs ✅
info.php /info.php ❌ (rejected)
AC/DC /AC/DC ❌ (rejected)

Things to know

  • Query parameters (?...) other than i (and src on free.direct-img.link) 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

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:

![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 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 — searched with license=cc0,pdm, JPG/PNG/GIF/WebP only. Made using the Openverse API, but not endorsed or certified by Openverse.
  • Wikimedia Commons — 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:

When writing markdown, embed relevant images/gifs using direct-img.link — a free image search proxy that returns images directly from a URL. But keep it entertaining, for example use it to show reaction faces, girl, or fun, not technical.
Example structure:
![Cat Gif](https://direct-img.link/cat+gif)
Use images to complement your responses, powered by Brave.

Rate Limits

WAF Protection (Cloudflare Free)

Rule Limit Action
Rate Limit ~10 requests/10s per IP HTTP 429 for ~10s

New Searches (Cache Misses)

  • 35 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

  • Images are cached for 90 days
  • After expiry, the next request triggers a fresh search
  • Images are stored in their original format as fetched from source
  • A matching R2 lifecycle rule deletes stored objects after 90 days

Support

Free community service. Donations help cover API and infrastructure costs.

GitHub Sponsors: github.com/sponsors/multipleof4

BTC: bc1q3d975cd57205dx6mz05s2g27xujxsc3q0nlv59


Self-Hosting

1. Brave Search API Key

  1. Go to brave.com/search/api
  2. Click Get Started
  3. Create a Brave account or sign in
  4. Subscribe — you get $5 in free monthly credits (covers 1,000 queries/month)
  5. Go to your API dashboard
  6. Copy your API key (starts with BSA...)

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:

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:

Resource Name Purpose
R2 Bucket direct-img-store Stores cached images
KV Namespace DIRECT_IMG_CACHE Cache existence + content type + timestamp

4. Pages Bindings

Settings → Functions → Bindings:

Type Variable Resource
R2 Bucket R2_IMAGES direct-img-store
KV Namespace DIRECT_IMG_CACHE DIRECT_IMG_CACHE

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
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

6. WAF Rules

Security → WAF → Rate limiting rules:

  1. Rate Limit — 10 req/10s per IP → Block 10s

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-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.

KV: DIRECT_IMG_CACHE

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.

Note: This implementation is tested and verified for SurrealDB v2.3.10.

After deploying, visit https://<your-domain>/_setup once. It creates the direct_img namespace, rate_limit database, rate table and an updated_at index (used by the cleanup query). It's idempotent, so visiting again is harmless, and it returns {"ok":true} on success.


Stack

  • Cloudflare Pages — hosting + edge functions
  • Cloudflare R2 — image storage
  • Cloudflare KV — generic lookups
  • 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

direct-img.link — because ![](https://direct-img.link/thing) should just work.

S
Description
Image searches directly in markdown!
Readme
406 KiB
Languages
JavaScript 68.1%
HTML 31.9%