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
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 12 additions & 0 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -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
}
}
39 changes: 39 additions & 0 deletions deploy/collect-status.py
Original file line number Diff line number Diff line change
@@ -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")
3 changes: 2 additions & 1 deletion deploy/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
17 changes: 17 additions & 0 deletions deploy/papyrus-status.service
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions deploy/papyrus-status.timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[Unit]
Description=Check Papyrus services once a minute

[Timer]
OnBootSec=15s
OnUnitActiveSec=60s
AccuracySec=1s

[Install]
WantedBy=timers.target
27 changes: 12 additions & 15 deletions js/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
53 changes: 25 additions & 28 deletions js/status.js
Original file line number Diff line number Diff line change
@@ -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);
})();
29 changes: 9 additions & 20 deletions js/tests/services.test.cjs
Original file line number Diff line number Diff line change
@@ -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' },
Expand All @@ -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));
});
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "papyrus-landing",
"version": "1.1.3",
"version": "1.1.4",
"private": true,
"scripts": {
"clean": "rm -rf build",
Expand Down
53 changes: 53 additions & 0 deletions scripts/tests/test_status_collector.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 2 additions & 2 deletions scss/_site.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
12 changes: 5 additions & 7 deletions status/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,14 @@
</nav>
</header>
<main id="main" class="container status-page">
<p class="eyebrow">Papyrus services</p>
<h1>Service status</h1>
<p class="hero-description">Check the services that keep your account and library in sync. Offline reading stays available on your device.</p>
<div class="status-summary"><h2 id="status-summary" aria-live="polite">Waiting for a check</h2><button class="button button-primary" id="refresh-status" type="button" hidden>Check again</button></div>
<noscript><p>Enable JavaScript to run live checks, or open either health endpoint below.</p></noscript>
<div class="status-summary"><h2 id="status-summary" aria-live="polite">Loading status…</h2></div>
<noscript><p>Enable JavaScript to view service status.</p></noscript>
<div class="status-grid">
<article class="service-card" data-service="api"><div class="service-heading"><h2>API</h2><span class="service-state" data-state="unknown" aria-live="polite">Not checked</span></div><p>Account and library requests. This check confirms that the API responds.</p><a href="https://api.papyrus-reader.com/health">api.papyrus-reader.com ↗</a><p class="service-detail">No check yet</p></article>
<article class="service-card" data-service="sync"><div class="service-heading"><h2>Sync</h2><span class="service-state" data-state="unknown" aria-live="polite">Not checked</span></div><p>Library synchronization. This check confirms that the sync service is ready.</p><a href="https://sync.papyrus-reader.com/probes/readiness">sync.papyrus-reader.com ↗</a><p class="service-detail">No check yet</p></article>
<article class="service-card" data-service="api"><div class="service-heading"><h2>API</h2><span class="service-state" data-state="unknown" aria-live="polite">Loading…</span></div><p>api.papyrus-reader.com</p></article>
<article class="service-card" data-service="sync"><div class="service-heading"><h2>Sync</h2><span class="service-state" data-state="unknown" aria-live="polite">Loading…</span></div><p>sync.papyrus-reader.com</p></article>
</div>
<p class="status-note">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.</p>
<p class="status-note" id="status-updated"></p>
</main>
<footer class="site-footer container">
<a class="footer-home" href="/">Papyrus</a>
Expand Down
Loading