Commit graph

556 commits

Author SHA1 Message Date
Wavesonics
9ead030ab9
Extract a generic interface for user gating
Simplify allowed user management.
2026-08-20 21:17:45 -07:00
Adam Brown
66e02d9e6e
Render shared stories in scene tree order (#923)
renderPaginated never walked the scene tree, so share pages came out in
entity id order and any project not written front to back rendered
shuffled. Scenes now flatten depth first, siblings by order, with
unreachable ones appended rather than dropped.

A scene-limited share reads only its own scenes and the groups above
them, found via each scene's path, and its validator covers those groups
so a chapter swap no longer serves a stale order. Bumped RENDER_VERSION,
without which cached shares would keep serving the old order.

Chapter headings render as the author wrote them, unnumbered.
2026-08-17 23:58:25 -07:00
Adam Brown
ba70218a44
Support configuring the server time zone (#882) (#922)
Adds a `timezone` config setting, with `HAMMER_TIMEZONE` and `TZ` as
environment-variable fallbacks, applied at startup so both rendered page
timestamps and log lines use it.
2026-08-17 23:20:03 -07:00
Adam Brown
be04256597
New Crowdin updates (#911) (#920)
* 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 (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 (Portuguese, Brazilian)

[ci skip]

* New translations strings_project_home.xml (French)

[ci skip]

* New translations strings_project_home.xml (Spanish)

[ci skip]

* New translations strings_project_home.xml (German)

[ci skip]

* New translations strings_project_home.xml (Italian)

[ci skip]

* New translations strings_project_home.xml (Ukrainian)

[ci skip]

* New translations strings_project_home.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_project_home.xml

[ci skip]

* Update source file Messages_en.properties

[ci skip]

* Update source file strings_account_settings.xml

[ci skip]

* Update source file strings_encyclopedia.xml

[ci skip]

* Update source file strings_sync.xml

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_encyclopedia.xml (French)

[ci skip]

* New translations strings_encyclopedia.xml (Spanish)

[ci skip]

* New translations strings_encyclopedia.xml (German)

[ci skip]

* New translations strings_encyclopedia.xml (Italian)

[ci skip]

* New translations strings_encyclopedia.xml (Ukrainian)

[ci skip]

* New translations strings_encyclopedia.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]
2026-08-17 21:43:18 -07:00
Adam Brown
4f2c8fb503
Encyclopedia names feed the spell check dictionary (#918)
Entry names and aliases become session-only words (AppLocal scope, never
the OS dictionary) while their project is open, cleared on close. Words
are tokenized to single words and filtered against the base dictionary
so only unknown spellings are added.

Three levels of control, all live-reactive:
- Global toggle in Spell Check settings (SpellCheckerSettings, default on)
- Per-project toggle (ProjectData.encyclopediaDictionary, synced;
  hashed only when false so existing hashes are stable)
- Per-entry exclusion (EntryContent.excludeFromDictionary, synced;
  hashed only when true, same zero-bytes-at-default rule as aliases)

SpellCheckRepository holds session words keyed by ProjectDef and emits a
fresh checker instance whenever the effective word set changes, so open
editors re-run their full scan; the same path re-applies words on locale
change and re-enable. ProjectDictionaryService (ProjectDefScope, eagerly
started in initializeProjectScope) rebuilds the word set from the
encyclopedia on entry changes, debounced, and clears it on scope close.
2026-08-16 20:20:31 -07:00
Adam Brown
5d02c09109
Drop the redundant per-call English message fallback (#916)
#909 installs EnglishFallbackMessageResolver, so every call.t/Msg.r site
already resolves an untranslated key against English. The helper added in
#915 duplicated that for three call sites, and its comment described a
hazard that no longer exists.

The German-locale rename conflict test stays; it passes through the
resolver instead.
2026-08-16 16:06:35 -07:00
Adam Brown
6d7f53db04
Fix renaming a project onto a deleted project's name (#862) (#915)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Account sync pushed renames before deletions, so renaming a project into
a name still held by one queued for deletion violated the server's
UNIQUE(name, user_id). Phases now run delete -> rename -> create.

The violation also escaped as a 500: ProjectDao maps SQLSTATE 23505 to a
ProjectNameTaken failure and /rename answers 409 Conflict.

Also in here:

- Renames queued against a server-tombstoned project id are dropped.
  They could only 404 and requeue every session.
- API error messages fall back to English. The locale bundles have no
  base Messages.properties to parent through, so a key present only in
  English threw MissingResourceException and made the response a 500 for
  every other locale.
- Rename's ProjectNotFound reported "Invalid project name".
2026-08-16 12:04:35 -07:00
Adam Brown
a94d5c783e
Fall back to English when a locale bundle is missing a key (#909)
ktor-i18n's ResourceBundleMessageResolver threw MissingResourceException
for any key a translation had not picked up yet, since the locale bundles
have no parent bundle to inherit from. Every Msg.r/call.t site turned that
into a 500, most visibly on /signup: rejecting a not-allowed email needs
api_allowedusers_rejected, which Crowdin had not yet delivered to de.

Fixes #883 (registration half)
2026-08-16 00:34:56 -07:00
Adam Brown
7e9bd46c26
Replace the native Argon2 binding with a pure-JVM implementation (#908)
Argon2 hashing went through JNA, which extracts libjnidispatch.so at runtime.
On Linux JNA ignores java.io.tmpdir and uses $XDG_CACHE_HOME, falling back to
<user.home>/.cache, and the Docker image sets user.home=/data. That puts the
shared object on the data volume, so hosts mounting it noexec cannot load it,
and signup and login returned a 500 (#884).

Argon2PasswordHasher derives with BouncyCastle instead, reading the variant,
version, and cost parameters back out of the stored PHC string. Its output is
byte-identical to libargon2 for the same inputs, so existing hashes keep
verifying; the tests pin that against hashes generated by the C implementation.

JNA is still on the runtime classpath via the CLI's terminal library, so the
image also points XDG_CACHE_HOME at its own layer.
2026-08-16 00:26:01 -07:00
Adam Brown
8c871b344e
Search allowed users by email (#906)
Add email search to the Allowed Users admin page

Filter the list by full or partial email, matched case-insensitively anywhere in
the address. The query survives sorting, paging, remove and both edit dialogs.

The input sits in the page shell, not the swapped fragment, so it keeps focus and
caret while typing; the fragment renders its view state as hidden inputs that the
search box and add form pull in via hx-include.

Also fixes three faults on this path: paginated queries ordered on date_added
alone, letting LIMIT/OFFSET repeat and skip rows that share a timestamp; "Page 1
of 0" from a mustache section on a boxed Integer; and a dead htmx:afterSwap
handler reading an always-empty query string.
2026-08-15 23:53:54 -07:00
Adam Brown
d0d4d667ba
New Crowdin updates (#902)
* 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 (Portuguese, Brazilian)

[ci skip]
2026-08-15 23:09:51 -07:00
Adam Brown
f0372c885d Update discord links 2026-08-15 22:55:51 -07:00
Adam Brown
dd30fb9245
Scene-limited private shares (#897)
* Add per-share scene restriction table and DAO plumbing

* Carry scene restrictions through ProjectAccessRepository

* Filter public story renders to a share's scene set

* Add scene selection to the private share dialog

* Mount share and publish-warning dialogs outside the sticky sidebar

* Keep the share dialog open on a rejected create and let expired shares' passwords be reused

* Drop scene path fixes swept in from another branch

These three files belong to the order-padding fix in #896, not to the
share work. They were picked up by a broad add and are reverted here so
this PR carries only the share changes.

* Harden scene-limited shares per code review

Atomic duplicate-password check under a project-row lock, shared scene-set validation that rejects group ids (review requests included), Set-based fail-closed restriction model, one clock for expiry, live scene counts that surface dead shares, symmetric child-row cleanup, surfaced tree-load failures, api_error_unknown fallback, and dedup of the sheet-dialog CSS.
2026-08-15 19:50:40 -07:00
Adam Brown
761bdca19f
Give the review dialog's scene list room to breathe (#894)
The scene picker was a scroller inside another scroller, capped at 260px, so
it stayed cramped no matter how tall the window was. The dialog body is now
the only scroll region and the list renders at full height inside it.

The "All scenes / Select all" row was position:sticky inside that inner
scroller, with side margins and container padding around it, so rows showed
through the gap above it and the gutters beside it. It is now a plain header
bar above the list, outside the scroller.

The dialog also sat inside the story sidebar, which is position:sticky and
therefore a stacking context its z-index could not escape, leaving it painted
under the site header. Moved the container out to a sibling of <main>.

Short viewports get dvh units, trimmed dialog padding, and a full-bleed sheet
under 600px wide.
2026-08-15 13:01:34 -07:00
Adam Brown
df35452429
Render story prose the way the editor shows it, on the web and in every export (#887)
A page of dialogue reached readers as one packed block. CommonMark reflows
prose: single newlines become spaces and any run of blank lines collapses to
one paragraph break, so what an author sees while writing was not what anyone
else saw. Prose is now laid out as it was typed, on every surface that renders
it: every newline starts a new line, every blank line is a blank line.

- base/markdown/ProseHtml.kt holds that layout as AST-level generating
  providers, shared by the server's pages and the client's EPUB export. Lists,
  tables, code and headings keep markdown's own layout by construction, so the
  old fence-tracking preprocessor is gone.
- parseProseMarkdown does the same for the DOCX, RTF and PDF exports, with a
  ProseBlock.Blank for an authored blank line. A body paragraph carries space
  after it only where prose ends, so lines that run on sit tight and the
  indent parts them.
- Quoted passages keep their lines, and a quote's '>' markers no longer leak
  into the text of a continuation line in the document exports.

Walking every construct the flavour can emit turned up three more faults. A
stripped element keeps its text, so tables (which the sanitizer allowed none
of) arrived as their cells run together; they are allowed and styled now,
column alignment included. An ordered list starting at 5 rendered as 1. Line
endings went unnormalized once the preprocessor that had done it incidentally
was removed.

The story's declared language now reaches the prose on the author's own page
as well as the public one, so a French story is not hyphenated by the rules of
whatever locale the author reads Hammer in.

RENDER_VERSION goes to v4 and moves into the fingerprint: it keyed the disk
cache but not the ETag, so a bump alone would have served stale prose to
anyone holding the old validator.
2026-08-14 23:41:47 -07:00
Adam Brown
da41f56998 Revert "New Crowdin updates (#879)"
This reverts commit c529bd063b.
2026-08-11 00:51:47 -07:00
Adam Brown
c529bd063b
New Crowdin updates (#879)
* Update source file strings_sync.xml

[ci skip]

* Update source file Messages_en.properties

[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 (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]
2026-08-11 00:05:45 -07:00
Adam Brown
8c83c964ea
Always-on Allowed Users list + public signup page (#875)
Always-on Allowed Users list and public signup page

The whitelist toggle is gone: every server now enforces the allowed users
list, and the web UI, routes, and message keys are renamed to "Allowed
Users". The Kotlin, database, and REST names are unchanged.

Adds a public /signup page so an allowed user can create their own account
from the web instead of only through the app. It goes through
AccountsComponent.createAccount, so the allowed users list and the Terms of
Service challenge are enforced, and the POST shares the login rate limit.
A not-allowed email is audited like a failed login.

Wires the dormant DataMigrator into startup with one-time completion markers
in server_config, and adds a migration that seeds the list with every
non-deleted account.
2026-08-10 23:30:46 -07:00
Adam Brown
a33f42d9a2
New Crowdin updates (#871)
* New translations messages_en.properties (French)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_about_app.xml (Spanish)

[ci skip]

* New translations strings_about_app.xml (German)

[ci skip]

* New translations strings_about_app.xml (Italian)

[ci skip]

* New translations strings_about_app.xml (Ukrainian)

[ci skip]

* New translations strings_about_app.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_about_app.xml

[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 (Portuguese, Brazilian)

[ci skip]

* Update source file Messages_en.properties

[ci skip]
2026-08-10 20:48:49 -07:00
Adam Brown
4099195eda
Render the admin server message as markdown (#876)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
The instance band now runs the message through MarkdownService, so admins can
use links, emphasis, and lists in it. Output is sanitized by the same OWASP
policy the About page uses, and a message that sanitizes down to nothing no
longer paints an empty band.
2026-08-10 01:15:31 -07:00
Adam Brown
146919556d
Record web sign-ins in the security audit trail (#874)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Record web sign-ins in the security audit trail

The web login form never called recordLoginAttempt, so only app clients
hitting /account/login showed up on the Security monitoring page.

Wire /login in, auditing what the server actually allowed: a whitelist
rejection is recorded as a failure, matching the API path. Move the
monitoring gate into SecurityRepository so both routes honor the
loginTrackingEnabled and storeLoginIp settings by construction, store
blank emails as null to keep them out of the per-account brute-force
queries, and log rather than propagate a failed audit write so it can't
break the sign-in. Rate limit the web login POST like the other two
login entry points.

* Suppress TooGenericExceptionCaught on the audit write

The catch is deliberately broad: no failure mode of recording an attempt
may break the sign-in that triggered it.
2026-08-09 18:23:17 -07:00
Adam Brown
725d75250d
New Crowdin updates (#870)
* New translations messages_en.properties (French)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]
2026-08-08 00:18:37 -07:00
Adam Brown
4df8802d43
Render strikethrough on the web (#864)
CommonMark has no strikethrough, so `~~struck~~` reached every server
rendered page as literal tildes while bold and italic worked. The editor
that writes the content parses GFM, so the two disagreed.

MarkdownService now parses GFM, matching the editor. GFM emits
strikethrough as `<span class="user-del">`, which the sanitizer would
unwrap for having no allowed attributes, so it is rewritten to `<del>`
and `del` is allowed through. Bumps RENDER_VERSION so cached story pages
regenerate.

The review page had the same gap for its own reason: parseInlineMarkdown
only branched on `*` and `_`. It now handles `~~` and threads the flag
through to both render sites.
2026-08-07 08:18:23 -07:00
Adam Brown
bd2e3a9666
Give rendered stories real book typography (#858)
* Give rendered stories real book typography

Lists sat at the prose margin with their items packed together, thematic
breaks ran the full column width, and any run of blank lines an author
typed collapsed to a single paragraph gap.

- MarkdownService emits a break for each blank line past the first, so
  deliberate white space survives CommonMark's collapsing. Runs are
  capped and fenced code is left alone.
- StoryRendererService separates sibling scenes with a blank line;
  without it the last paragraph of one scene and the first of the next
  parsed as a single paragraph.
- story.css styles every element markdown can emit: indented lists with
  accent markers and breathing room, a centered scene-break rule with an
  ornament, a tinted blockquote panel, code and links, h3-h6, and ragged
  right prose below 600px where justification opens rivers.

* Indent every prose paragraph, including a page's first

Each page opens with the scene's heading, so the flush-left rule for a
paragraph following a heading swallowed the indent on the first line of
every page. Drop the rule entirely: paragraphs after a scene break, list,
or quote now indent too, so the prose is uniform.

* Keep blank-line spacing to the prose that asked for it

Review of the branch turned up four ways the blank-line work reached
further than intended.

- markdownToSafeHtml takes preserveBlankLines, defaulting off. Bios, the
  About page, the privacy policy and the review frontend render with
  CommonMark's collapsing again; only story rendering opts in.
- A code fence is now tracked by its delimiter and length, so a ~~~ line
  inside a ``` block no longer ends it and leaks a literal <br /> into the
  code. A blank run between two indented lines is left alone, which covers
  indented code blocks and fences nested in list items.
- The chapter heading no longer carries a leading newline; with
  appendScene's trailing blank line it made a run long enough to render a
  break above every heading.
- RENDER_VERSION goes to v2 so cached story pages re-render.

* Fold the group word count into the scene walker

buildGroupMarkdown had its own copy of writeGroupChildren, differing only
by a word-count accumulator, so every change to how scenes are separated
had to be made twice. writeGroupChildren now returns the count and the
single-group export uses it.

Also brings the Story Prose table in the design system doc back in line
with the rules story.css actually carries.
2026-08-06 23:43:34 -07:00
Adam Brown
fcd94201ef
Trust proxy forwarding so a proxied server sees real client addresses (#855)
Adds trustProxyForwarding, which reads each request's client address and scheme from the X-Forwarded-* headers so the login rate limiter, the login audit trail and story reader counts see real clients rather than the proxy.

Reads the last X-Forwarded-For entry rather than Ktor's default first entry, which arrives with the request and can be forged to claim a fresh rate limit bucket per request.
2026-08-05 21:20:40 -07:00
Adam Brown
7620fb81bc
New Crowdin updates (#848)
* 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 (Portuguese, Brazilian)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_project_home.xml (French)

[ci skip]

* New translations strings_project_home.xml (Spanish)

[ci skip]

* New translations strings_project_home.xml (German)

[ci skip]

* New translations strings_project_home.xml (Italian)

[ci skip]

* New translations strings_project_home.xml (Ukrainian)

[ci skip]

* New translations strings_project_home.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]

* New translations full_description.txt (French)

[ci skip]

* New translations full_description.txt (Spanish)

[ci skip]

* New translations full_description.txt (German)

[ci skip]

* New translations full_description.txt (Italian)

[ci skip]

* New translations full_description.txt (Ukrainian)

[ci skip]

* New translations full_description.txt (Portuguese, Brazilian)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_project_home.xml (French)

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* Update source file Messages_en.properties

[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 (Portuguese, Brazilian)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_about_app.xml (Spanish)

[ci skip]

* New translations strings_about_app.xml (German)

[ci skip]

* New translations strings_about_app.xml (Italian)

[ci skip]

* New translations strings_about_app.xml (Ukrainian)

[ci skip]

* New translations strings_about_app.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_about_app.xml

[ci skip]

* Restore project-language string keys dropped by Crowdin sync

The prior Crowdin sync (#847) overwrote strings_sync.xml from a stale
copy that predated the project-language feature (#838), silently
deleting 7 keys still referenced by composeUi. Restoring them here so
this sync's build compiles; see #851 for the same fix on develop.

* Update source file strings_sync.xml

[ci skip]
2026-08-04 21:25:39 -07:00
Adam Brown
e836ee7182 Switching way from kotlinx datetime where possible 2026-08-04 20:51:46 -07:00
Adam Brown
76fc56f869
Add create-admin-account form to the server setup page (#849)
The first-run /setup page now hosts an email/password form that creates
the initial admin account, signs the operator into a web session, and
redirects to /admin. The app-based setup steps remain as a secondary
section. POST is rate limited alongside login and re-checks hasUsers()
to guard races and double submits.
2026-08-04 19:04:41 -07:00
Adam Brown
cfac12ed5f Lib bumps
Remove kotlinx.datetime
2026-08-04 00:27:59 -07:00
Adam Brown
5ab2afabee Guard the English message bundle against dropped keys
English is the fallback bundle every locale resolves through, so a key the
server looks up but Messages_en.properties does not carry throws
MissingResourceException at request time. A Crowdin sync rewrote the English
source file and dropped 129 live keys, which 500ed the home page, account
refresh, and account deletion.

The test scans msg/Msg.r/localizedMsg call sites and mustache msg.* references
and asserts each resolves in English. Parity is checked against referenced keys
rather than the translation files, which Crowdin owns and which carry strings
that have outlived their use in the templates.
2026-08-03 23:24:26 -07:00
Adam Brown
c8eb4b874d
New Crowdin updates (#847)
* Update source file strings.xml

[ci skip]

* Update source file strings_about_app.xml

[ci skip]

* Update source file strings_account_settings.xml

[ci skip]

* Update source file strings_desktop.xml

[ci skip]

* Update source file strings_drafts.xml

[ci skip]

* Update source file strings_encyclopedia.xml

[ci skip]

* Update source file strings_globalsearch.xml

[ci skip]

* Update source file strings_ideas.xml

[ci skip]

* Update source file strings_notes.xml

[ci skip]

* Update source file strings_project_home.xml

[ci skip]

* Update source file strings_project_navigation.xml

[ci skip]

* Update source file strings_project_select_navigation.xml

[ci skip]

* Update source file strings_projects_list.xml

[ci skip]

* Update source file strings_scene_editor.xml

[ci skip]

* Update source file strings_scene_list.xml

[ci skip]

* Update source file strings_sync.xml

[ci skip]

* Update source file strings_timeline.xml

[ci skip]

* Update source file strings_android.xml

[ci skip]

* Update source file full_description.txt

[ci skip]

* Update source file Messages_en.properties

[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 (Portuguese, Brazilian)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_project_home.xml (French)

[ci skip]

* New translations strings_project_home.xml (Spanish)

[ci skip]

* New translations strings_project_home.xml (German)

[ci skip]

* New translations strings_project_home.xml (Italian)

[ci skip]

* New translations strings_project_home.xml (Ukrainian)

[ci skip]

* New translations strings_project_home.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]

* New translations full_description.txt (French)

[ci skip]

* New translations full_description.txt (Spanish)

[ci skip]

* New translations full_description.txt (German)

[ci skip]

* New translations full_description.txt (Italian)

[ci skip]

* New translations full_description.txt (Ukrainian)

[ci skip]

* New translations full_description.txt (Portuguese, Brazilian)

[ci skip]
2026-08-03 23:17:09 -07:00
Adam Brown
1b312bc3b2
Allow a project to set the language it's written in (#838)
* Add per-project language setting (#754)

An optional BCP-47 language on ProjectData, picked from a searchable
list of all platform locales in project settings. New projects default
to the device locale; the Alice example project is en-US.

Spell check is gated per project: when the project language does not
leniently match the dictionary locale, the dictionary is withheld
(ProjectSpellCheckRepository) and project settings explain why.

The public story page emits <html lang> and JSON-LD inLanguage from the
declared language, and EPUB export prefers it over the device locale.
The hasher contributes zero bytes when unset so existing sync hashes
stay stable.

* Fix review findings in the project-language feature

createProject now only seeds the default language for genuinely new
projects (seedDefaultLanguage), so account sync materializes server
projects with the never-synced baseline intact, and the seed is
language-only so it cannot gate spell check against a same-language
dictionary. The hasher's language block gets a -1 marker plus length
prefix so it can never collide with a tags block, and the initial
write goes through the shared saveStoredProjectData path.

The Locale type now retains the script subtag, keeping zh-Hans/zh-Hant
style locales distinct in the picker. The picker's clear row is pinned
above the list so it survives an empty search, watchSpellCheckAllowed
delivers on the main dispatcher, and the public story page hashes the
stored project-data hash into its validator instead of parsing the
blob per request, applying the language override after withDefaults so
chrome links keep the viewer's locale.

* Enforce single-owner persisted formats

The tags write in PromoteIdeaUseCase rewrote project_data.toml from
scratch, erasing the language seed createProject had just written: the
exact hazard of a second inline writer. It now read-modify-writes
through the datasource's scope-less helpers, and ProjectsListComponent's
hand-rolled reader delegates to a new blocking readStoredProjectData.

The rule is written down (ARCHITECTURE.md hard constraint 7, CLAUDE.md)
and enforced by PersistedFormatOwnershipTest, which fails the build when
raw TOML I/O appears outside a Datasource file. Migrators are exempt by
role; the two remaining legacy offenders are allowlisted as a burn-down
that can only shrink.

* Burn down the last raw TOML I/O outside datasources

ProjectStatisticsCacheReader now delegates to a scope-less
readProjectStatistics helper in StatisticsDatasource, and the example
project's fabricated activity log goes through writeDeviceLog in
WritingActivityDatasource, which also becomes the single owner of the
.activity path convention.

With no offenders left, PersistedFormatOwnershipTest drops its
burn-down allowlist entirely: only Datasource files and migrators may
touch persisted TOML formats from here on.

* Pass seedDefaultLanguage in the Android instrumented-test harness

* Pass seedDefaultLanguage in the round-trip sync HeadlessClient
2026-08-03 22:22:00 -07:00
Adam Brown
ad2553908b
New Crowdin updates (#843)
* Update source file strings.xml

[ci skip]

* Update source file strings_about_app.xml

[ci skip]

* Update source file strings_account_settings.xml

[ci skip]

* Update source file strings_desktop.xml

[ci skip]

* Update source file strings_drafts.xml

[ci skip]

* Update source file strings_encyclopedia.xml

[ci skip]

* Update source file strings_globalsearch.xml

[ci skip]

* Update source file strings_ideas.xml

[ci skip]

* Update source file strings_notes.xml

[ci skip]

* Update source file strings_project_home.xml

[ci skip]

* Update source file strings_project_navigation.xml

[ci skip]

* Update source file strings_project_select_navigation.xml

[ci skip]

* Update source file strings_projects_list.xml

[ci skip]

* Update source file strings_scene_editor.xml

[ci skip]

* Update source file strings_scene_list.xml

[ci skip]

* Update source file strings_sync.xml

[ci skip]

* Update source file strings_timeline.xml

[ci skip]

* Update source file strings_android.xml

[ci skip]

* Update source file full_description.txt

[ci skip]

* Update source file Messages_en.properties

[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 (Portuguese, Brazilian)

[ci skip]

* New translations strings.xml (French)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_desktop.xml (French)

[ci skip]

* New translations strings_drafts.xml (French)

[ci skip]

* New translations strings_encyclopedia.xml (French)

[ci skip]

* New translations strings_notes.xml (French)

[ci skip]

* New translations strings_project_home.xml (French)

[ci skip]

* New translations strings_project_home.xml (Spanish)

[ci skip]

* New translations strings_project_home.xml (German)

[ci skip]

* New translations strings_project_home.xml (Italian)

[ci skip]

* New translations strings_project_home.xml (Ukrainian)

[ci skip]

* New translations strings_project_home.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_project_navigation.xml (French)

[ci skip]

* New translations strings_project_navigation.xml (Spanish)

[ci skip]

* New translations strings_project_navigation.xml (German)

[ci skip]

* New translations strings_project_navigation.xml (Italian)

[ci skip]

* New translations strings_project_navigation.xml (Ukrainian)

[ci skip]

* New translations strings_project_navigation.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_projects_list.xml (French)

[ci skip]

* New translations strings_scene_editor.xml (French)

[ci skip]

* New translations strings_scene_list.xml (French)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_timeline.xml (French)

[ci skip]

* New translations strings_timeline.xml (Spanish)

[ci skip]

* New translations strings_timeline.xml (German)

[ci skip]

* New translations strings_timeline.xml (Italian)

[ci skip]

* New translations strings_timeline.xml (Ukrainian)

[ci skip]

* New translations strings_timeline.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_android.xml (French)

[ci skip]

* New translations strings_android.xml (Spanish)

[ci skip]

* New translations strings_android.xml (German)

[ci skip]

* New translations strings_android.xml (Italian)

[ci skip]

* New translations strings_android.xml (Ukrainian)

[ci skip]

* New translations strings_android.xml (Portuguese, Brazilian)

[ci skip]

* New translations full_description.txt (French)

[ci skip]

* New translations full_description.txt (Spanish)

[ci skip]

* New translations full_description.txt (Italian)

[ci skip]

* New translations full_description.txt (Ukrainian)

[ci skip]

* New translations short_description.txt (French)

[ci skip]

* New translations short_description.txt (Spanish)

[ci skip]

* New translations short_description.txt (Italian)

[ci skip]

* New translations short_description.txt (Ukrainian)

[ci skip]

* New translations strings_globalsearch.xml (French)

[ci skip]

* New translations strings_ideas.xml (French)

[ci skip]
2026-08-03 22:07:22 -07:00
Adam Brown
210696f66a
Send htmx callers somewhere when access is denied (#823)
Both access-control plugins built their htmx denial with createHTML().div, whose result was discarded, so the response was an empty <html></html> and the caller saw nothing happen.

Redirect by header instead of trying to deliver a message in the body. htmx acts on HX-Redirect before it decides what to do with the body, which matters here: it discards the body of a 4xx outright, and StatusPages answers a 401 with the whole unauthorized page, which must not end up swapped into a fragment. Browser requests keep the redirect they already had, so both kinds of caller now land in the same place.
2026-08-01 16:33:35 -07:00
Adam Brown
199bc7dc6d
Show error toasts on htmx error responses (#818)
htmx discards the body of a 4xx, so the out-of-band toasts the dashboard's rejection paths send never reached the user.

The toast helpers mark error responses with X-Hammer-Swap-Error and toast.js swaps the ones carrying it. Marking each response rather than every 4xx keeps the swap away from responses that are not swap payloads: a bare respond(BadRequest) would blank its target, and a 404 or 401 would land a whole error page inside it. Toast-only errors also reswap to none so the toast lands without emptying the request's target.

Delete-account no longer needs its 200-on-error workaround.

Fixes #812
2026-08-01 15:43:48 -07:00
Adam Brown
dd9774c34c
Self-service account deletion (#815)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Add self-service account deletion

Users can delete their account from the web dashboard danger zone. The
account is soft-deleted: locked out of login and sync, all stories
unpublished, pen name released, data retained for a configurable window
(accountDeletion.retentionDays, default 30 days) during which an admin
can restore it from the users page. A daily job permanently purges
accounts past the window. Admin accounts cannot be deleted; the guard is
enforced in the SQL, the service, and the UI.

* Harden account deletion edge cases from review

softDelete verifies the deleted flag actually landed before running its
destructive steps, and retries re-run the idempotent cleanup so a partial
failure heals; markDeleted leaves an already-deleted row untouched so
retries never extend retention. Tokens of soft-deleted accounts are
hidden inside the token query itself, restoring the whitelist-off
single-query bearer auth path. Re-registration against a soft-deleted
email returns the pending-deletion message instead of a misleading
"account exists", and the delete dialog warns that the email stays
reserved. Shared test account builder replaces per-file duplicates.
2026-08-01 02:59:30 -07:00
Adam Brown
eeba953897 Fix download link 2026-07-31 17:06:16 -07:00
Adam Brown
f9f57ca510
Redesign the home page for writers (#779)
It was very oriented towards technical details before, now it's more about hammer's value for writters
2026-07-27 13:44:55 -07:00
Adam Brown
f043cc4c0b
Sync translations from Crowdin, and fix the glob that skipped strings.xml (#777)
* Fix the crowdin.yml glob that was skipping strings.xml

Crowdin's * requires at least one character, so strings*.xml matched the
16 strings_*.xml files but never strings.xml itself. That file had been
silently absent from every sync. The composeResources values directory
only holds value resources, so *.xml is the safe form.

The android pattern is left alone: that directory also contains theme.xml
and ic_launcher_background.xml, which must not be uploaded.

* Sync translations from Crowdin

First download since the string cleanup and the export-processor fix.

- Removes the 98 retired keys from all six locales, so every values-* now
  carries the same 984 keys as values/.
- Drops Crowdin's spurious \' and \" from the Compose resources. Compose
  never unescaped those, so they were rendering on screen.
- Picks up 38 newly added server strings, which arrive as English until
  they are translated.
2026-07-27 12:30:51 -07:00
Adam Brown
ba1f7fd2c2
Address PageSpeed Insights findings for the web frontend (#772)
hammer.ink scored 90 desktop / 61 mobile on Performance (LCP 11.1s,
FCP 4.2s) with a 1.3 MiB hero image and ~3.7s of render-blocking CSS
dominating the cost. No build step is added; generated artifacts were
produced by one-time local commands and committed.

Hero image: masthead.jpg (2976x1200, 1.28 MiB) replaced with WebP
variants (163 KiB desktop, 54 KiB mobile) plus a JPEG fallback via
image-set(), preloaded with fetchpriority="high". Preload media queries
exactly complement the .masthead breakpoint so only one variant is
fetched.

Render-blocking: error.css is now delivered per-page via page_stylesheet
instead of on every request; Lora moved out of an @import in base.css to
a head <link> with preconnects; Font Awesome loads at media="print" and
is promoted by a new async-css.js with a <noscript> fallback; htmx is
deferred.

Fonts: font-display: swap on Kingthings, plus a WOFF2 (137 -> 50 KiB)
listed ahead of the TTF.

Caching: asset URLs carry ?v=<build version>. Versioned requests get
max-age=31536000, immutable; unversioned ones keep the existing 1 day /
7 day windows so stylesheet-internal references cannot go stale.

Accessibility: aria-labels on the download selects and links, heading
hierarchy fixed so each page reads h1 -> h2 -> h3 (the per-page
site-title h1 is now a span), and .footer-version raised to 6.9:1
contrast. New keys land in Messages_en.properties only.

Also sets Cross-Origin-Opener-Policy: same-origin.
2026-07-25 09:52:35 -07:00
Adam Brown
a79d2c2652
Rename SqliteTestDatabase to SharedPostgresTestDatabase (#766)
The class is backed by embedded Postgres, not SQLite, and the old name gave
no hint that the instance is a handle onto one process-wide database shared
by every test in the JVM. Document that its truncate is the only isolation
boundary, so anything that can write on its own schedule must be shut down
before a test ends.

* Serialize access to the shared test FakeFileSystem

The fake is shared by the test thread, the server's Jetty threads and the
client's dispatcher threads, and it is not thread-safe: it tracks open files
in a plain ArrayList. An autosave write on the client's dispatcher thread
while a sync read on the JUnit thread iterated that list threw
ConcurrentModificationException, failing the sync and with it
StaleAllocatorAfterSyncTest.

Wrap it in a SynchronizedFileSystem that locks every call, including the
returned Source/Sink/FileHandle — the fake mutates its open-file list when
those close, not only when they open.
2026-07-21 15:36:17 -07:00
Adam Brown
f0a0d726e9
Stop e2e tests leaking background jobs into the shared test database (#765)
EndToEndTest held the Ktor ApplicationEngine and stopped that in tearDown.
Only EmbeddedServer.stop() destroys the application, and that is the sole
place ApplicationStopping/ApplicationStopped are raised, so the recurring
jobs every e2e test started kept running for the rest of the JVM against
the process-wide Postgres. Their ticks then wrote to tables later tests had
just truncated, which is the intermittent :server:test failures on develop.

Hold the EmbeddedServer and stop that. Parent each recurring task's scope
to the application job and move its graceful stop to ApplicationStopping,
so a teardown that misses the lifecycle event still can't leave a loop
running. Assert in tearDown that no task survived the stop.
2026-07-21 01:02:53 -07:00
Adam Brown
bec0548d9f
Add operator-configurable extra nav links (#763)
Undocumented `[[extraLinks]]` config blocks append links to the header and/or footer, with optional per-locale labels. Site-relative and http(s) URLs only; malformed URLs and unusable locale keys abort startup.
2026-07-21 00:11:10 -07:00
Adam Brown
54fe6523e9
Configurable disk cache directory (#762)
The OG card and rendered story HTML caches were hard-coded to
hammer_data/cache. A [cache] block now sets the root directory and the
per-cache size bound, so an admin can move them to a scratch partition.

A configured directory resolves relative to the config file and is probed
for writability at startup: an unusable path aborts rather than degrading
to a permanent cache miss that just looks like a slow server.

* Close the gaps in cache directory validation

Validating after path resolution made the blank-directory guard dead: a
blank value resolves to the config file's own directory, which then looks
like a perfectly good absolute path, so the caches would land next to the
database instead of aborting. The cache block is now validated on the
parsed config, before resolution touches it.

The write probe only covered the cache root, but entries go in a
subdirectory per cache — a writable root holding a subdirectory owned by
someone else still degraded to a silent permanent cache miss, which is
the failure the probe exists to catch. Probing every subdirectory needs
their names in one place, so they move from string literals at the two
injection sites into a DiskCache enum.

Also: bound maxSizeMb, since a size given in bytes by mistake overflowed
the conversion into a negative cap that escaped the positive-value check;
collapse the 200 MB default to one definition; and read the e2e cache
helper's location from the config the server under test actually runs on.
2026-07-20 23:51:41 -07:00
Adam Brown
a363a2882e Rename server story "export" as "render"
Export came from client terminology
2026-07-20 23:26:07 -07:00
Adam Brown
cd54525ef8 Comments 2026-07-20 21:53:49 -07:00
Adam Brown
9c80a3c1f0
Cache rendered story HTML and serve ETags for the public reader page (#758)
Every hit on /a/{penName}/{projectName} re-rendered the story: a decrypt
and deserialize per scene, word counts for pagination, then markdown
parsing and sanitizing. That ran identically for every visitor of a story
that hadn't changed.
Renders now go through the LruDiskCache added for OG cards, keyed on the
project name plus every scene's stored content hash. A synced edit,
rename, reorder, or deletion lands on a different key and renders fresh,
so there are no eviction hooks in the write path and no upload, delete, or
end_sync path left to forget. Reading those hashes is one indexed query
with no decryption.
The same fingerprint doubles as an HTTP validator. The page shell is built
before the render so it can be hashed into a weak ETag, letting a
revalidating reader be answered 304 without rendering at all. pageETag
hashes the Mustache model itself rather than an enumerated list of inputs,
so a field added to a page joins its validator instead of quietly serving
a stale copy to everyone holding the old one.
Only stories reachable without a password are written to disk — scene
content is encrypted at rest, and a private share's decrypted prose has no
business sitting in a plaintext cache. PublicProjectResult.Success now
carries isPublic, set by the access layer from the branch it actually
took, so callers read it instead of re-deriving it from the request; a
public story fetched with a stray ?p= is no longer treated as private.
Supporting changes:
- LruDiskCache takes the injected okio FileSystem like everything else,
  through a TouchableFileSystem that adds the one operation okio omits:
  setting a modification time, which the LRU ordering depends on.
- A cache write failure degrades to an uncached render rather than
  failing the request, and a render whose scenes didn't all load is
  served but never stored.
- One DiskCachePruneJob covers every disk cache instead of a job per
  cache, and pruning is amortized over bytes written rather than run on
  every put.
2026-07-20 21:49:21 -07:00
Adam Brown
7ecc821ece
SEO metadata & rich link previews for the web frontend (#756)
* Cache static assets with Cache-Control headers

Add CachingHeaders so static assets get a public max-age — CSS/JS for a day,
images/fonts for a week — letting browsers skip revalidating them on every
navigation (ETags via ConditionalHeaders still catch changes once max-age
lapses). HTML and XML responses get no caching header. Also gate gzip on
minimumSize like deflate.

* Add canonical URLs, per-page titles, and author meta description

- Shared <head> emits <link rel="canonical"> and an optional <meta name="description">.
- Every page gets a self-referential canonical from its request path (query stripped)
  via withDefaults; the base URL prefers publicUrl and falls back to the request host.
- Story pages override the canonical to include ?page so each page is independently
  indexable (self-canonical), but never the ?p password param.
- Author and story pages get unique <title>s; author pages get a bio-derived description.

* Add OpenGraph and Twitter Card meta tags

Shared <head> emits og:site_name/type/title/description/url/image and the
twitter:card equivalents, reusing the title/description/canonical fields already
set. Defaults (type=website, image=site icon) come from withDefaults; author
pages set og:type=profile and story pages og:type=article.

* Add a branded 1200x630 default OpenGraph image

Ship a wide default share image (Hammer icon + wordmark) so social previews
render as summary_large_image cards instead of the small square icon. og:image
/twitter:image now point at it; pages can still override og:image later.

* Use branded per-type default OG images for authors and stories

Author and story pages now use type-specific static share cards (og-author /
og-story) instead of the generic default, so shared links read as an author
profile or a story at a glance. Zero setup — these ship as static assets.

* Add a generic size-bounded LRU disk cache

Stores arbitrary byte blobs keyed by string (SHA-256-hashed to a filename), with
atomic writes and approximate-LRU eviction by last-access time. put() self-bounds
to maxBytes; prune() and prune(maxAge) expose size/age maintenance for a scheduled
job. Reusable beyond the upcoming OG-image cache.

* Add OpenGraph image renderer and richLinkPreviews flag

Headless-AWT renderer for 1200x630 share cards (icon + wordmark + wrapped title
+ subtitle), loading the Kingthings TTF once. Adds the richLinkPreviews config
flag (default false; needs native font libs). Wiring to routes comes next.

* Wire dynamic OpenGraph images behind richLinkPreviews

With the flag on, author/story pages point og:image at per-entity endpoints
(/a/{pen}/og.png, /a/{pen}/{project}/og.png) that render personalized cards via a
disk cache (OgImageService over LruDiskCache) and 404 non-public entities. A
recurring job prunes the cache by age. With the flag off (default), pages fall
back to the branded static cards, so no font libraries are needed. Documents the
fontconfig/libfreetype6 requirement.

* Fix OG card layout so the subtitle never collides with the accent bar

Use fixed title/subtitle baselines instead of spacing relative to the title
height, so a 3-line (wrapped/truncated) title no longer pushes the subtitle off
the bottom. Slightly smaller title font to fit three lines cleanly.

* Redesign OG story card and localize share-card labels

Story card now leads with the title as the hero (inline open-book mark,
full-width wrap below), the author beneath it, and a footer attribution
pinned to the bottom. Secondary text is larger and darker for legibility
when the card is scaled down to a chat unfurl.

All card labels are localized: the renderer takes them as parameters and
the route resolves them via the existing ResourceBundle i18n. Reuses
public_story_by; adds og_attribution and og_author_subtitle (the latter a
{0} template filled with the server's own host). Cache keys are built from
the exact render inputs, so language and host variations regenerate.

* Add JSON-LD structured data to author, story, and home pages

A typed schema.org builder (kotlinx-serialization) emits ProfilePage/Person
for community author pages, Article for publicly-published stories, and
WebSite for the home page. The header template renders it into a
<script type="application/ld+json"> block when the model carries jsonLd.

Author-supplied text is escaped (< -> <) so it can't break out of the
script block. Structured data is emitted only for indexable pages.

* Add per-page titles and meta descriptions to public pages

Home, About, Community Authors, and Story Feed pages now set descriptive
<title>s and meta descriptions (reusing existing localized subtitles).
Public story pages get a meta description built from the title and author.
These feed the description/og/twitter tags the header already renders.

* Add Subresource Integrity to the htmx and Font Awesome CDN links

Pin the exact bytes of the two third-party assets so a compromised CDN
can't inject altered code: the browser blocks either file if its hash
doesn't match. Hashes verified against the served bytes (htmx cross-checked
across unpkg + jsdelivr, Font Awesome against cdnjs's published SRI);
crossorigin=anonymous added since both CDNs send Access-Control-Allow-Origin.

* Resolve dynamic OG images by stable id instead of caller-supplied strings

The share-card routes now take an account id / project UUID and render only
fields read back from the database — the pen name, story title, and the
subtitle host (from publicUrl config, never the request Host). Nothing the
caller supplies is drawn into the image or mixed into the cache key.

The author/story pages emit the dynamic og:image URL only when the subject
actually qualifies (community author; public, password-free story), matching
what the route will serve — so a share preview is never a broken 404 that
should have fallen back to the static card. A malformed project UUID is
rejected before the query so it can't raise a Postgres cast error. Rendering
moves off the event loop.

Adds findPublicProjectByUuid and route-level gating tests.

* Harden OG image caching, render concurrency, and cache stampede

- Scope Cache-Control by path: dynamic OG cards under /og cache for 30 days
  (the disk-prune window), /assets keep their type-based rules, everything
  else stays uncached. Previously any image response — including the dynamic
  OG PNGs — was matched by content type and cached for a week.
- Make LruDiskCache.getOrPut single-flight per key (striped locks) so a
  scraper burst on a viral link renders the card once, not once per request.
- Collapse the duplicated wrapText into a delegation to wrapTextIndented.
2026-07-20 00:17:23 -07:00
Adam Brown
a337b058a4
Add a bytecode guard for responds in value position (#755)
Scans compiled route handlers for the Unit coercion the compiler emits
when a respond is the value of an exhaustive when, which is the shape
that broke the bio endpoint and is invisible in source review.

Includes a positive control: a fixture written in the unsafe shape that
the detector must keep flagging, so a codegen change fails the test
instead of silently disabling it. Also asserts a floor on the number of
handlers scanned so a broken filter can't pass vacuously.
2026-07-19 20:31:26 -07:00
Adam Brown
52f774822b
Fix ClassCastException when saving a bio (#752)
* Fix ClassCastException when responding from a when branch

Ktor's respond* functions are tail-call suspend functions that return the
send pipeline's subject instead of Unit. When such a call is the last
expression of a route handler's when/if, the compiler casts that value to
Unit and the handler fails once Compression has rewritten the response.

Route the toast helpers through a respondHtml that genuinely returns Unit,
and keep end_sync's branches out of value position.

* Fix the bio ClassCastException by restructuring, not a no-op call

Drop the forceUnitReturn() barrier in ToastUtils and the return@post in
end_sync. The when in the bio handler now yields a BioOutcome and the
handler responds once at the end, so no respond sits in value position
and nothing relies on a compiler workaround.

end_sync was never affected: its lone checkcast is a spilled-local
restore, present before the change too. Only an exhaustive enum when
materializes its branch values.
2026-07-19 19:40:39 -07:00