diff --git a/README.md b/README.md index c4c02dd..c62a5fb 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,20 @@ A failed or rate-limited lookup keeps the known release links and labels the lookup as unavailable. Android's AAB is clearly labeled as a publishing bundle. Tester installations use the separate Google Play opt-in link. -`/status/` checks the public API `/health` and sync `/probes/readiness` endpoints -from the visitor's browser, without credentials. Requests time out after eight -seconds. Non-success responses and unhealthy payloads are shown as unavailable. -Network errors and timeouts are shown as uncertain, not confirmed outages. -Checks refresh every minute while the page is visible. There is no uptime history. -The shared edge proxy permits the website origin to read only GET `/health`. -API health confirms a response, not database or end-to-end account functionality. +`/status/` reads a cached `/status.json` snapshot. A systemd timer probes API +health and sync readiness once per minute, independently of site visitors. +There is no refresh button and browsers never probe the services. Snapshots +older than three minutes show Status unavailable. These are service health +checks, without historical uptime or end-to-end account checks. + +Install `deploy/collect-status.py` at `/srv/apps/papyrus-website/collect-status.py` +and the two `deploy/papyrus-status.*` units in `/etc/systemd/system/`. +Run `systemctl daemon-reload`, `systemctl start papyrus-status.service` and +`systemctl enable --now papyrus-status.timer`. The restricted monitor owns only +`/var/lib/papyrus-status`, which the website mounts read-only. Install +`deploy/Caddyfile` beside the website Compose file and apply that project with +`docker compose up -d --wait`. This one-time configuration update replaces only +the website container. Subsequent website releases still switch static files +without restarting services. `/status.json` is publicly cached for 30 seconds. Use no semicolons in website prose. diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..266eff5 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,12 @@ +:8080 { + @status path /status.json + handle @status { + root * /srv/status + header Cache-Control "public, max-age=30" + file_server + } + handle { + root * /srv/site/current + file_server + } +} diff --git a/deploy/collect-status.py b/deploy/collect-status.py new file mode 100644 index 0000000..60910e7 --- /dev/null +++ b/deploy/collect-status.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Publish one shared status snapshot for the public website.""" +import json +import os +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +from pathlib import Path +from urllib.request import Request, urlopen + +ENDPOINTS = { + "api": "https://api.papyrus-reader.com/health", + "sync": "https://sync.papyrus-reader.com/probes/readiness", +} + + +def check_service(service, opener=urlopen): + try: + request = Request(ENDPOINTS[service], headers={"User-Agent": "PapyrusStatus/1.0", "Cache-Control": "no-cache"}) + with opener(request, timeout=8) as response: + data = json.loads(response.read(65536)) + healthy = data.get("status") == "healthy" if service == "api" else data.get("ready") is True and data.get("started") is True + return "operational" if response.status == 200 and healthy else "disrupted" + except Exception: + return "disrupted" + + +def collect(destination): + with ThreadPoolExecutor(max_workers=2) as pool: + states = dict(zip(ENDPOINTS, pool.map(check_service, ENDPOINTS))) + snapshot = {"checked_at": datetime.now(timezone.utc).isoformat(), "services": states} + destination = Path(destination) + temporary = destination.with_suffix(".tmp") + temporary.write_text(json.dumps(snapshot) + "\n") + temporary.chmod(0o644) + os.replace(temporary, destination) + + +if __name__ == "__main__": + collect("/var/lib/papyrus-status/status.json") diff --git a/deploy/compose.yml b/deploy/compose.yml index a5ed17d..5c49ff8 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -4,9 +4,10 @@ services: website: image: caddy:2.10.2-alpine restart: unless-stopped - command: ['caddy', 'file-server', '--listen', ':8080', '--root', '/srv/site/current'] volumes: - ./site:/srv/site:ro + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - /var/lib/papyrus-status:/srv/status:ro networks: edge: aliases: [papyrus-website] diff --git a/deploy/papyrus-status.service b/deploy/papyrus-status.service new file mode 100644 index 0000000..734fcf5 --- /dev/null +++ b/deploy/papyrus-status.service @@ -0,0 +1,17 @@ +[Unit] +Description=Update the public Papyrus service status +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +DynamicUser=yes +StateDirectory=papyrus-status +StateDirectoryMode=0755 +ExecStart=/usr/bin/python3 /srv/apps/papyrus-website/collect-status.py +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +NoNewPrivileges=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +TimeoutStartSec=25 diff --git a/deploy/papyrus-status.timer b/deploy/papyrus-status.timer new file mode 100644 index 0000000..3d6640d --- /dev/null +++ b/deploy/papyrus-status.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Check Papyrus services once a minute + +[Timer] +OnBootSec=15s +OnUnitActiveSec=60s +AccuracySec=1s + +[Install] +WantedBy=timers.target diff --git a/js/services.js b/js/services.js index f652b2b..dc09216 100644 --- a/js/services.js +++ b/js/services.js @@ -25,23 +25,20 @@ if (!Object.keys(links).length) throw new Error('No release downloads'); return { version: release.tag_name.slice(1), notes: `${repository}/releases/tag/${encodeURIComponent(release.tag_name)}`, links }; } - async function checkService(url, service, fetcher = fetch, timeout = 8000) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeout); - const start = performance.now(); - try { - const response = await fetcher(url, { cache: 'no-store', credentials: 'omit', signal: controller.signal }); - if (!response.ok) return { state: 'unavailable', message: `HTTP ${response.status}` }; - const data = await response.json(); - const healthy = service === 'api' ? data.status === 'healthy' : data.ready === true && data.started === true; - return { state: healthy ? 'healthy' : 'unavailable', message: healthy ? `${Math.round(performance.now() - start)} ms` : 'Health check did not pass' }; - } catch (error) { - return { state: 'unknown', message: controller.signal.aborted ? 'Check timed out' : 'Could not check from this browser' }; - } finally { - clearTimeout(timer); + function serviceStatus(snapshot, now = Date.now()) { + const checkedAt = Date.parse(snapshot?.checked_at); + if (!Number.isFinite(checkedAt) || checkedAt > now + 60000 || now - checkedAt > 180000) { + throw new Error('Status snapshot is stale or invalid'); } + const states = {}; + for (const service of ['api', 'sync']) { + const state = snapshot.services?.[service]; + if (!['operational', 'disrupted'].includes(state)) throw new Error('Invalid service status'); + states[service] = state; + } + return { checkedAt, states }; } - const api = { releaseDownloads, checkService }; + const api = { releaseDownloads, serviceStatus }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.PapyrusSite = api; })(globalThis); diff --git a/js/status.js b/js/status.js index e7008df..2e8e876 100644 --- a/js/status.js +++ b/js/status.js @@ -1,38 +1,35 @@ (() => { 'use strict'; - const endpoints = { api: 'https://api.papyrus-reader.com/health', sync: 'https://sync.papyrus-reader.com/probes/readiness' }; - const labels = { healthy: 'Responding', unavailable: 'Unavailable', unknown: 'Unable to check' }; - const button = document.getElementById('refresh-status'); + const labels = { operational: 'Operational', disrupted: 'Service disruption', unknown: 'Status unavailable' }; const summary = document.getElementById('status-summary'); + const updated = document.getElementById('status-updated'); let running = false; - let timer; async function refresh() { if (running || document.hidden) return; - clearTimeout(timer); running = true; - button.disabled = true; - summary.textContent = 'Checking services…'; - const results = await Promise.all(Object.entries(endpoints).map(async ([service, url]) => { - const card = document.querySelector(`[data-service="${service}"]`); - const badge = card.querySelector('.service-state'); - badge.textContent = 'Checking…'; - badge.dataset.state = 'unknown'; - const result = await window.PapyrusSite.checkService(url, service); - badge.textContent = labels[result.state]; - badge.dataset.state = result.state; - card.querySelector('.service-detail').textContent = `${result.message} · Checked ${new Date().toLocaleTimeString()}`; - return result; - })); - summary.textContent = results.every(result => result.state === 'healthy') ? 'All services responding' : 'Some checks need attention'; - button.disabled = false; - running = false; - timer = setTimeout(refresh, 60000); + try { + const response = await fetch('/status.json', { credentials: 'omit', signal: AbortSignal.timeout(8000) }); + if (!response.ok) throw new Error('Status unavailable'); + const result = window.PapyrusSite.serviceStatus(await response.json()); + for (const [service, state] of Object.entries(result.states)) { + const badge = document.querySelector(`[data-service="${service}"] .service-state`); + badge.textContent = labels[state]; + badge.dataset.state = state; + } + summary.textContent = Object.values(result.states).every(state => state === 'operational') ? 'All systems operational' : 'Service disruption'; + updated.textContent = `Updated ${new Date(result.checkedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; + } catch { + summary.textContent = 'Status unavailable'; + updated.textContent = ''; + for (const badge of document.querySelectorAll('.service-state')) { + badge.textContent = labels.unknown; + badge.dataset.state = 'unknown'; + } + } finally { + running = false; + } } - button.hidden = false; - button.addEventListener('click', refresh); - document.addEventListener('visibilitychange', () => { - clearTimeout(timer); - if (!document.hidden) refresh(); - }); + // Visitors read a cached snapshot. Only the scheduled monitor probes services. refresh(); + setInterval(refresh, 60000); })(); diff --git a/js/tests/services.test.cjs b/js/tests/services.test.cjs index 9a4fea1..5d793e1 100644 --- a/js/tests/services.test.cjs +++ b/js/tests/services.test.cjs @@ -1,6 +1,6 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); -const { releaseDownloads, checkService } = require('../services.js'); +const { releaseDownloads, serviceStatus } = require('../services.js'); function release() { return { tag_name: 'v0.0.3+4', draft: false, prerelease: false, assets: [ { name: 'papyrus-v0.0.3+4-windows-x64-setup.exe', browser_download_url: 'https://github.com/PapyrusReader/client/releases/download/v0.0.3%2B4/papyrus-v0.0.3%2B4-windows-x64-setup.exe' }, @@ -20,23 +20,12 @@ test('drafts, malformed releases and unexpected download destinations are reject value.assets[0].browser_download_url = 'https://example.com/installer.exe'; assert.throws(() => releaseDownloads(value)); }); -test('health checks require valid response content and never send credentials', async () => { - const fetcher = data => async (url, options) => { - assert.equal(options.credentials, 'omit'); - assert.equal(options.cache, 'no-store'); - return { ok: true, json: async () => data }; - }; - assert.equal((await checkService('https://example.com', 'api', fetcher({ status: 'healthy' }))).state, 'healthy'); - assert.equal((await checkService('https://example.com', 'sync', fetcher({ ready: true, started: true }))).state, 'healthy'); - assert.equal((await checkService('https://example.com', 'sync', fetcher({ ready: false, started: true }))).state, 'unavailable'); - assert.equal((await checkService('https://example.com', 'api', fetcher({}))).state, 'unavailable'); -}); -test('service failures are distinguished from network and timeout uncertainty', async () => { - assert.equal((await checkService('', 'api', async () => ({ ok: false, status: 503 }))).state, 'unavailable'); - assert.equal((await checkService('', 'api', async () => { throw new Error('offline'); })).state, 'unknown'); - const result = await checkService('', 'api', (_, options) => new Promise((resolve, reject) => { - options.signal.addEventListener('abort', () => reject(new Error('aborted'))); - }), 5); - assert.equal(result.state, 'unknown'); - assert.equal(result.message, 'Check timed out'); +test('status snapshots report monitored results and reject stale or incomplete data', () => { + const now = Date.now(); + const snapshot = { checked_at: new Date(now).toISOString(), services: { api: 'operational', sync: 'disrupted' } }; + assert.deepEqual(serviceStatus(snapshot, now).states, snapshot.services); + assert.throws(() => serviceStatus(snapshot, now + 180001)); + assert.throws(() => serviceStatus(snapshot, now - 60001)); + assert.throws(() => serviceStatus({ ...snapshot, checked_at: 'invalid' }, now)); + assert.throws(() => serviceStatus({ ...snapshot, services: { api: 'operational' } }, now)); }); diff --git a/package-lock.json b/package-lock.json index 395065e..44526b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "papyrus-landing", - "version": "1.1.3", + "version": "1.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "papyrus-landing", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "serve": "^14.2.5" }, diff --git a/package.json b/package.json index 0b29fa4..393ab01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "papyrus-landing", - "version": "1.1.3", + "version": "1.1.4", "private": true, "scripts": { "clean": "rm -rf build", diff --git a/scripts/tests/test_status_collector.py b/scripts/tests/test_status_collector.py new file mode 100644 index 0000000..d5c5a83 --- /dev/null +++ b/scripts/tests/test_status_collector.py @@ -0,0 +1,53 @@ +"""Status checks are scheduled independently of visitor requests.""" +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("status_collector", Path(__file__).parents[2] / "deploy/collect-status.py") +collector = importlib.util.module_from_spec(spec) +spec.loader.exec_module(collector) + + +class Response: + status = 200 + + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def read(self, limit): + return json.dumps(self.payload).encode() + + +class StatusCollectorTest(unittest.TestCase): + def test_health_payloads_and_connection_failures(self): + self.assertEqual(collector.check_service("api", lambda *a, **k: Response({"status": "healthy"})), "operational") + self.assertEqual(collector.check_service("sync", lambda *a, **k: Response({"ready": True, "started": True})), "operational") + self.assertEqual(collector.check_service("sync", lambda *a, **k: Response({"ready": False, "started": True})), "disrupted") + self.assertEqual(collector.check_service("api", lambda *a, **k: Response({})), "disrupted") + with patch.object(collector, "urlopen", side_effect=TimeoutError): + self.assertEqual(collector.check_service("api", collector.urlopen), "disrupted") + + def test_atomic_snapshot_contains_only_public_state(self): + with tempfile.TemporaryDirectory() as directory: + destination = Path(directory) / "status.json" + destination.write_text('old snapshot') + with patch.object(collector, "check_service", side_effect=lambda service: "operational") as check: + collector.collect(destination) + self.assertEqual(check.call_count, 2) + snapshot = json.loads(destination.read_text()) + self.assertEqual(set(snapshot), {"checked_at", "services"}) + self.assertEqual(snapshot["services"], {"api": "operational", "sync": "operational"}) + self.assertFalse(destination.with_suffix('.tmp').exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scss/_site.scss b/scss/_site.scss index 6b9ce4f..4d1e485 100644 --- a/scss/_site.scss +++ b/scss/_site.scss @@ -138,8 +138,8 @@ h3 { font-size: 25px; letter-spacing: -.025em; } .service-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; } .service-heading h2 { font-size: 26px; } .service-state { font-size: 14px; padding: 5px 12px; border: 1px solid var(--border); border-radius: 20px; } -.service-state[data-state='healthy'] { color: var(--accent); } -.service-state[data-state='unavailable'] { font-weight: 700; } +.service-state[data-state='operational'] { color: var(--accent); } +.service-state[data-state='disrupted'] { font-weight: 700; } .service-card > a { display: inline-block; margin-top: 20px; overflow-wrap: anywhere; } .service-detail { font-size: 14px; } button:disabled { opacity: .65; cursor: wait; } diff --git a/status/index.html b/status/index.html index ce8a94b..f30d61f 100644 --- a/status/index.html +++ b/status/index.html @@ -58,16 +58,14 @@
-

Papyrus services

Service status

-

Check the services that keep your account and library in sync. Offline reading stays available on your device.

-

Waiting for a check

- +

Loading status…

+
- - +

API

Loading…

api.papyrus-reader.com

+

Sync

Loading…

sync.papyrus-reader.com

-

Checks run from your browser every minute while this page is visible. A connection issue can affect your results. This page shows current responses, not uptime history.

+