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
+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;
}
}