mirror of
https://github.com/direct-img/direct-img.link.git
synced 2026-10-29 07:24:52 +00:00
Fix: Serve promoted images with a + without redirecting
Rule conditions see the original path, so chained rules can't encode +s. One rule per number of +s does it instead, which only fits the free plan's 10 rules if they're shared by all sites, so static files now live under their hostname (/free.direct-img.link/1/orange%2Bcat). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+3
-4
@@ -31,11 +31,10 @@ if (r.workers) {
|
||||
}
|
||||
if (r.workersError) lines.push(`**Worker requests:** couldn't read (${esc(r.workersError)})`);
|
||||
|
||||
// web/2/orange+cat → [orange cat](https://direct-img.link/orange+cat?i=2), so each opens on its site
|
||||
const zone = env.ZONE || "direct-img.link";
|
||||
// free.direct-img.link/1/orange+cat → [orange cat](https://free.direct-img.link/orange+cat) (free), so each opens on its site
|
||||
const link = id => {
|
||||
const [f, i, name] = id.split("/");
|
||||
return `[${esc(name.replaceAll("+", " "))}](https://${f === "web" ? "" : `${f}.`}${zone}/${name}${i > 1 ? `?i=${i}` : ""})${i > 1 ? ` i=${i}` : ""}${f === "web" ? "" : ` (${f})`}`;
|
||||
const [host, , name] = id.split("/"), site = host.split(".").length > 2 && host.split(".")[0];
|
||||
return `[${esc(name.replaceAll("+", " "))}](https://${host}/${name})${site ? ` (${site})` : ""}`;
|
||||
};
|
||||
|
||||
const failed = env.STATUS !== "success";
|
||||
|
||||
+5
-5
@@ -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, siteOf, folder, normalizeQuery, cacheKey, r2Key } from "../src/_utils/keys.js";
|
||||
import { TTL_SECONDS, MAX_PLUSES, siteOf, normalizeQuery, cacheKey, r2Key } from "../src/_utils/keys.js";
|
||||
import { parseFreeSource } from "../src/_utils/free.js";
|
||||
import { parseAiSource } from "../src/_utils/ai.js";
|
||||
|
||||
@@ -46,9 +46,9 @@ async function parse(host, path) {
|
||||
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;
|
||||
if (BAD_NAME.test(query) || Buffer.byteLength(name) > 200 || name.split("+").length > MAX_PLUSES + 1) return null;
|
||||
const src = site === "free" ? parseFreeSource(new URLSearchParams()) : site === "ai" ? parseAiSource(new URLSearchParams()) : null;
|
||||
return { id: `${folder(site)}/1/${name}`, r2: await r2Key(site, cacheKey(site, src, 1, query)) };
|
||||
return { id: `${host}/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
|
||||
@@ -150,8 +150,8 @@ for (let k = 0; k < ranked.length; k += 4) {
|
||||
if (got.some(g => !g.cached)) await sleep(1200 - (Date.now() - started));
|
||||
}
|
||||
|
||||
// public/<folder>/ holds only this run's images, hard-linked from the cache so they aren't stored twice
|
||||
for (const f of ["web", "free", "ai"]) await rm(`${OUT}/${f}`, { recursive: true, force: true });
|
||||
// public/<hostname>/ holds only this run's images, hard-linked from the cache so they aren't stored twice
|
||||
for (const h of HOSTS) await rm(`${OUT}/${h}`, { recursive: true, force: true });
|
||||
for (const id of Object.keys(chosen)) {
|
||||
await mkdir(dirname(`${OUT}/${id}`), { recursive: true });
|
||||
await link(`${CACHE}/files/${id}`, `${OUT}/${id}`);
|
||||
|
||||
+15
-13
@@ -1,25 +1,27 @@
|
||||
// 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.
|
||||
// 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.
|
||||
// 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.
|
||||
import { siteOf, folder } from "../src/_utils/keys.js";
|
||||
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";
|
||||
const PHASE = "http_request_transform", PATH = "http.request.uri.path";
|
||||
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.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 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"`;
|
||||
const pluses = n => `"${"*+".repeat(n)}*"`;
|
||||
|
||||
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}`,
|
||||
// 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("/${f}/1", ${path})` } } },
|
||||
}));
|
||||
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 zone = (await api(`/zones?name=${ZONE}`)).result[0].id;
|
||||
const others = ((await api(`/zones/${zone}/rulesets/phases/${PHASE}/entrypoint`)).result?.rules || [])
|
||||
|
||||
Reference in New Issue
Block a user