Commit graph

2960 commits

Author SHA1 Message Date
Adam Brown
3e259bcf10 Merge branch 'fix/android-scrollbar-side' into develop 2026-06-22 11:27:14 -07:00
Adam Brown
ae1c0cb095 Fix Android scrollbar rendering on left instead of right
The Android MpScrollBarList dropped its modifier parameter, so the
Alignment.CenterEnd passed by the project list never reached the Canvas.
The bar fell back to the Box default alignment (TopStart) and rendered
on the left. Thread the modifier through to the Canvas.
2026-06-22 10:51:48 -07:00
Adam Brown
c5e155adb9 Keep scene list footer reachable when list can't scroll (#615)
The Outline Overview and add buttons auto-hide on scroll-down and only
reappeared on scroll-up. Once the list became non-scrollable (e.g. all
groups collapsed) while hidden, nothing could flip them back. Tie footer
visibility to canScrollForward so it always shows when the list can't
scroll down to it.
2026-06-22 10:51:02 -07:00
Adam Brown
8d41946d77
New Crowdin updates (#631)
* New translations strings.xml (French)

[ci skip]

* New translations strings.xml (Spanish)

[ci skip]

* New translations strings.xml (German)

[ci skip]

* New translations strings.xml (Italian)

[ci skip]

* New translations strings.xml (Ukrainian)

[ci skip]

* New translations strings.xml (Chinese Simplified)

[ci skip]

* New translations strings.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings.xml (French)

[ci skip]

* New translations strings.xml (Spanish)

[ci skip]

* New translations strings.xml (German)

[ci skip]

* New translations strings.xml (Italian)

[ci skip]

* New translations strings.xml (Ukrainian)

[ci skip]

* New translations strings.xml (Chinese Simplified)

[ci skip]

* New translations strings.xml (Portuguese, Brazilian)

[ci skip]
2026-06-22 10:28:54 -07:00
Adam Brown
a4fa14c6c3 Add two new shortcuts for closing the project window
Ctrl + W - Closes the project, returns to project selection
Ctrl + Q - Close the application

Resolves #614
2026-06-22 10:15:11 -07:00
Adam Brown
bf5277a056 Warn before discarding in-progress timeline edits on project close (#588)
Exiting a project with auto-sync on runs requestClose(), which queued
CloseConfirm.Sync and tore down open editors before the sync ran. Scenes,
notes, and encyclopedia entries flagged unsaved edits via shouldConfirmClose(),
but TimeLineComponent returned emptySet(), so an in-progress timeline event
edit was silently discarded with no warning.

Wire TimeLineComponent.shouldConfirmClose() to the existing isEditingAndDirty()
check and add a CloseConfirm.Timeline confirmation dialog on Android/common and
desktop, mirroring the notes/encyclopedia pattern.

Closes #588
2026-06-22 10:09:23 -07:00
Adam Brown
85c7913eba Strip dep info blob from fdroid builds 2026-06-22 02:08:29 -07:00
Adam Brown
6be3651f9f Show snackbar on "Save All" 2026-06-22 01:54:39 -07:00
Matt Van Horn
9e5a3103f2
feat: add Ctrl+Alt+S save-all keyboard shortcut on desktop (#626)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-06-22 01:46:33 -07:00
renovate[bot]
a9efa340d3
Update hikaricp to v7.1.0 (#629)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 01:41:53 -07:00
Adam Brown
e7d2d96616 Render only visible scene-tree nodes to fix group label flicker (#613)
The scene tree emitted every node as a LazyColumn item and hid collapsed
descendants with AnimatedVisibility, leaving them as zero-height rows. On a
large, mostly-collapsed tree the LazyColumn composes through those trailing
zero-height rows trying to fill its viewport, and combined with animateItem()
the last visible row's placement never settles -- it flickers.

Compute the visible nodes (everything except the root scene and anything under
a collapsed ancestor) and emit only those as items. Collapsed subtrees are
absent rather than zero-height, so there is nothing for the list to churn over.
Drag/drop is keyed by node id, not list index, so reordering is unaffected; it
also no longer sees zero-height rows as drop targets.
2026-06-22 01:35:01 -07:00
Adam Brown
52988c3489 Match backups by directory encoding so they appear in Manage Backups (#612)
Backup filenames used an ad-hoc, lossy `space<->underscore` transform while
project directories use encodeForFilename. Two failures fell out of the
mismatch: names with underscores never matched their project on read (the
backup vanished from Manage Backups), and names with now-allowed OS-forbidden
characters produced filenames that can't be written on Windows/Android, so the
backup was silently never created.

Write backups using the same encodeForFilename as the project directory, and
match a file to a project by comparing its name-key against that encoding, with
the legacy `space->underscore` name accepted as a fallback so backups written
by older clients are still found.
2026-06-22 01:23:42 -07:00
Adam Brown
584cf62271 Allow Windows reserved names in scene/group titles (#586)
Scene and group titles are stored wrapped as `order~name~id`, so a leading
dot or a Windows reserved word (CON, PRN, COM0-9, LPT0-9, ...) can never
collide on disk for them — yet name validation rejected them as if the title
were a raw filesystem basename. Project names, which become directories
verbatim, keep the strict rules.

- ProjectNameValidator.validate gains usedAsRawFilename (default true); the
  leading-dot and reserved-name checks now only apply to raw filenames.
  Trailing dot/space stay rejected for everyone (the on-disk encoder strips
  them). Reserved set extended with COM0/LPT0.
- Thread the flag through ProjectsRepository.validateFileName and
  SceneRepository.validateSceneName.
- Consolidate the six naming dialogs onto a shared rememberNameValidation
  hook keyed by NameKind (Project vs SceneItem), so strictness lives in one
  place instead of a per-call-site boolean.
2026-06-22 01:10:52 -07:00
Adam Brown
88b7e586fd
Fix formatting of warning message in documentation 2026-06-21 21:11:38 -07:00
renovate[bot]
52dc757354
Update koin.bom to v4.2.2 (#622)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-21 21:11:14 -07:00
Lindsay
1ed25d978f
Minor changes to server run doc (#627)
minor updates, particularly regarding DNS requirements for access and bindHost usage in the reverse proxy configuration for security.
2026-06-21 18:04:07 -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
2e7bdc3399
Make markdown import map heading levels to scene/group hierarchy (#625)
The importer split on a single exact heading level and treated every
other level as plain body, so round-tripping a document (including
Hammer's own export of `# Title` + `## Chapter`) collapsed everything
into one scene when H1 was chosen and produced a spurious "Untitled"
scene for the title when H2 was chosen.

Fold the heading stream into a hierarchy instead: headings shallower
than the chosen level open groups, headings at the chosen level open
scenes, and deeper headings stay as scene body. Leading content that is
only headings/whitespace no longer becomes an Untitled scene. Heading
detection now tolerates a BOM and up to three spaces of indent, and
scene bodies are trimmed both ends so the blank line after a heading
does not leak into content.

Fixes #578
2026-06-20 10:16:12 -07:00
Adam Brown
8927347aca
Fix backup culling deleting the newest backups (#624)
Order backups by file modification time instead of the date parsed from
the filename. Backups written before the date-format fix used a broken
format (ISO week-based-year YYYY and 12-hour hh), so late-December-2025
backups were stamped months in the future. Sorting by that encoded date
made those phantom-future files look newest, so culling kept them and
deleted the genuinely newest backups instead.

Also broaden the backup filename pattern so project names containing
characters outside [a-zA-Z0-9_] (apostrophes, hyphens, non-ASCII) are
recognized, and stop date parsing from throwing so one malformed
filename can't blank the entire backup list.
2026-06-20 10:16:00 -07:00
Adam Brown
c39f1b7854 Update spell check lib to fix crash on Android 2026-06-20 01:01:39 -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
1fd9a016b3 Cleanup some docs files 2026-06-20 00:12:28 -07:00
renovate[bot]
233d0cf094
Update jna monorepo to v5.19.1 (#598)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-20 00:05:44 -07:00
Adam Brown
3318c1f284
Make SceneTree state Compose-stable with immutable collections (#621)
Mark the scene-tree state types @Immutable/@Stable and move them onto
kotlinx.collections.immutable so Compose can skip recomposition when the
tree is unchanged: TreeValue.children becomes ImmutableList, SceneSummary
.hasDirtyBuffer a PersistentSet (sourced as such from SceneContentRepository),
and SceneList.State.archivedScenes an ImmutableList. Also cache ImmutableTree
.nodeIndex/hashCode lazily and gate compose-compiler stability reports behind
the composeCompilerReports property.

* Harden onSceneBufferUpdate to reduce from oldState

Read the scene summary from the getAndUpdate lambda's oldState argument
instead of a snapshot captured before the CAS, so the reducer stays a pure
function of its input and composes correctly if buffer updates ever run off
the main dispatcher.
2026-06-19 23:29:41 -07:00
Wavesonics
d157ca83c4
Explain why self-signed won't work 2026-06-19 19:04:29 -07:00
Wavesonics
d5ea29151c
Remove self signed certs section
They won't work with client API calls, so they are worthless to us
2026-06-19 19:01:54 -07:00
Adam Brown
a08f42d8ee Clean up and clarify how to run server doc 2026-06-19 18:14:47 -07:00
Lindsay
3d52281e7f
Reverse proxy docs (#592)
* Update HOW-TO-RUN-A-SERVER.md

adding user and systemd setup.

* Update HOW-TO-RUN-A-SERVER.md

adding note about the installation directory fill.

* Update HOW-TO-RUN-A-SERVER.md

Update run.sh under linux

* Update HOW-TO-RUN-A-SERVER.md

Add caveat about using port 80.

* Update HOW-TO-RUN-A-SERVER.md

simplified bash run.sh and added where to put the run.sh script.

* Update HOW-TO-RUN-A-SERVER.md

Added steps for using Nginx as a reverse proxy for Hammer.

* Update HOW-TO-RUN-A-SERVER.md

Explicitly call out not to use the SSL steps  for Java when using Reverse Proxy.

* Update HOW-TO-RUN-A-SERVER.md

fixed LetsEncrypt paths for copy-pasta.
added full location block to https example instead of the [...].
added missing file link step.
added the missing http to https redirect as well as a note about LE doing it.
removed dhparams line because unnecessary and may not be auto generated.
2026-06-19 18:07:36 -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
Wavesonics
83785c9f89
Fix SL4J being stripped out of desktop builds 2026-06-19 17:23:11 -07:00
Wavesonics
c6b049318d
Bump coreKtx 2026-06-18 15:32:06 -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
3f6da503ae Improve docs 2026-06-17 22:59:27 -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
408a2b720e Replace secret-storage design docs with a user-facing admin guide
The feature has shipped (PR1-PR5), so swap the temporary working docs for an
operator guide: SERVER-SECRET-STORAGE.md is now "Encryption at rest & key
management" (the keyring, enabling/disabling encryption, generate-keyring /
inspect-keyring / rotate-key, --converge-dry-run, deleting an old key, and
upgrading an already-encrypted server). Linked from HOW-TO-RUN-A-SERVER.md.

Deletes the implementation plan doc.
2026-06-17 02:01:58 -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