diff --git a/.github/workflows/sponsors-readme.yml b/.github/workflows/sponsors-readme.yml new file mode 100644 index 0000000..87102ef --- /dev/null +++ b/.github/workflows/sponsors-readme.yml @@ -0,0 +1,90 @@ +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. +# +# GitHub does not run workflows for pushes or PRs made with GITHUB_TOKEN, so +# the PR only gets its required checks when an AMNESIA_BOT_PAT secret is set. +# Without one, close and reopen the bot PR to start them. + +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: + # The push below runs as this token; see the header for why a PAT. + token: ${{ secrets.AMNESIA_BOT_PAT || secrets.GITHUB_TOKEN }} + 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.AMNESIA_BOT_PAT || 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)" + existing="$(gh pr list --head "$branch" --base main --state open --json number --jq '.[0].number // empty')" + # An open bot PR that already carries this block needs no new push: + # one would re-run its checks and reviews for the same README. + if [ -n "$existing" ] && git fetch --depth 1 origin "$branch" 2>/dev/null \ + && git diff --quiet FETCH_HEAD HEAD -- README.md; then + echo "#$existing already carries this block" + exit 0 + fi + # 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" + 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..0f3029e 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,12 @@ OpenSSF Scorecard OpenSSF Best Practices MIT license + Sponsor on GitHub

no accounts · no ads · no analytics · no query log · one ~45 KB HTML file · engine traffic only through a VPN · MIT -Use it · Run your own · Check it yourself · Who sees your query · How it's built · Reference +Use it · Run your own · Check it yourself · Who sees your query · How it's built · Reference · Sponsor @@ -100,6 +101,14 @@ Amnesia doesn't protect against a global adversary watching both ends, a comprom - **[Self-host](docs/self-host.md)**: the one-container image, its hardened run line and options, and the full production shape. - **[README assets](scripts/readme/README.md)**: how the pictures on this page are made, and which one is generated from a live run. +## Sponsor + + +amnesia has no ads and no tracking, so it is funded by its users through [GitHub Sponsors](https://github.com/sponsors/askalf): the VPN exit and the server behind the hosted instance are the running costs. Sponsors at $25/month and up are listed here. + + +This block comes from scripts/sponsors.mjs, which reads the public sponsor list; sponsors-readme.yml opens a PR when it changes (its checks run only with an AMNESIA_BOT_PAT secret; without one, close and reopen that PR). 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..68e459e --- /dev/null +++ b/scripts/sponsors.mjs @@ -0,0 +1,146 @@ +#!/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!, $cursor: String) { + user(login: $login) { + sponsorshipsAsMaintainer(first: 100, after: $cursor, activeOnly: true, includePrivate: false) { + pageInfo { hasNextPage endCursor } + 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})`; + +/** A sponsor's display name is theirs to set: render it as text, never as Markdown or HTML. */ +export function escapeMarkdownText(value) { + return value.replace(/\s+/g, ' ').replace(/[\\`*_{}[\]<>()#+\-.!|~&]/g, '\\$&'); +} + +/** 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 ? ` (${escapeMarkdownText(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 nodes = []; + let cursor = null; + for (;;) { + 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, cursor } }), + signal: AbortSignal.timeout(20_000), + }); + if (!res.ok) throw new Error(`GraphQL HTTP ${res.status}`); + const json = await res.json(); + const conn = json && json.data && json.data.user && json.data.user.sponsorshipsAsMaintainer; + if (!conn || !Array.isArray(conn.nodes)) throw new Error(`unexpected GraphQL shape: ${JSON.stringify(json).slice(0, 200)}`); + nodes.push(...conn.nodes); + const page = conn.pageInfo || {}; + if (!page.hasNextPage) break; + if (!page.endCursor || page.endCursor === cursor) throw new Error('GraphQL pagination did not advance'); + cursor = page.endCursor; + } + 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 @@
- +