Fix: Keep visitors' URLs unchanged for ?i= and promoted images with a +

The ?i= redirect is gone, so only bare queries are promoted, and the rewrite
encodes + as %2B so static files no longer redirect to their encoded name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 18:06:16 +00:00
co-authored by Claude Opus 5.5
parent fdbd91aa19
commit b932044c5f
4 changed files with 40 additions and 50 deletions
+5 -6
View File
@@ -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.<your-domain>` and `ai.<your-domain>` (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. Change the three `routes` in `wrangler.jsonc` to your domain, `free.<your-domain>` and `ai.<your-domain>` (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 ```bash
CLOUDFLARE_API_TOKEN=... node scripts/rules.js <your-domain> free.<your-domain> ai.<your-domain> CLOUDFLARE_API_TOKEN=... node scripts/rules.js <your-domain> free.<your-domain> ai.<your-domain>
@@ -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 - 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 - 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 | | Request | Looked up as |
|---|---| |---|---|
| `direct-img.link/orange+cat` | `/web/1/orange+cat` (URL Rewrite, lowercased) | | `direct-img.link/orange+cat` | `/web/1/orange%2Bcat` (lowercased, `+` encoded like the file's name) |
| `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%2Bcat` |
| `free.direct-img.link/orange+cat` | `/free/1/orange+cat` |
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` ### KV: `DIRECT_IMG_CACHE`
+8 -10
View File
@@ -4,7 +4,7 @@
// promoted/report.json for scripts/notify.js. promoted/ is kept between runs (Actions cache), so only new images download. // 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 { mkdir, readFile, writeFile, link, rm, readdir } from "node:fs/promises";
import { dirname } from "node:path"; 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 { parseFreeSource } from "../src/_utils/free.js";
import { parseAiSource } from "../src/_utils/ai.js"; import { parseAiSource } from "../src/_utils/ai.js";
@@ -39,22 +39,20 @@ async function gql(query) {
return r.data.viewer; 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 image a request for a bare query (/orange+cat) was for. Analytics has no query string, so a ?i= view counts toward
// the rules redirect every ?i= to its folded path. null for anything else (the homepage, assets, another site's folder). // 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) { async function parse(host, path) {
const site = siteOf(host), m = path.match(/^\/(?:(web|free|ai)\/(\d+)\/)?([^/.]+)$/); const site = siteOf(host), m = path.match(/^\/([^/.]+)$/), query = m && normalizeQuery(m[1]);
if (!m || (m[1] && m[1] !== folder(site))) return null; if (!query || query.length > 200) 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 name = query.replaceAll(" ", "+"); const name = query.replaceAll(" ", "+");
if (BAD_NAME.test(query) || Buffer.byteLength(name) > 200) return null; if (BAD_NAME.test(query) || Buffer.byteLength(name) > 200) return null;
const src = site === "free" ? parseFreeSource(new URLSearchParams()) : site === "ai" ? parseAiSource(new URLSearchParams()) : 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 // 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) { async function views(zoneTag) {
const totals = new Map(), now = Date.now(); const totals = new Map(), now = Date.now();
let total = 0; let total = 0;
+22 -30
View File
@@ -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 // Sets the zone's URL Rewrite Rules that turn a bare query into its site's static file path (/orange+cat →
// query strings): a URL Rewrite turns /orange+cat into /web/1/orange+cat, and a Redirect sends /orange+cat?i=2 to // /web/1/orange%2Bcat), so a promoted image is served without running the worker and the visitor's URL doesn't change.
// /web/2/orange+cat, which also makes ?i= show up in analytics. Replaces only the rules it made earlier. // +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 // 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"; 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 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()); 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 + // A bare query with no params: not the homepage, assets/, robots.txt or an already folded path. A literal + (%2B) is
// (%2B) is left to the worker, since a promoted file's + stands for a space. // left to the worker, since a static file's %2B 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"'; 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 mine = Object.entries(Object.groupBy(hosts, h => folder(siteOf(h)))).map(([f, hs]) => ({
const rules = { description: `${TAG} ${f} bare query → /${f}/1/`,
http_request_transform: Object.entries(bySite).map(([f, hs]) => ({ expression: `http.host in {${hs.map(h => JSON.stringify(h)).join(" ")}} and ${BARE}`,
description: `${TAG} ${f} bare query → /${f}/1/`, action: "rewrite",
expression: `http.host in {${hs.map(h => JSON.stringify(h)).join(" ")}} and http.request.uri.query eq "" and ${FLAT}`, action_parameters: { uri: { path: { expression: `concat("/${f}/1", ${path})` } } },
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}/<i>/`,
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 zone = (await api(`/zones?name=${ZONE}`)).result[0].id; 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 || [])
const others = ((await api(`/zones/${zone}/rulesets/phases/${phase}/entrypoint`)).result?.rules || []) .filter(r => !r.description?.startsWith(TAG))
.filter(r => !r.description?.startsWith(TAG)) .map(({ id, version, last_updated, ref, ...r }) => r);
.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] }) });
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));
console.log(phase, res.success ? `${mine.length} set, ${others.length} others kept` : JSON.stringify(res.errors)); if (!res.success) process.exitCode = 1;
if (!res.success) process.exitCode = 1;
}
+5 -4
View File
@@ -365,14 +365,15 @@ async function countHit(env, request, query, site, params) {
} catch {} } 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) // The zone's URL Rewrite (scripts/rules.js) turns a bare query into its site's static file path (/orange+cat →
// so promoted images can be served as static files, which ignore query strings. Unfolded back here, so the rest of the // /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. A folded path for another site is left as is, so it's rejected like any slash. // 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) { function unfold(url, site) {
const m = url.pathname.match(/^\/(web|free|ai)\/([^/]*)\/([^/]+)$/); const m = url.pathname.match(/^\/(web|free|ai)\/([^/]*)\/([^/]+)$/);
if (!m || m[1] !== folder(site)) return url; if (!m || m[1] !== folder(site)) return url;
const u = new URL(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]); if (m[2] && m[2] !== "1") u.searchParams.set("i", m[2]);
return u; return u;
} }