mirror of
https://github.com/direct-img/direct-img.link.git
synced 2026-10-15 17:27:54 +00:00
Free searches never spend Brave credits, so they no longer share the main site's 35/day counter. Main-site rate ids are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
11 KiB
Markdown
278 lines
11 KiB
Markdown
# 🔗 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
|
||
|
||

|
||
|
||
```markdown
|
||

|
||

|
||

|
||
```
|
||
|
||
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](#free-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
|
||
|
||
## 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
|
||

|
||

|
||

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

|
||
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** on `direct-img.link` and **100** on `free.direct-img.link` (separate counters, reset at midnight UTC), then `limit.webp` is served
|
||
- **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](https://github.com/sponsors/multipleof4)
|
||
|
||
**BTC:** `bc1q3d975cd57205dx6mz05s2g27xujxsc3q0nlv59`
|
||
|
||
---
|
||
|
||
## Self-Hosting
|
||
|
||
### 1. Brave Search API Key
|
||
|
||
1. Go to [brave.com/search/api](https://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](https://api.search.brave.com/app/#/subscriptions)
|
||
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:
|
||
|
||
```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:
|
||
|
||
| 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 `` should just work.
|