mirror of
https://github.com/direct-img/direct-img.link.git
synced 2026-10-29 15:34:51 +00:00
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:
@@ -0,0 +1,37 @@
|
||||
// AI images for ai.direct-img.link, and the fallback for an ?i= past a search's working images
|
||||
const SOURCES = { "auroraimg-6m": auroraImage };
|
||||
export const DEFAULT_AI_SOURCE = "auroraimg-6m";
|
||||
|
||||
// ?src= on ai.*, case-insensitive. Returns null if invalid.
|
||||
export function parseAiSource(searchParams) {
|
||||
const src = searchParams.get("src")?.toLowerCase() || DEFAULT_AI_SOURCE;
|
||||
return Object.hasOwn(SOURCES, src) ? src : null;
|
||||
}
|
||||
|
||||
// Same result shape as fetchImage: { success, buffer, contentType } or { success: false, reason }
|
||||
export async function aiImage(src, query, i, env) {
|
||||
try {
|
||||
return await SOURCES[src](query, seedFor(query, i), env);
|
||||
} catch (err) {
|
||||
return { success: false, reason: err.name === "TimeoutError" ? "Timeout" : err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic per query and i, so each ?i= is a different image of the same prompt and repeats are identical.
|
||||
// 32-bit FNV-1a: the API takes a 32-bit seed, and this is ~28x faster than crypto.subtle (seeds don't need security)
|
||||
function seedFor(query, i) {
|
||||
let h = 0x811c9dc5, s = `${query}\n${i}`;
|
||||
for (let k = 0; k < s.length; k++) h = Math.imul(h ^ s.charCodeAt(k), 0x01000193);
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
// AuroraIMG-6M on our Dokploy server: https://github.com/direct-img/AuroraIMG-6M
|
||||
async function auroraImage(query, seed, env) {
|
||||
if (!env.AURORA_API_KEY) return { success: false, reason: "AURORA_API_KEY not set" };
|
||||
const res = await fetch(`https://aurora.direct-img.link/generate?${new URLSearchParams({ prompt: query, seed })}`, {
|
||||
headers: { Authorization: `Bearer ${env.AURORA_API_KEY}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) return { success: false, reason: `HTTP ${res.status}` };
|
||||
return { success: true, buffer: await res.arrayBuffer(), contentType: res.headers.get("content-type") };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// { urls } ([] if none found), or { error } if Brave refused or failed, e.g. once our rate or spending limit is hit
|
||||
export async function braveImageSearch(query, apiKey) {
|
||||
try {
|
||||
const res = await fetch(`https://api.search.brave.com/res/v1/images/search?q=${encodeURIComponent(query)}&count=100&safesearch=off`, {
|
||||
headers: { "Accept": "application/json", "X-Subscription-Token": apiKey },
|
||||
});
|
||||
if (!res.ok) return { error: `HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` };
|
||||
const data = await res.json();
|
||||
return { urls: data.results?.map(r => r.properties?.url || r.thumbnail?.src).filter(url => !!url) || [] };
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Results per search. Firecrawl charges 2 credits per 10, so the free plan's 1,000 credits a month buy 500 searches
|
||||
export const FIRECRAWL_RESULTS = 10;
|
||||
|
||||
// { urls } ([] if none found), or { error } if Firecrawl refused or failed, e.g. 402 once the month's credits run out,
|
||||
// or 429 past the free plan's 10 searches a minute or 2 at once
|
||||
export async function firecrawlImageSearch(query, apiKey) {
|
||||
try {
|
||||
const res = await fetch("https://api.firecrawl.dev/v2/search", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
// SafeSearch off, like Brave. It's off by default too, but set so a default change can't filter results
|
||||
body: JSON.stringify({ query, sources: ["images"], limit: FIRECRAWL_RESULTS, safe: false }),
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
if (!res.ok) return { error: `HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` };
|
||||
const data = await res.json();
|
||||
if (!data.success) return { error: JSON.stringify(data).slice(0, 300) };
|
||||
return { urls: data.data?.images?.map(r => r.imageUrl).filter(url => !!url) || [] };
|
||||
} catch (err) {
|
||||
return { error: err.name === "TimeoutError" ? "Timeout" : err.message };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// Every source's image URLs, one source after another. All are searched at once, since the whole list gets cached.
|
||||
export async function freeImageUrls(query, src, env) {
|
||||
const names = src === "auto" ? Object.keys(SOURCES) : [src];
|
||||
return (await Promise.all(names.map(n => SOURCES[n](query, env, FREE_UA)))).flatMap(urls => urls || []);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Shared by the worker and scripts/promote.js, so a promoted image's R2 key always matches the one the worker stored.
|
||||
|
||||
// Highest ?i= per site: 40 on the main site (100 Brave results; a Firecrawl query's ?i= past FIRECRAWL_RESULTS gets
|
||||
// an AI image), 20 on free.* (~50 Openverse + Wikimedia results) and 1000 on ai.* (each is a new image, so there's no
|
||||
// list to run out).
|
||||
// Higher values serve max-i.webp, which lists these, so redraw it from design/max-i.html when they change.
|
||||
export const MAX_INDEX = { "": 40, free: 20, ai: 1000 };
|
||||
|
||||
// "free", "ai" or "" (main site), from the hostname's first label
|
||||
export const siteOf = hostname => ["free", "ai"].find(s => hostname.startsWith(`${s}.`)) || "";
|
||||
|
||||
// Path prefix each site's ?i= is folded into by the zone's URL Rewrite and Redirect Rules (scripts/rules.js), e.g.
|
||||
// /orange+cat?i=2 → /web/2/orange+cat, so promoted images can be static files, which ignore query strings
|
||||
export const folder = site => site || "web";
|
||||
|
||||
export function normalizeQuery(path) {
|
||||
const clean = s => s.toLowerCase().trim().replace(/[\x00-\x1f]/g, "").replace(/\/+$/, "").replace(/\s+/g, " ");
|
||||
try {
|
||||
return clean(decodeURIComponent(path.replace(/\+/g, " ")));
|
||||
} catch {
|
||||
return clean(path);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
export const cacheKey = (site, src, i, query) => site ? `${site.toUpperCase()}:${src}:${i}:${query}` : i === 1 ? query : `WEB:${i}:${query}`;
|
||||
export const r2Key = async (site, key) => `${site ? `${site}/` : ""}${await sha256(key)}`;
|
||||
|
||||
export async function sha256(str) {
|
||||
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
|
||||
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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.
|
||||
// Production has OPENVERSE_CLIENT_ID/SECRET set, so its requests are authenticated.
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Uppercase so it can't collide with query cache keys, which are always lowercased
|
||||
const TOKEN_KEY = "SURREAL_TOKEN";
|
||||
// SurrealDB re-checks a password against its deliberately slow hash on every request (~47ms), but a token in ~1ms. A
|
||||
// token lasts 60 min, so it's kept in this isolate's memory and in KV until 10 min before it expires.
|
||||
let cached = null;
|
||||
|
||||
// A token from memory, then KV, then a new /signin (fresh skips memory and KV). null if signing in fails.
|
||||
async function getToken(env, fresh) {
|
||||
if (!fresh && cached?.until > Date.now()) return cached.token;
|
||||
let token = !fresh && await env.DIRECT_IMG_CACHE.get(TOKEN_KEY), signedIn = !token;
|
||||
if (signedIn) {
|
||||
const res = await fetch(`${env.SURREAL_URL}/signin`, {
|
||||
method: "POST",
|
||||
headers: { "Accept": "application/json" },
|
||||
body: JSON.stringify({ user: env.SURREAL_USER, pass: env.SURREAL_PASS }),
|
||||
});
|
||||
token = res.ok && (await res.json()).token;
|
||||
if (!token) return null;
|
||||
}
|
||||
const ttl = Math.floor(JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))).exp - Date.now() / 1000) - 600;
|
||||
if (signedIn && ttl >= 60) await env.DIRECT_IMG_CACHE.put(TOKEN_KEY, token, { expirationTtl: ttl }).catch(() => {});
|
||||
cached = ttl > 0 ? { token, until: Date.now() + ttl * 1000 } : null;
|
||||
return token;
|
||||
}
|
||||
|
||||
// Runs SurrealQL in direct_img/rate_limit and resolves to the Response, or null if SurrealDB isn't configured. Uses the
|
||||
// password only if no token can be had, so it still works, just slower.
|
||||
export function surreal(env) {
|
||||
if (!env.SURREAL_URL || !env.SURREAL_USER || !env.SURREAL_PASS) return null;
|
||||
const send = (token, body) => fetch(`${env.SURREAL_URL}/sql`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Authorization": token ? `Bearer ${token}` : `Basic ${btoa(`${env.SURREAL_USER}:${env.SURREAL_PASS}`)}`,
|
||||
"surreal-ns": "direct_img",
|
||||
"surreal-db": "rate_limit",
|
||||
},
|
||||
body
|
||||
});
|
||||
return async body => {
|
||||
const token = await getToken(env).catch(() => null), res = await send(token, body);
|
||||
// A refused token (expired early or revoked) means the query never ran, so it's safe to retry with a new one
|
||||
return res.status === 401 && token ? send(await getToken(env, true).catch(() => null), body) : res;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user