From d085474c298bec78ba14ca8457b71cb7b72641b1 Mon Sep 17 00:00:00 2001
From: Thomas Sprayberry <263217947+askalf@users.noreply.github.com>
Date: Fri, 25 Sep 2026 02:10:13 +0000
Subject: [PATCH 1/5] Sponsors: footer link, README section and a daily
sponsor-list sync
The page footers say "no ads, funded by sponsors" and link to GitHub
Sponsors. The README gains a Sponsor section and badge; its block is
rebuilt by scripts/sponsors.mjs from the public sponsor list, and
sponsors-readme.yml runs it daily and opens a PR when it changes.
---
.github/workflows/sponsors-readme.yml | 77 +++++++++++++++
CHANGELOG.md | 6 ++
README.md | 11 ++-
scripts/sponsors.mjs | 131 ++++++++++++++++++++++++++
src/_headers | 2 +-
src/amnesia-search.html | 4 +-
test/sponsors.test.mjs | 49 ++++++++++
7 files changed, 276 insertions(+), 4 deletions(-)
create mode 100644 .github/workflows/sponsors-readme.yml
create mode 100644 scripts/sponsors.mjs
create mode 100644 test/sponsors.test.mjs
diff --git a/.github/workflows/sponsors-readme.yml b/.github/workflows/sponsors-readme.yml
new file mode 100644
index 0000000..17a510a
--- /dev/null
+++ b/.github/workflows/sponsors-readme.yml
@@ -0,0 +1,77 @@
+name: Sponsors README sync
+
+# The $25+ GitHub Sponsors tiers promise a line in the README. Once a day,
+# rebuild the marked block from the public sponsor list (scripts/sponsors.mjs)
+# and open a PR when it changed. A PR, not a push: README changes go through
+# review like everything else. Private sponsors are never named.
+
+on:
+ schedule:
+ # 06:53 UTC daily, off the hour.
+ - cron: '53 6 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sponsors-readme
+ cancel-in-progress: false
+
+jobs:
+ sync:
+ if: github.repository == 'askalf/amnesia'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: true
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 22
+
+ - name: Rebuild the README sponsors block
+ id: sync
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ # --write exits 1 when the sponsor list could not be read, so a
+ # broken read never becomes an empty list in a PR.
+ node scripts/sponsors.mjs --write
+ if git diff --quiet -- README.md; then
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ echo "README sponsors block unchanged."
+ else
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ git diff -- README.md
+ fi
+
+ - name: Open or refresh the PR
+ if: steps.sync.outputs.changed == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ branch="bot/sponsors-readme"
+ git config user.email "actions@github.com"
+ git config user.name "sponsors-readme[bot]"
+ git checkout -B "$branch"
+ git add README.md
+ git commit -m "README: sponsors block refreshed $(date -u +%Y-%m-%dT%H:%MZ)"
+ # One long-lived branch, force-pushed: the block is derived state,
+ # so the newest run is the only one worth reviewing.
+ git push --force origin "$branch"
+ existing="$(gh pr list --head "$branch" --base main --state open --json number --jq '.[0].number // empty')"
+ if [ -n "$existing" ]; then
+ echo "refreshed #$existing"
+ exit 0
+ fi
+ # shellcheck disable=SC2016
+ gh pr create --head "$branch" --base main \
+ --title "README: sponsors block refreshed" \
+ --body 'The public sponsor list changed; this is the README block rebuilt from it by `scripts/sponsors.mjs`. Private sponsors are never named.'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14c4a6d..90bc3fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- **Sponsors.** The page footers link to GitHub Sponsors ("no ads, funded by
+ sponsors"): a plain link, no script or request, and `Referrer-Policy:
+ same-origin` means GitHub is not told the visitor came from amnesia. The
+ README gains a Sponsor section whose block `scripts/sponsors.mjs` rebuilds
+ from the public sponsor list; `sponsors-readme.yml` runs it daily and opens a
+ PR when it changes. Private sponsors are never named.
- **Latency benchmarks, `scripts/bench/`.** `live.mjs` times a cold page load,
a warm reload, a search and a repeat search (edge-cache hit) in a real
browser against amnesia.tax or a self-host. `engines.mjs` runs the pinned
diff --git a/README.md b/README.md
index 599e8dd..508e349 100644
--- a/README.md
+++ b/README.md
@@ -16,11 +16,12 @@
+
scripts/sponsors.mjs, which reads the public sponsor list; sponsors-readme.yml opens a PR when it changes. Private sponsors are never named.
+
## Project
- [`CHANGELOG.md`](CHANGELOG.md): what changed and why
diff --git a/scripts/sponsors.mjs b/scripts/sponsors.mjs
new file mode 100644
index 0000000..0e972aa
--- /dev/null
+++ b/scripts/sponsors.mjs
@@ -0,0 +1,131 @@
+#!/usr/bin/env node
+/**
+ * The README's sponsors block, rebuilt from the public sponsor list.
+ *
+ * The GitHub Sponsors tiers on github.com/sponsors/askalf promise $25+
+ * sponsors a line in the README. This keeps that promise without anyone
+ * remembering to (same query and rules as dario's scripts/sponsors.mjs):
+ *
+ * node scripts/sponsors.mjs print the block
+ * node scripts/sponsors.mjs --write rewrite the README block in place
+ *
+ * Reads the maintainer's ACTIVE, PUBLIC sponsorships through the GraphQL
+ * API (`GH_TOKEN` / `GITHUB_TOKEN`, or `gh auth token`). Private sponsors
+ * are never named: the query does not ask for them. `--write` exits 1 when
+ * the list could not be read, so a failed read never empties the block.
+ */
+
+import { readFileSync, writeFileSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { resolve } from 'node:path';
+
+export const MAINTAINER = 'askalf';
+export const README_START = '';
+export const README_END = '';
+/** Tiers at or above this monthly amount promised a README line. */
+export const README_TIER_MIN_USD = 25;
+
+const SPONSOR_URL = `https://github.com/sponsors/${MAINTAINER}`;
+
+const QUERY = `query($login: String!) {
+ user(login: $login) {
+ sponsorshipsAsMaintainer(first: 100, activeOnly: true, includePrivate: false) {
+ nodes {
+ isOneTimePayment
+ tier { monthlyPriceInDollars isOneTime }
+ sponsorEntity {
+ __typename
+ ... on User { login name }
+ ... on Organization { login name }
+ }
+ }
+ }
+ }
+}`;
+
+/** Sponsorship nodes → { login, name, monthly, oneTime }, highest tier first. Nodes without a login are dropped. */
+export function normalizeSponsors(nodes) {
+ const out = [];
+ for (const n of Array.isArray(nodes) ? nodes : []) {
+ const e = n && n.sponsorEntity;
+ if (!e || typeof e.login !== 'string' || e.login.length === 0) continue;
+ const monthly = n.tier && typeof n.tier.monthlyPriceInDollars === 'number' ? n.tier.monthlyPriceInDollars : 0;
+ const oneTime = Boolean(n.isOneTimePayment || (n.tier && n.tier.isOneTime));
+ out.push({ login: e.login, name: typeof e.name === 'string' && e.name.trim() ? e.name.trim() : null, monthly, oneTime });
+ }
+ return out.sort((a, b) => (b.monthly - a.monthly) || a.login.localeCompare(b.login));
+}
+
+const mention = (s) => `[@${s.login}](https://github.com/${s.login})`;
+
+/** The block, markers included: the $25+ monthly sponsors, or one sentence when there are none yet. */
+export function renderReadmeBlock(sponsors) {
+ const named = sponsors.filter((s) => !s.oneTime && s.monthly >= README_TIER_MIN_USD);
+ const lines = [README_START];
+ if (named.length === 0) {
+ lines.push(`amnesia has no ads and no tracking, so it is funded by its users through [GitHub Sponsors](${SPONSOR_URL}): the VPN exit and the server behind the hosted instance are the running costs. Sponsors at $${README_TIER_MIN_USD}/month and up are listed here.`);
+ } else {
+ lines.push(`amnesia has no ads and no tracking, so it is funded by its users through [GitHub Sponsors](${SPONSOR_URL}). Thank you:`);
+ lines.push('');
+ for (const s of named) lines.push(`- ${mention(s)}${s.name ? ` (${s.name})` : ''}`);
+ }
+ lines.push(README_END);
+ return lines.join('\n');
+}
+
+/** Replace the marked block in README text; throws when the markers are missing. */
+export function replaceReadmeBlock(readme, block) {
+ const a = readme.indexOf(README_START);
+ const b = readme.indexOf(README_END);
+ if (a === -1 || b === -1 || b < a) throw new Error(`README is missing the ${README_START} … ${README_END} markers`);
+ return readme.slice(0, a) + block + readme.slice(b + README_END.length);
+}
+
+function token() {
+ const env = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
+ if (env) return env;
+ try { return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim(); } catch { return null; }
+}
+
+export async function fetchSponsors(login = MAINTAINER, fetchImpl = fetch) {
+ const t = token();
+ if (!t) throw new Error('no GitHub token (GH_TOKEN / GITHUB_TOKEN / gh auth)');
+ const res = await fetchImpl('https://api.github.com/graphql', {
+ method: 'POST',
+ headers: { authorization: `bearer ${t}`, 'content-type': 'application/json', 'user-agent': 'amnesia-sponsors' },
+ body: JSON.stringify({ query: QUERY, variables: { login } }),
+ signal: AbortSignal.timeout(20_000),
+ });
+ if (!res.ok) throw new Error(`GraphQL HTTP ${res.status}`);
+ const json = await res.json();
+ const nodes = json && json.data && json.data.user && json.data.user.sponsorshipsAsMaintainer && json.data.user.sponsorshipsAsMaintainer.nodes;
+ if (!Array.isArray(nodes)) throw new Error(`unexpected GraphQL shape: ${JSON.stringify(json).slice(0, 200)}`);
+ return normalizeSponsors(nodes);
+}
+
+async function main() {
+ const write = process.argv.includes('--write');
+ let sponsors;
+ try {
+ sponsors = await fetchSponsors();
+ } catch (err) {
+ console.error(`sponsors: ${err.message}`);
+ process.exit(1);
+ }
+ const block = renderReadmeBlock(sponsors);
+ if (!write) { process.stdout.write(block + '\n'); return; }
+ const path = resolve(fileURLToPath(new URL('../README.md', import.meta.url)));
+ const before = readFileSync(path, 'utf8');
+ const after = replaceReadmeBlock(before, block);
+ if (after !== before) { writeFileSync(path, after); console.error('sponsors: README block updated'); }
+ else console.error('sponsors: README block unchanged');
+}
+
+function isMainModule() {
+ if (!process.argv[1]) return false;
+ try { return resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); }
+ catch { return false; }
+}
+
+if (isMainModule()) await main();
diff --git a/src/_headers b/src/_headers
index 6eb4c1f..16548b6 100644
--- a/src/_headers
+++ b/src/_headers
@@ -17,7 +17,7 @@
Referrer-Policy: same-origin
Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=(), usb=(), interest-cohort=()
Cross-Origin-Opener-Policy: same-origin
- Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-FwcfzpimHQ8qwmqCJidXPMNsigJU29gxwIQkN5Ue4zQ=' https://challenges.cloudflare.com; style-src 'self' 'sha256-kf65YGE17RKwx+m5uoIbUiZlrtKF1ViXiqCopVFmKx0='; font-src 'self'; img-src 'self' data: https:; connect-src 'self' https://api.amnesia.tax https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests
+ Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-BJvW23fnCte2LiPWCpnKL7J20gf/WIjAzcsBMokkqrQ=' https://challenges.cloudflare.com; style-src 'self' 'sha256-kf65YGE17RKwx+m5uoIbUiZlrtKF1ViXiqCopVFmKx0='; font-src 'self'; img-src 'self' data: https:; connect-src 'self' https://api.amnesia.tax https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests
# The SPA only changes on deploy, and Pages purges its cache then — let the
# edge serve it without an origin revalidation per request (was max-age=0).
diff --git a/src/amnesia-search.html b/src/amnesia-search.html
index 9471c82..a4ee912 100644
--- a/src/amnesia-search.html
+++ b/src/amnesia-search.html
@@ -931,7 +931,7 @@
-
+