Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/workflows/sponsors-readme.yml
Original file line number Diff line number Diff line change
@@ -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.'
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
<a href="https://scorecard.dev/viewer/?uri=github.com/askalf/amnesia"><img src="https://api.scorecard.dev/projects/github.com/askalf/amnesia/badge" alt="OpenSSF Scorecard"></a>
<a href="https://www.bestpractices.dev/projects/14490"><img src="https://www.bestpractices.dev/projects/14490/badge" alt="OpenSSF Best Practices"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-8b5cf6" alt="MIT license"></a>
<a href="https://github.com/sponsors/askalf"><img src="https://img.shields.io/badge/sponsor-askalf-8b5cf6?logo=githubsponsors&logoColor=white" alt="Sponsor on GitHub"></a>
</p>

<sub>no accounts · no ads · no analytics · no query log · one ~45 KB HTML file · engine traffic only through a VPN · MIT</sub>

<sub><a href="#use-it">Use it</a> · <a href="#run-your-own">Run your own</a> · <a href="#check-it-yourself">Check it yourself</a> · <a href="#who-sees-your-query">Who sees your query</a> · <a href="#how-its-built">How it's built</a> · <a href="#reference">Reference</a></sub>
<sub><a href="#use-it">Use it</a> · <a href="#run-your-own">Run your own</a> · <a href="#check-it-yourself">Check it yourself</a> · <a href="#who-sees-your-query">Who sees your query</a> · <a href="#how-its-built">How it's built</a> · <a href="#reference">Reference</a> · <a href="#sponsor">Sponsor</a></sub>

</div>

Expand Down Expand Up @@ -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

<!-- sponsors:start -->
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.
<!-- sponsors:end -->

<sub>This block comes from <a href="scripts/sponsors.mjs"><code>scripts/sponsors.mjs</code></a>, which reads the public sponsor list; <code>sponsors-readme.yml</code> opens a PR when it changes (its checks run only with an <code>AMNESIA_BOT_PAT</code> secret; without one, close and reopen that PR). Private sponsors are never named.</sub>

## Project

- [`CHANGELOG.md`](CHANGELOG.md): what changed and why
Expand Down
146 changes: 146 additions & 0 deletions scripts/sponsors.mjs
Original file line number Diff line number Diff line change
@@ -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 = '<!-- sponsors:start -->';
export const README_END = '<!-- sponsors: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();
2 changes: 1 addition & 1 deletion src/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions src/amnesia-search.html
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,7 @@
</div>

<div id="content"></div>
<div class="landing-footer" id="landingFooter">built by <a href="https://sprayberrylabs.com">sprayberrylabs</a></div>
<div class="landing-footer" id="landingFooter">no ads, funded by <a href="https://github.com/sponsors/askalf">sponsors</a> &middot; built by <a href="https://sprayberrylabs.com">sprayberrylabs</a></div>
</div>

<script>
Expand Down Expand Up @@ -1356,7 +1356,7 @@
html += '<button data-action="page" data-page="' + (currentPage + 1) + '" ' + (results.length === 0 ? 'disabled' : '') + '>next &rarr;</button>';
html += '</div>';

html += '<div class="footer">amnesia.tax &mdash; built by <a href="https://sprayberrylabs.com">sprayberrylabs</a></div>';
html += '<div class="footer">amnesia.tax &mdash; no ads, funded by <a href="https://github.com/sponsors/askalf">sponsors</a> &middot; built by <a href="https://sprayberrylabs.com">sprayberrylabs</a></div>';

content.innerHTML = html;
window.scrollTo(0, 0);
Expand Down
Loading
Loading