Commit graph

436 commits

Author SHA1 Message Date
Adam Brown
8fcc067446
Add a Google Analytics provider (#643)
Adds GA4 as a second web-analytics provider alongside Umami, selected via
analytics.type = "google" with an [analytics.google] measurementId block.

GA4 can fall back to tracking-pixel loads, so the AnalyticsProvider
interface gains imgSrcHosts() (empty for Umami) and the CSP builder folds
it into img-src. Download-click events flow through the existing neutral
data-track-* / hammerTrack bridge unchanged.
2026-06-23 20:48:56 -07:00
Adam Brown
7a897ba423
New Crowdin updates (#639)
* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Chinese Simplified)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Chinese Simplified)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]
2026-06-23 16:36:20 -07:00
Adam Brown
a42aae7a25
Add provider-agnostic download-link click tracking (#642)
Track download-button clicks on the home page through the analytics
provider abstraction so a future provider (e.g. Google Analytics) works
without touching templates.

Templates declare neutral data-track-* attributes; a shared binder
(analytics.js) forwards clicks to window.hammerTrack, which each provider
defines via the new AnalyticsProvider.eventBridge(). All tracking markup
is gated on analyticsEnabled, so nothing renders when analytics is off.
2026-06-23 15:31:41 -07:00
Adam Brown
e5408f3ac0
Make admin API Traffic graph a 30-day daily chart (#641)
The API Traffic graph on the monitor dashboard was the visual odd one
out: a filled-area chart over a 24h hourly window, while the Active
Users and Story Readers graphs are plain daily lines over 30 days.

Switch its data source to the 30-day daily time series (the same source
the Performance/Errors pages use for their 30d ranges) and drop the
region fill / hidden dots so it matches the neighboring charts.
2026-06-23 15:14:53 -07:00
Adam Brown
8e55ec4ab0 Add a token-maintenance job to purge dead auth tokens
Now that refresh tokens expire, expired auth_token rows accumulated
forever since nothing deleted them. Add a daily maintenance job that
purges auth_token rows whose refresh window has fully elapsed (so a
still-refreshable row is never deleted), sharing AccountsRepository's
REFRESH_TOKEN_WINDOW so the cutoff can't drift from the refresh policy.

The job follows the existing job pattern (DI singleton + configure* at
boot, stopped on ApplicationStopped) and also runs the previously
unscheduled password-reset token cleanup.
2026-06-23 10:05:36 -07:00
Adam Brown
91dec02192 Compare Patreon webhook HMAC in constant time
verifyWebhookSignature compared the expected and supplied signatures with
String.equals, which short-circuits at the first differing character. On
an unauthenticated endpoint that leaks, via response timing, how long a
prefix of the expected signature a caller guessed correctly — enough to
recover a valid signature byte by byte and forge a webhook.

Use MessageDigest.isEqual, which examines the whole input regardless of
where the first difference is. Behaviour is otherwise unchanged
(case-insensitive, length-mismatch rejects), per PatreonApiClientTest.
2026-06-23 10:05:36 -07:00
Adam Brown
e691715541 Authorize web admin routes against the DB, not the session cookie
AdminOnlyPlugin gated access on the session cookie's isAdmin flag, baked
in at login. A user demoted from admin kept admin access in the web UI
until their 7-day session expired. Re-read is_admin from the database on
each admin request (the REST admin path already did this), so a demotion
takes effect immediately.

The cookie's isAdmin is still used for cosmetic UI (showing the admin
link); it no longer grants authorization.
2026-06-23 10:05:36 -07:00
Adam Brown
260a657737 Expire refresh tokens 6 months after the access token
refreshToken validated only that the refresh hash matched, never any
expiry, so a refresh token worked forever — a leaked one could mint new
sessions indefinitely.

Reject the refresh once now is past expires + a 6-month window. Because
expires slides forward to now + 30d on every token issue, the refresh
deadline slides with it: an actively-refreshed session never lapses,
while an idle one must re-login ~6 months after its access token expired.
The access token lifetime is unchanged.
2026-06-23 10:05:36 -07:00
Adam Brown
dee4e7220d Close the createAccount and refresh-token enumeration oracles
Two more account-existence oracles remained alongside the login one:

- refresh_token returned 500 for an unknown userId (getAccount threw
  AccountNotFound) but 401 for a known userId with a bad token, so the
  status code revealed whether the account existed. Look the account up
  without throwing and fall through to the normal 401 failure.

- createAccount only ran Argon2 when creating a new account, so the
  existing-account path returned faster — a timing oracle. Hash on that
  path too, and bring the route under the same rate limit as login.
2026-06-23 10:05:36 -07:00
Adam Brown
29cc901a98 Make login responses uniform to prevent account enumeration
Login returned distinct "Account not found" vs "Incorrect Password"
messages, and only ran Argon2 when the account existed — so both the
response body and the response timing revealed whether an email was
registered.

Return one generic "Invalid email or password" for both cases, and
verify the supplied password against a decoy Argon2 hash on the
unknown-account path so the work (and timing) matches a real wrong
password.
2026-06-23 10:05:36 -07:00
Adam Brown
25525df648 Derive emailed link hosts from config only, never the Host header
publicBaseUrl() fell back to the request Host header when publicUrl was
unset (the documented single-host default). An attacker could send
POST /forgot-password with a forged Host, and the server would email the
victim a real reset token inside a link pointing at the attacker's host —
account takeover. The review-invite and review-submitted emails shared
the same sink, giving an email-spoofing/phishing primitive.

publicBaseUrl() now resolves only from the configured publicUrl and
returns null when unset; out-of-band emails are skipped rather than sent
with a poisoned host (password reset still returns success to stay
enumeration-safe). A separate requestBaseUrl() keeps the request-derived
URL for in-session author-facing copy links, where the host is trusted.
2026-06-23 10:05:36 -07:00
Adam Brown
76f8b79df0 Scope project-access deletion to the owning project (fix IDOR)
deleteAccessById verified the caller owned the URL project but then
deleted by the global project_access.id alone, with no project scope.
Because the id is a sequential BIGSERIAL, any authenticated user could
enumerate ids and delete other tenants' publish/share records.

Scope the DELETE by project_id (RETURNING id to detect a hit), return
whether a row was deleted, and 404 the route when the access row is not
in the caller's project.
2026-06-23 10:05:36 -07:00
Wavesonics
0285ed029d
Let admins bypass the web whitelist
An admin shouldn't be able to lock themselves out of their own server by
enabling the whitelist without listing their own email. Authorize any
account flagged is_admin in the DB regardless of whitelist membership.
2026-06-23 08:18:06 -07:00
Wavesonics
951d12219e
Fix login/dashboard redirect loop for unauthorized sessions
The login page redirected to /dashboard on session presence alone, while
the dashboard auth gate also requires the whitelist check. A logged-in but
non-whitelisted user looped between the two. Share one sessionIsAuthorized
predicate across both, clear an unauthorized cookie on the login page, and
reject non-whitelisted logins with a message instead of setting a session.
2026-06-23 08:15:25 -07:00
Adam Brown
ed00d06e1f
Emit keyring JSON compactly for single-line env vars (#636)
Drop prettyPrint from the keyring serializer so generated keyrings
fit on one line, suitable for dropping into an environment variable.
2026-06-23 01:28:55 -07:00
Adam Brown
7a33e93aff Enforce web whitelist in session auth validator
The Ktor session `validate` lambda authenticates on any non-null
return. The validator returned a Boolean, so a non-whitelisted user
returned `false` — non-null — and was authenticated with a Boolean
principal. The whitelist gate was a no-op for the web UI: any valid
session reached whitelist-protected pages.

Return the UserSession when access is allowed and null when denied so
the challenge fires. Treat an unknown userId (getAccount throws
AccountNotFound) as denied rather than a 500.

ReviewCommitTest logged in a non-whitelisted author and was passing
only because of this bug; whitelist the author in its seed.
2026-06-23 00:32:19 -07:00
Adam Brown
6a34bf50c6 Encrypt-then-MAC web session tokens 2026-06-23 00:08:32 -07:00
Adam Brown
a10cdb1d20
Short-id project URLs (fix name round-trip 404s) + shared project-name validation (#628)
Promote the client's project/file-name validation rules out of :common's
ProjectsRepository and into a shared ProjectNameValidator in :base, resolving
the long-standing TODO on the old length-only stub.

Project URLs are now /story/{slug}-{id} (and /a/{penName}/{slug}-{id}), where the
trailing id is a stable 6-char base62 hash of the project's uuid and the slug is
purely cosmetic and never parsed back. This dissolves the slug round-trip bug:
the slug can be as pretty as we like because resolution matches the embedded id
against the user's projects, not the name. A bare /story/{id} (no slug) also
resolves, for short URLs.
2026-06-21 18:02:56 -07:00
Adam Brown
cbaa7385d4
Add configurable server bind addresses (bindHosts) (#623)
The bind address was hardcoded to 0.0.0.0. Add a bindHosts config list
(default ["0.0.0.0"]) so self-hosters can restrict the server to loopback
only, e.g. bindHosts = ["127.0.0.1", "::1"] when running behind a reverse
proxy on the same host. Each address gets its own HTTP and HTTPS listener.

Distinct from the existing `host` field, which remains the public display
name shown on the setup page.

Closes #590
2026-06-20 00:58:29 -07:00
Adam Brown
568bbb5ecd
New Crowdin updates (#616)
* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Chinese Simplified)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Chinese Simplified)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Chinese Simplified)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]
2026-06-19 17:52:01 -07:00
Adam Brown
c63b09f58f Use the HSTS plugin 2026-06-19 17:51:21 -07:00
Wavesonics
8d7ae2ab2a
Add testing to Server SSL cert loading
Update Server docs on setting up SSL
2026-06-19 17:25:17 -07:00
Adam Brown
7f5696236e Improved crypto migration story for existing servers 2026-06-18 02:29:42 -07:00
Adam Brown
491da055ef Replaced kotlinx.cli with Clikt 2026-06-18 00:35:55 -07:00
Adam Brown
2424a3d2ba Add prune-key subcommand to remove unused key generations
After a rotation + convergence, old content key generations linger in the
keyring as dead weight, and deleting the wrong one by hand destroys data.
prune-key reads the keyring (provider or --in), checks the database for which
content generations still protect rows, and drops every non-active generation
with zero rows on it; the active generation is never removed.

A generation still referenced is kept and reported (skip + report), so the
sweep never half-strands data. An explicit --key that is active or still
referenced fails instead of silently no-opping. The tokenHmac role needs no
database: only the active token key verifies tokens, so every non-active
generation is already dead.

KeyPruner holds the pure logic; the command resolves in-use key ids from new
distinctCiphers queries via AesGcmContentEncryptor.keyIdForTag. A parity test
pins all convergence queries to fold the legacy tag identically.
2026-06-18 00:11:24 -07:00
Adam Brown
936d12fe9d Organize sub-commands into files 2026-06-17 23:18:28 -07:00
Adam Brown
5987d0439b Resolve the keyring from the configured provider in the keyring CLI
inspect-keyring and rotate-key now read the current keyring from the server
config's [secret] provider (via --config) when --in is omitted, instead of
defaulting to a hardcoded file path — so they work with the env provider or a
custom file location, and mirror what the running server actually loads
(including a grandfathered legacy server.secret). --in still overrides with an
explicit file.

Provider construction is extracted into a shared buildSecretProvider() used by
both the DI binding and the CLI, so they can't drift.
2026-06-17 23:15:29 -07:00
Adam Brown
3227a14fac Generate the fallback token secret with full entropy
ServerSecretManager.generateSecret now base64-encodes 32 random bytes instead
of commonToUtf8String, which collapsed invalid UTF-8 sequences and left the
secret with materially less than 256 bits of entropy. This is the token-HMAC
key used when no keyring is configured (a zero-config plaintext server); the
keyring path was already clean.

Only affects newly generated secrets on fresh servers — existing server.secret
files are read verbatim, so no tokens are invalidated. Also corrects the stale
KDoc (content-key derivation moved to the keyring).
2026-06-17 22:45:33 -07:00
Adam Brown
a356fb4da6 Address code-review feedback across the secret-storage PRs
Correctness/robustness:
- FileSecretProvider treats an empty/whitespace keyring file as absent so it
  no longer bypasses the legacy grandfather; KeyringManager wraps parse/validate
  failures in MalformedKeyringException instead of a raw stacktrace.
- loadEntity catches crypto/Base64 failures (corrupt or mis-tagged row -> clean
  SResult.failure, not an uncaught crash); unknown tag stays loud.
- ContentEncryptorRegistry rejects duplicate cipher tags at construction.
- Convergence dry-run no longer loads the whole table (paginated select), and
  runs in main() via a standalone Koin graph before the HTTP engine starts, so
  it never binds a port.
- review_scene.countForConvergence uses the same join as selectForConvergence so
  the completion ("safe to delete key") signal can't diverge.
- Single source for the active encryptor (ContentEncryptors.active): the DI
  write binding and the convergence gate resolve it the same way.
- PBKDF2 key cache 10 -> 100 so convergence/rotation doesn't thrash it.

Quality: TokenHasher KDoc, trimmed 4.sqm header, imports over fully-qualified
names in Application.kt, shared secretFor, dropped unused FileSystem params.

Tests: provider blank/empty/absent, malformed keyring, tag/content mismatch,
registry duplicate-tag + cross-generation/legacy-alias round-trip, invalid
mode rejected, nextKeyId non-vN, and a review_scene backfill data-migration test.

Left as decided: review snapshots stay uncapped (documented); secret cache
unbounded; Int counters on the convergence report.
2026-06-17 02:00:58 -07:00
Adam Brown
836e2883cc PR5c: rotate-key CLI, convergence dry-run, crash/no-loss test
- rotate-key subcommand: adds a new key generation to a role (content or
  tokenHmac), makes it active, keeps the old keys, and emits the updated
  keyring (stdout or --out). Offline flow: rotate-key -> place keyring ->
  restart -> convergence re-encrypts onto the new key. KeyringCodec.rotate.
- --converge-dry-run: reports rows off the configured target and any entities
  that would exceed the size cap once encrypted, then exits writing nothing.
- Crash/no-loss test: an injected mid-convergence failure leaves committed
  rows re-crypted and the rest with their readable original; a re-run finishes.

This completes PR5: enable/disable/rotate convergence, the blocking boot gate,
the nullable-mode downgrade guard, rotation, and the dry-run.
2026-06-17 00:25:58 -07:00
Adam Brown
06568869c0 PR5b: blocking pre-launch encryption convergence
EncryptionConvergence re-crypts story_entity and review_scene rows onto the
active cipher (per-row atomic updates, resumable: the tag column is the
progress ledger). The SQL predicate normalizes NULL to plaintext and the
legacy AES/GCM/NoPadding tag to v1, so a server upgrading from before key ids
sees no re-crypt churn. An over-cap row aborts with a named report.

EncryptionBootstrap is the boot gate (runs in appMain before routing):
- encryption.mode is now nullable. Unspecified + existing encrypted data is a
  hard stop (admin must choose); explicit none converges to plaintext; explicit
  aes converges to the active key.
- A last-applied marker in ServerConfigDao skips the scan on normal boots.

remaining(target) is the completion signal: 0 means fully converged, so an old
key is provably unreferenced and safe to delete.

rotate-key CLI and the dry-run land next.
2026-06-17 00:11:58 -07:00
Adam Brown
560b8c1294 PR5a: key-id-aware cipher tags (aesgcm:<keyId>)
Foundation for key rotation. AES content is tagged aesgcm:<keyId> instead of
"AES/GCM/NoPadding"; the registry holds one AES encryptor per content-key
generation in the keyring and resolves a row's encryptor by its tag. The
legacy "AES/GCM/NoPadding" tag aliases to aesgcm:v1 (the grandfathered key),
so existing rows keep decrypting.

Key derivation now takes the content-key value (cached per content-key +
client-secret), making the key provider keyring-agnostic. The active write
encryptor is the active key id's AES instance, or plaintext under mode=none.

No data migration here: new writes get the new tag, old rows read via the
alias. Converging old rows onto the active key is the next sub-commit.
2026-06-16 23:23:34 -07:00
Adam Brown
2d1384db3a PR4: hash auth tokens with the keyring's tokenHmac role
TokenHasher now keys off the keyring's tokenHmac role instead of the raw
server secret, completing the content/token key split. When no keyring exists
at all (a zero-config plaintext server) it falls back to the auto-managed
server.secret, so auth still works with no setup. Any keyring present
(explicit or grandfathered) takes precedence.

A grandfathered keyring carries tokenHmac.v1 == the legacy server.secret, so
existing tokens keep verifying with no forced re-login. "No auto-generation"
now means content keys specifically; the token key stays auto-managed because
losing it only forces re-login, never data loss.
2026-06-16 22:53:00 -07:00
Adam Brown
b03eb43782 PR3: versioned keyring, pluggable secret provider, generation CLI
Replaces the single auto-generated content secret with a versioned keyring
read through a pluggable provider.

- Keyring/RoleKeys data classes + KeyringCodec (parse, serialize, generate,
  grandfather). Key values are opaque strings used directly (PBKDF2 chars,
  UTF-8 HMAC bytes), never decoded to raw bytes. New keys are base64(32 bytes),
  fixing the lossy-entropy generation; a grandfathered key is the legacy
  server.secret string verbatim so existing content stays readable.
- ServerSecretProvider with File and Env implementations, selected by a new
  [secret] config block (default file).
- KeyringManager resolves the keyring (provider, else grandfather a pre-existing
  server.secret) and fails fast when mode=aes has no content key.
- AES key provider reads the active content key; boot requires it under mode=aes.
- generate-keyring / inspect-keyring kotlinx-cli subcommands.

Content keys only: the tag format stays "AES/GCM/NoPadding" (aesgcm:vN with
rotation is PR5) and TokenHasher stays on the legacy server.secret until PR4.
2026-06-16 22:04:57 -07:00
Adam Brown
ebb3b6e7ed Hard-stop boot when plaintext mode meets encrypted data
EncryptionModeGuard.verifyOnBoot refuses to start the server when
encryption.mode is none but AES-tagged rows exist in story_entity or
review_scene. This stops a previously-encrypted deployment from silently
downgrading to plaintext on upgrade (the plaintext default would otherwise
leave existing AES data unreadable on write); the admin must explicitly set
mode=aes.

Unconditional for now. PR5 will refine it so an explicit mode=none triggers
convergence to plaintext instead, and add a second trigger on a keyring
content key being present.
2026-06-16 21:14:11 -07:00
Adam Brown
3a98622759 Make review snapshots polymorphic and default to plaintext encryption
Two PR2 follow-ups for a coherent encryption-mode story:

Reviews polymorphic: review_scene gains a cipher TEXT NOT NULL column
(schema v5, migration 4.sqm). ReviewRepository tags snapshots with the
active encryptor on write and resolves the row's encryptor from the registry
on read, mirroring story_entity. review_scene postdates at-rest encryption,
so existing rows have no plaintext history; the migration backfills them with
the AES tag (NULL would wrongly read as plaintext) via a temporary default
that is then dropped.

Plaintext default: a zero-config server now stores plaintext (EncryptionMode
default NONE) so a casual self-hoster needs no key material. Enabling AES is a
deliberate opt-in. EndToEndTest pins mode=aes since it exercises the AES path.
2026-06-16 20:32:33 -07:00
Adam Brown
c8c6717e8c PR2: config-selectable content encryption mode
Adds an [encryption] config block (EncryptionMode aes|none, default aes)
that selects the active write encryptor. DI binds ContentEncryptor by
encryption.mode, so a server can be configured to write plaintext. Reads
remain polymorphic per-row, so existing AES rows still decrypt after the
mode changes.
2026-06-16 19:56:56 -07:00
Adam Brown
6897abbd76 PR1: polymorphic content decryption via cipher registry
Decryption now dispatches on each row's stored cipher tag instead of the
single DI-injected encryptor. Adds ContentEncryptorRegistry and an identity
PlaintextContentEncryptor (tag "none"); loadEntity resolves the row's
encryptor from its cipher column. NULL cipher resolves to plaintext
(pre-#367 rows); an unknown non-null tag fails loudly rather than reading
ciphertext as garbage. Store path is unchanged.

Tests: mixed-tag read (aesgcm/none/NULL), unknown-tag loud failure, and
cipher-orthogonal-to-hash invariant.
2026-06-16 19:52:37 -07:00
Adam Brown
71fda2e2ee Resolve web project URLs losslessly to fix name-with-dash 404s
formatForUrl maps spaces to dashes and decodeFromUrl maps every dash
back to a space, so a project whose name contains a literal dash (e.g.
a "2026-06-07" date) could not be reversed and its web page 404'd.

Add ProjectsRepository.findProjectByUrlName, which tries the exact
decoded name then matches the URL segment against each project
re-encoded with formatForUrl. formatForUrl output is unchanged, so
existing shared/published links keep working. Route the story and
review lookups through it, and compare slugs in reviewMatchesUrlProject.
2026-06-16 02:34:10 -07:00
Adam Brown
92e7578aa7 Add Postgres schema-upgrade verification test suite
Verifies that databases upgraded via Schema.migrate end up structurally
identical to a fresh install, and that data written under the v1 schema
survives migration to the latest version. Restores the schema/data
guarantees the SQLite migration tests provided before the Postgres move.

The checked-in v1_baseline.sql is the immutable upgrade-from snapshot;
new schema versions are picked up automatically via Schema.version.
2026-06-16 02:23:23 -07:00
Adam Brown
90feb01a4d Improve monitoring dashboard styling for more stats 2026-06-16 01:57:55 -07:00
Adam Brown
27d33a6995 Improve monitoring dashboard styling for active users 2026-06-16 01:42:03 -07:00
Adam Brown
a6147ab72a Fix server tests 2026-06-16 00:33:25 -07:00
Adam Brown
c533916395 Change string 2026-06-15 23:58:47 -07:00
Adam Brown
e535b68f46 Distinguish client-fault errors from server faults on the monitoring dashboard
The Recent Errors list rendered every entry as loud red, even though most
were scanner/bot probes hitting /api without a protocol header. Give those a
quieter treatment and surface the HTTP status each error resolved to.

- Record the resolved HTTP status on error_log (folded into the v4 migration;
status column added last to match ALTER's physical column order).
- Classify only deliberately-marked client/transport faults as 4xx via a new
HttpStatusException hierarchy; everything else stays 500 so genuine server
bugs aren't hidden as client errors. The protocol enforcer now throws
UnsupportedProtocolVersionException instead of a generic korlibs exception.
- Apply the classified status to /api responses only; web routes keep their
500 + servererror page behavior.
- Render 4xx as amber warnings and 5xx as red errors, with a status badge and
severity icon, on the errors page.
2026-06-15 21:06:56 -07:00
Adam Brown
5e963c48ec Don't count the story owner as a reader of their own story
The public story route now reads the logged-in viewer's id from the session
and skips the reader tally when it matches the story owner, so an author
browsing their own published / shared story doesn't inflate their count.
2026-06-15 17:44:00 -07:00
Adam Brown
501dec8100 Fix error-rate metric missing exception-driven errors
The Error Rate graph read from MetricsCollector error counts recorded on
RoutingCallFinished, which never fires when a handler throws. Exceptions
mapped to 500 by StatusPages were therefore invisible to the time series
even though they were logged to the recent-errors list.

Record metrics on ResponseSent instead, so the final (exception-mapped)
status is observed, and key on the route template stashed during routing.
2026-06-15 17:31:00 -07:00
Adam Brown
0cc3c754a4 Add per-story reader counter and unique active-users metrics
Two privacy-preserving usage metrics for the sync server, accumulated in
memory and flushed by the monitoring maintenance job:

- Unique active users (sync + web) over 24h/7d/30d on the admin dashboard.
- Best-effort unique readers of published / shared stories, identified by a
  cookieless daily-salted visitor hash: IP + user-agent are hashed in-request
  and never stored, and the salt rotates daily in memory, so reads can't be
  linked across days or back to an IP. Authors see an all-time total plus a
  30-day daily trend on their story page; admins see aggregate window counts.

Reader rows dedupe per (project, day) and are purged on the metrics retention
window, with each day's count rolled into a per-story lifetime total first so
the all-time number survives the purge.

Adds the user_activity and published_story_reader[_total] tables (schema v4),
consolidates UTC-day truncation into a shared helper, and formats the trend
labels in UTC to match the buckets.
2026-06-15 02:01:32 -07:00
Adam Brown
e43833b7a5 Codereview changes 2026-06-15 00:26:19 -07:00
Adam Brown
0f0f3b1cfe Add Active Users metric to server monitoring dashboard
Track unique logged-in users (real accounts, not anonymous visitors)
split by activity: syncing vs. authenticated web sessions.

- New user_activity table (dedup per user/type/hour) + migration 3.sqm,
  DB version 4. Distinct counts via COUNT(DISTINCT user_id); the additive
  histogram used for API metrics can't answer distinct-user counts.
- In-memory UserActivityCollector flushed by MonitoringMaintenanceJob,
  mirroring MetricsCollector. Gated end-to-end by the master monitoring
  switch (MonitoringConfig.enabled) and purged on metricsRetentionDays.
- Recorded at two hook points: sync (ProjectsRoutes.beginProjectsSync)
  and any authenticated web render (Frontend.withDefaults).
- Overview dashboard shows 24h/7d/30d rollups (DAU/WAU/MAU) plus a daily
  active-users trend line (Sync vs Web) over the last 30 days.
2026-06-15 00:06:42 -07:00