Feat: Promote every ?i= and ?src=, measured by a WAF logging rule

Analytics has no query string, so a WAF custom rule logs image requests with
theirs and promote.js splits each path's views across its variants. The
rewrite names static files after the param values (/orange%2Bcat/i2-), since
the static file server redirects names with = or &. Drops the legacy
/free/5/x paths and the /1/ segment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 19:08:03 +00:00
co-authored by Claude Opus 5.5
parent d2f8c8ffba
commit 87f80ac956
6 changed files with 114 additions and 76 deletions
+40 -23
View File
@@ -1,32 +1,49 @@
// Sets the zone's URL Rewrite Rules that turn a bare query into its static file's path, under its hostname
// (free.direct-img.link/Orange+Cat → /free.direct-img.link/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 the rules it made earlier.
// Sets the zone's rules for static images, replacing only the ones it made earlier:
// - URL Rewrite Rules give each image request a static file path under its hostname, named after its ?i= and ?src=
// (free.direct-img.link/Orange+Cat?i=2 → /free.direct-img.link/orange%2Bcat/i2-), so a promoted image is served
// without running the worker and the visitor's URL doesn't change. +s become %2B and the params skip = and &, since
// the static file server redirects to those encodings otherwise.
// - A WAF custom rule logs every image request with its query string (analytics has no query string), which is how
// scripts/promote.js tells ?i= and ?src= apart. It's a skip that skips nothing: it's kept last in its ruleset.
// 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 edit.
// With no hostnames it removes them. Needs CLOUDFLARE_API_TOKEN with Zone > Transform Rules and Zone WAF edit.
import { MAX_PLUSES } 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", PATH = "http.request.uri.path";
const PATH = "http.request.uri.path", ARG = n => `http.request.uri.args["${n}"][0]`;
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 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.host in {${hosts.map(h => JSON.stringify(h)).join(" ")}} and http.request.uri.query eq "" and ${PATH} ne "/" and not ${PATH} contains "." and not ${PATH} wildcard "/*/*" and not lower(${PATH}) contains "%2b"`;
// An image request: not the homepage, assets/, robots.txt, probes like /wp-admin/x.php or an already rewritten path
const IMAGE = `http.host in {${hosts.map(h => JSON.stringify(h)).join(" ")}} and ${PATH} ne "/" and not ${PATH} contains "." and not ${PATH} wildcard "/*/*"`;
// Folded only with no params but i and src (others get bad.webp from the worker), and without a literal + (%2B), since
// a static file's %2B stands for a space
const FOLDABLE = `${IMAGE} and (http.request.uri.query eq "" or all(http.request.uri.args.names[*] in {"i" "src"})) and not lower(${PATH}) contains "%2b"`;
const pluses = n => `"${"*+".repeat(n)}*"`;
// A rule's condition sees the original path, and the free plan has no regex_replace and allows one wildcard_replace per
// rule, so each rule handles an exact number of +s; a query with more always runs the worker
const mine = hosts.length ? Array.from({ length: MAX_PLUSES + 1 }, (_, n) => ({
description: `${TAG} bare query with ${n} +`,
expression: `${BARE} and ${n ? `${PATH} wildcard ${pluses(n)}` : `not ${PATH} contains "+"`} and not ${PATH} wildcard ${pluses(n + 1)}`,
action: "rewrite",
action_parameters: { uri: { path: { expression: `concat("/", http.host, "/1", ${n ? `wildcard_replace(lower(${PATH}), ${pluses(n)}, "${Array.from({ length: n }, (_, k) => `\${${k + 1}}%2B`).join("")}\${${n + 1}}")` : `lower(${PATH})`})` } } },
})) : [];
const rules = {
// A rule's condition sees the original path, and the free plan has no regex_replace and allows one wildcard_replace
// per rule, so each rule handles an exact number of +s; a query with more always runs the worker. A missing param is
// empty, so a bare query is i-.
http_request_transform: hosts.length ? Array.from({ length: MAX_PLUSES + 1 }, (_, n) => ({
description: `${TAG} query with ${n} +`,
expression: `${FOLDABLE} and ${n ? `${PATH} wildcard ${pluses(n)}` : `not ${PATH} contains "+"`} and not ${PATH} wildcard ${pluses(n + 1)}`,
action: "rewrite",
action_parameters: { uri: { path: { expression: `concat("/", http.host, ${n ? `wildcard_replace(lower(${PATH}), ${pluses(n)}, "${Array.from({ length: n }, (_, k) => `\${${k + 1}}%2B`).join("")}\${${n + 1}}")` : `lower(${PATH})`}, "/i", ${ARG("i")}, "-", ${ARG("src")})` } } },
})) : [],
http_request_firewall_custom: hosts.length ? [{
description: `${TAG} log image requests`,
expression: IMAGE,
action: "skip",
action_parameters: { ruleset: "current" },
logging: { enabled: true },
}] : [],
};
const zone = (await api(`/zones?name=${ZONE}`)).result[0].id;
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;
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;
}