Feat: Move from Pages to a Worker and serve popular images as static files

Static assets don't count toward the free plan's 100,000 requests a day, so a
daily GitHub Action promotes the most viewed images (by Cloudflare analytics)
from R2 into public/ before deploying, and reports to ntfy. Zone rules fold
the site and ?i= into the path, since static files ignore query strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 17:08:34 +00:00
co-authored by Claude Opus 5.5
parent fb3635dd47
commit 32f742b73f
30 changed files with 471 additions and 41 deletions
+192
View File
@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>direct-img.link | Live images in markdown</title>
<meta name="description" content="Embed live images in markdown powered by search. No APIs, no tokens, just search.">
<link rel="icon" href="/assets/favicon.ico" sizes="16x16 32x32 48x48"><link rel="icon" href="/assets/logo.svg" type="image/svg+xml">
<script data-goatcounter="https://direct-img.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
<style>
:root{--bg:#fff;--text:#1a1a1a;--text-muted:#666;--accent:#0070f3;--warning:#eb5757;--code-bg:#f5f5f5;--border:#eaeaea;--radius:8px;}
*{box-sizing:border-box;}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;line-height:1.6;color:var(--text);background-color:var(--bg);margin:0;display:flex;flex-direction:column;align-items:center;}
main{max-width:680px;width:100%;padding:80px 24px;}
h1{font-size:2.5rem;font-weight:800;letter-spacing:-0.02em;margin-bottom:8px;}
p.tagline{font-size:1.25rem;color:var(--text-muted);margin-bottom:32px;}
.status-notice{padding:20px;background:#fff5f5;border:1px solid #ffe0e0;border-radius:var(--radius);margin-bottom:48px;}
.status-notice p{margin:0;font-size:1rem;}
.status-notice strong{color:var(--warning);}
.donate{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin-top:12px;}
.btc-address{font-family:monospace;background:rgba(0,0,0,0.05);padding:4px 8px;border-radius:4px;font-size:0.85rem;word-break:break-all;display:inline-block;}
section{margin-bottom:48px;}
h2{font-size:1.5rem;font-weight:700;margin-bottom:16px;border-bottom:1px solid var(--border);padding-bottom:8px;}
pre{background:var(--code-bg);padding:16px;border-radius:var(--radius);overflow-x:auto;font-size:0.9rem;border:1px solid var(--border);}
.copy-wrap{position:relative;}
.copy-wrap pre{white-space:pre-wrap;padding-right:72px;}
.copy{position:absolute;top:8px;right:8px;font:inherit;font-size:0.75rem;font-weight:600;padding:4px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text-muted);cursor:pointer;}
.copy:hover{color:var(--accent);border-color:var(--accent);}
code{font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:0.9rem;}
th,td{text-align:left;padding:12px;border-bottom:1px solid var(--border);}
th{color:var(--text-muted);font-weight:600;}
.card{border:1px solid var(--border);padding:16px;border-radius:var(--radius);}
.card h3{margin:0 0 8px 0;font-size:1rem;}
.card p{margin:0;font-size:0.9rem;color:var(--text-muted);}
.sub{display:flex;flex-wrap:wrap;gap:8px;}
.sub input{flex:1;min-width:200px;font:inherit;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);color:var(--text);}
.sub input:focus{outline:none;border-color:var(--accent);}
.sub button{font:inherit;font-weight:600;padding:10px 20px;border:0;border-radius:var(--radius);background:var(--accent);color:#fff;cursor:pointer;}
.sub button:disabled{opacity:.6;cursor:default;}
.cf-turnstile{margin-top:8px;}
#sub-msg{margin:8px 0 0;font-size:0.9rem;}
footer{width:100%;max-width:680px;padding:40px 24px;border-top:1px solid var(--border);color:var(--text-muted);font-size:0.9rem;display:flex;justify-content:space-between;margin-top:auto;}
a{color:var(--accent);text-decoration:none;} a:hover{text-decoration:underline;}
@media(max-width:600px){h1{font-size:2rem;}}
</style>
</head>
<body>
<main>
<h1>direct-img.link</h1>
<p class="tagline">Live images in markdown, powered by search.</p>
<section class="status-notice">
<p>This service is <strong>free</strong> to use. However, search API and server costs are high. Without community donations, <strong>this project will die quickly.</strong></p>
<p style="margin-top:10px;">If we receive significant donations, we will <strong>increase the daily search limits</strong> for everyone.</p>
<div class="donate">
<iframe src="https://github.com/sponsors/multipleof4/button" title="Sponsor multipleof4" height="32" width="114" style="border:0;border-radius:6px;"></iframe>
<div class="btc-address">BTC: bc1q3d975cd57205dx6mz05s2g27xujxsc3q0nlv59</div>
</div>
</section>
<section>
<h2>For AI System Prompts</h2>
<p>Add this to your system instructions:</p>
<div class="copy-wrap">
<button class="copy" onclick="copyPrompt(this)">Copy</button>
<pre><code id="prompt">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.</code></pre>
</div>
</section>
<section>
<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>40</code>) to serve the next one:</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. Only free images are served, so if there are none you get <code>bad.webp</code> instead of an AI image. With <code>src</code>, only that source is searched. 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>1000</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 has fewer than <code>?i=</code> working images, 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>
<thead>
<tr><th>Scope</th><th>Limit</th><th>Action</th></tr>
</thead>
<tbody>
<tr><td>Burst</td><td>~20 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 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. A search's results are kept for an hour, so trying other <code>?i=</code> values right after is free and fast. An <code>?i=</code> past a search's working images on direct-img.link serves an AI image instead, cached the same way.</p>
</div>
</section>
<section>
<h2>Get Updates</h2>
<p>Get an email when direct-img.link gets new features, sources, or higher limits. No spam.</p>
<form onsubmit="event.preventDefault();sub(this)">
<div class="sub">
<input type="email" name="email" required placeholder="you@example.com" autocomplete="email" aria-label="Email address">
<button type="submit">Sign up</button>
</div>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAFG4HWETOcDDQ5vg" data-appearance="interaction-only" data-size="flexible"></div>
<p id="sub-msg" role="status"></p>
</form>
<p style="font-size:0.85rem;color:var(--text-muted);">* Unsubscribe anytime with the link in any email. We never share your email.</p>
</section>
</main>
<footer>
<div>Built for the AI era.</div>
<div><a href="https://github.com/direct-img/direct-img.link/blob/main/TERMS.md">Terms</a> · <a href="https://github.com/direct-img/direct-img.link/blob/main/PRIVACY.md">Privacy</a> · <a href="https://github.com/direct-img/direct-img.link">GitHub</a></div>
</footer>
<script>
const copyPrompt=b=>{
const p=document.getElementById('prompt');
(navigator.clipboard?.writeText(p.textContent)??Promise.reject()).then(()=>{
b.textContent='Copied!';
setTimeout(()=>b.textContent='Copy',1500);
},()=>getSelection().selectAllChildren(p));
};
const sub=async f=>{
const b=f.querySelector('button'),m=document.getElementById('sub-msg'),tok=()=>f.querySelector('[name=cf-turnstile-response]')?.value;
b.disabled=true;b.textContent='Signing up…';m.textContent='';
for(let i=0;i<50&&!tok();i++)await new Promise(r=>setTimeout(r,100));
let ok=false,e='Network error. Please try again.';
try{
const r=await fetch('https://newsletter.planetrenox.com/api/sub',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pool:'direct-img',email:f.email.value.trim(),token:tok()||''})}),d=await r.json();
ok=r.ok&&d.ok;
e={'invalid email':"That email doesn't look right.",'captcha failed':"Couldn't verify you're human. Please try again."}[d.error]??'Something went wrong. Please try again.';
}catch{}
m.textContent=ok?"You're signed up. Thanks!":e;
m.style.color=ok?'#1a7f37':'var(--warning)';
if(ok)f.reset();
window.turnstile?.reset();
b.disabled=false;b.textContent='Sign up';
};
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script type="module">
import * as L from '/assets/rate-limit.js';
for(const e of document.querySelectorAll('[data-limit]'))e.textContent=L[e.dataset.limit]??e.textContent;
</script>
</body>
</html>