diff --git a/README.md b/README.md index 28f881f..674244f 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ Fork this repo and add these **GitHub Actions secrets** (Settings → Secrets an Change the three `routes` in `wrangler.jsonc` to your domain, `free.` and `ai.` (each needs a proxied DNS record; with nothing behind it, use `AAAA 100::`). Any hostname starting with `free.` serves free images, and any starting with `ai.` serves AI images; everything else serves regular search. -Then add the rules that let static images work (needs a token with Zone: Transform Rules Edit and Single Redirect Edit): +Then add the rules that let static images work (needs a token with Zone: Transform Rules Edit): ```bash CLOUDFLARE_API_TOKEN=... node scripts/rules.js free. ai. @@ -307,15 +307,14 @@ Every request runs the Worker, which counts toward the free plan's 100,000 reque - Images are cached between runs (Actions cache), so only newly promoted ones download from R2 - An image is dropped once it's 90 days old, the same as the R2 lifecycle rule, so a popular query (e.g. `current us president`) is still searched again instead of staying static forever. It's promoted again the next day if it's still popular -Static files ignore query strings, so the zone's rules (`scripts/rules.js`) fold the site and `?i=` into the path first. The visitor's URL doesn't change: +Static files ignore query strings, so a URL Rewrite Rule (`scripts/rules.js`) turns each site's bare queries into their static file's path first. The visitor's URL doesn't change: | Request | Looked up as | |---|---| -| `direct-img.link/orange+cat` | `/web/1/orange+cat` (URL Rewrite, lowercased) | -| `direct-img.link/orange+cat?i=2` | 302 to `/web/2/orange+cat` (Redirect, so `?i=` shows up in analytics) | -| `free.direct-img.link/orange+cat` | `/free/1/orange+cat` | +| `direct-img.link/orange+cat` | `/web/1/orange%2Bcat` (lowercased, `+` encoded like the file's name) | +| `free.direct-img.link/Orange+Cat` | `/free/1/orange%2Bcat` | -A promoted file named `/web/2/orange+cat` serves those; anything else falls through to the Worker, which unfolds the path and handles it as before. Requests with other params (`?src=`), a literal `+` (`%2B`) or a trailing slash aren't folded and always go to the Worker. Static files with a `+` in the name answer with one extra redirect (to `%2B`), which is still free. +A promoted file there serves it; otherwise the Worker gets the request, unfolds the path and handles it as before. Requests with any params (`?i=`, `?src=`), a literal `+` (`%2B`) or a trailing slash aren't rewritten and always go to the Worker. Analytics has no query string, so `?i=` views count toward the query's `?i=1`, and only `?i=1` is promoted. The free plan has no `regex_replace`, so the rule encodes up to 10 `+`s; a longer query's static file answers with one extra (free) redirect. ### KV: `DIRECT_IMG_CACHE` diff --git a/scripts/promote.js b/scripts/promote.js index f5152a7..7c64aed 100644 --- a/scripts/promote.js +++ b/scripts/promote.js @@ -4,7 +4,7 @@ // promoted/report.json for scripts/notify.js. promoted/ is kept between runs (Actions cache), so only new images download. import { mkdir, readFile, writeFile, link, rm, readdir } from "node:fs/promises"; import { dirname } from "node:path"; -import { TTL_SECONDS, MAX_INDEX, siteOf, folder, normalizeQuery, cacheKey, r2Key } from "../src/_utils/keys.js"; +import { TTL_SECONDS, siteOf, folder, normalizeQuery, cacheKey, r2Key } from "../src/_utils/keys.js"; import { parseFreeSource } from "../src/_utils/free.js"; import { parseAiSource } from "../src/_utils/ai.js"; @@ -39,22 +39,20 @@ async function gql(query) { return r.data.viewer; } -// The image a request was for: a folded path (/web/2/orange+cat) or a bare query (/orange+cat), which is ?i=1 since -// the rules redirect every ?i= to its folded path. null for anything else (the homepage, assets, another site's folder). +// The image a request for a bare query (/orange+cat) was for. Analytics has no query string, so a ?i= view counts toward +// its query's ?i=1 too, and only ?i=1 is promoted (other ?i= always run the worker). null for anything else (the +// homepage, assets, probes like /wp-admin/x.php). async function parse(host, path) { - const site = siteOf(host), m = path.match(/^\/(?:(web|free|ai)\/(\d+)\/)?([^/.]+)$/); - if (!m || (m[1] && m[1] !== folder(site))) return null; - // A promoted image's +s come back as %2B from the asset redirect, while the rules lowercase a query's own %2b - const i = +(m[2] || 1), query = normalizeQuery(m[1] ? m[3].replaceAll("%2B", "+") : m[3]); - if (!query || query.length > 200 || i < 1 || i > MAX_INDEX[site]) return null; + const site = siteOf(host), m = path.match(/^\/([^/.]+)$/), query = m && normalizeQuery(m[1]); + if (!query || query.length > 200) return null; const name = query.replaceAll(" ", "+"); if (BAD_NAME.test(query) || Buffer.byteLength(name) > 200) return null; const src = site === "free" ? parseFreeSource(new URLSearchParams()) : site === "ai" ? parseAiSource(new URLSearchParams()) : null; - return { id: `${folder(site)}/${i}/${name}`, r2: await r2Key(site, cacheKey(site, src, i, query)) }; + return { id: `${folder(site)}/1/${name}`, r2: await r2Key(site, cacheKey(site, src, 1, query)) }; } // Views per image over the last DAYS: one query per host and day, since a query returns at most 10,000 rows. Only 200s -// count, so a promoted image isn't counted again for the redirect that encodes its + as %2B. +// count, so redirects (e.g. /favicon.ico) aren't. async function views(zoneTag) { const totals = new Map(), now = Date.now(); let total = 0; diff --git a/scripts/rules.js b/scripts/rules.js index 8f9c83f..7967c07 100644 --- a/scripts/rules.js +++ b/scripts/rules.js @@ -1,38 +1,30 @@ -// Sets the zone's rules that fold the site and ?i= into the path, so promoted images can be static files (which ignore -// query strings): a URL Rewrite turns /orange+cat into /web/1/orange+cat, and a Redirect sends /orange+cat?i=2 to -// /web/2/orange+cat, which also makes ?i= show up in analytics. Replaces only the rules it made earlier. +// Sets the zone's URL Rewrite Rules that turn a bare query into its site's static file path (/orange+cat → +// /web/1/orange%2Bcat), so a promoted image is served without running the worker and the visitor's URL doesn't change. +// +s become %2B (the static file's own encoding), since a + would get a redirect to it. Replaces only rules it made. // node scripts/rules.js direct-img.link free.direct-img.link ai.direct-img.link -// With no hostnames it removes them. Needs CLOUDFLARE_API_TOKEN with Zone > Transform Rules and Single Redirect edit. +// With no hostnames it removes them. Needs CLOUDFLARE_API_TOKEN with Zone > Transform Rules edit. import { siteOf, folder } from "../src/_utils/keys.js"; const env = process.env, hosts = process.argv.slice(2), ZONE = env.ZONE || "direct-img.link", TAG = "direct-img fold:"; +const PHASE = "http_request_transform"; const api = (path, init) => fetch(`https://api.cloudflare.com/client/v4${path}`, { ...init, headers: { Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`, "Content-Type": "application/json" } }).then(r => r.json()); -// A bare query: not the homepage, assets/, robots.txt or an already folded path (which a redirect leads to). A literal + -// (%2B) is left to the worker, since a promoted file's + stands for a space. -const FLAT = 'http.request.uri.path ne "/" and not http.request.uri.path contains "." and not http.request.uri.path wildcard "/*/*" and not lower(http.request.uri.path) contains "%2b"'; +// A bare query with no params: not the homepage, assets/, robots.txt or an already folded path. A literal + (%2B) is +// left to the worker, since a static file's %2B stands for a space. +const BARE = 'http.request.uri.query eq "" and http.request.uri.path ne "/" and not http.request.uri.path contains "." and not http.request.uri.path wildcard "/*/*" and not lower(http.request.uri.path) contains "%2b"'; +// Each wildcard_replace encodes one + (the free plan has no regex_replace); a query with more keeps a redirect +const path = Array.from({ length: 10 }).reduce(e => `wildcard_replace(${e}, "*+*", "\${1}%2B\${2}")`, "lower(http.request.uri.path)"); -const bySite = Object.groupBy(hosts, h => folder(siteOf(h))); -const rules = { - http_request_transform: Object.entries(bySite).map(([f, hs]) => ({ - description: `${TAG} ${f} bare query → /${f}/1/`, - expression: `http.host in {${hs.map(h => JSON.stringify(h)).join(" ")}} and http.request.uri.query eq "" and ${FLAT}`, - action: "rewrite", - action_parameters: { uri: { path: { expression: `concat("/${f}/1", lower(http.request.uri.path))` } } }, - })), - http_request_dynamic_redirect: Object.entries(bySite).map(([f, hs]) => ({ - description: `${TAG} ${f} ?i= → /${f}//`, - expression: `http.host in {${hs.map(h => JSON.stringify(h)).join(" ")}} and http.request.uri.query strict wildcard "i=*" and not http.request.uri.query contains "&" and ${FLAT}`, - action: "redirect", - action_parameters: { from_value: { status_code: 302, preserve_query_string: false, target_url: { expression: `concat("https://", http.host, "/${f}/", http.request.uri.args["i"][0], lower(http.request.uri.path))` } } }, - })), -}; +const mine = Object.entries(Object.groupBy(hosts, h => folder(siteOf(h)))).map(([f, hs]) => ({ + description: `${TAG} ${f} bare query → /${f}/1/`, + expression: `http.host in {${hs.map(h => JSON.stringify(h)).join(" ")}} and ${BARE}`, + action: "rewrite", + action_parameters: { uri: { path: { expression: `concat("/${f}/1", ${path})` } } }, +})); const zone = (await api(`/zones?name=${ZONE}`)).result[0].id; -for (const [phase, mine] of Object.entries(rules)) { - const others = ((await api(`/zones/${zone}/rulesets/phases/${phase}/entrypoint`)).result?.rules || []) - .filter(r => !r.description?.startsWith(TAG)) - .map(({ id, version, last_updated, ref, ...r }) => r); - const res = await api(`/zones/${zone}/rulesets/phases/${phase}/entrypoint`, { method: "PUT", body: JSON.stringify({ rules: [...others, ...mine] }) }); - console.log(phase, res.success ? `${mine.length} set, ${others.length} others kept` : JSON.stringify(res.errors)); - if (!res.success) process.exitCode = 1; -} +const others = ((await api(`/zones/${zone}/rulesets/phases/${PHASE}/entrypoint`)).result?.rules || []) + .filter(r => !r.description?.startsWith(TAG)) + .map(({ id, version, last_updated, ref, ...r }) => r); +const res = await api(`/zones/${zone}/rulesets/phases/${PHASE}/entrypoint`, { method: "PUT", body: JSON.stringify({ rules: [...others, ...mine] }) }); +console.log(res.success ? `${mine.length} set, ${others.length} others kept` : JSON.stringify(res.errors)); +if (!res.success) process.exitCode = 1; diff --git a/src/worker.js b/src/worker.js index eb02c20..3a9754f 100644 --- a/src/worker.js +++ b/src/worker.js @@ -365,14 +365,15 @@ async function countHit(env, request, query, site, params) { } catch {} } -// The zone's rules fold the site and ?i= into the path (/orange+cat?i=2 → /web/2/orange+cat, and a bare query gets i 1) -// so promoted images can be served as static files, which ignore query strings. Unfolded back here, so the rest of the -// worker sees the original request. A folded path for another site is left as is, so it's rejected like any slash. +// The zone's URL Rewrite (scripts/rules.js) turns a bare query into its site's static file path (/orange+cat → +// /web/1/orange%2Bcat) so a promoted image is served without running the worker. Unfolded back here, so the rest of the +// worker sees the original request: the rule's %2B were +s, since it leaves a query's own %2B unfolded. Other ?i= in the +// path are still accepted (links from when ?i= was folded too). A folded path for another site is rejected like any slash. function unfold(url, site) { const m = url.pathname.match(/^\/(web|free|ai)\/([^/]*)\/([^/]+)$/); if (!m || m[1] !== folder(site)) return url; const u = new URL(url); - u.pathname = `/${m[3]}`; + u.pathname = `/${m[3].replace(/%2B/gi, "+")}`; if (m[2] && m[2] !== "1") u.searchParams.set("i", m[2]); return u; }