Commit Graph

999 Commits

Author SHA1 Message Date
jubnl 5870dd4fda feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim 2026-08-17 06:23:13 +02:00
jubnl 616897b60b fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep 2026-08-17 06:19:31 +02:00
jubnl 0962df2f22 refactor(journey): photo deletion through storage 2026-08-17 06:17:24 +02:00
jubnl 939c2ec3e8 refactor(memories): immich upload reads through storage; drop uploads-root 2026-08-17 06:15:01 +02:00
jubnl 364f376b07 refactor(memories): thumbnail service category-addressed 2026-08-17 06:08:04 +02:00
jubnl 1a2e4d4ef2 refactor(memories): trek photo cache internals through storage 2026-08-17 06:05:52 +02:00
jubnl f8faef77c4 refactor(files): trash deletion through storage 2026-08-17 06:01:28 +02:00
jubnl f382a7635e refactor(collab): note-file deletion through storage 2026-08-17 05:51:44 +02:00
jubnl dfb8f8a8a4 refactor(auth): avatar reclaim through storage 2026-08-17 05:47:31 +02:00
jubnl 8a3327c5f6 refactor(trips): trip covers through storage 2026-08-17 05:37:52 +02:00
jubnl 965566369e refactor(places): reclaim place images and collection covers through storage 2026-08-17 05:31:53 +02:00
jubnl 4eca78b53e refactor(place-photos): route place-photo cache through storage 2026-08-17 05:20:09 +02:00
jubnl d27e54952a test(storage): raise src/nest/storage branches pin 2026-08-16 18:36:43 +02:00
jubnl 31478e232a test(server): close serving-swap gaps surfaced by the full tier 2026-08-16 18:36:38 +02:00
jubnl 94490b7d31 fix(share): answer place-photo cache miss with 204 like the maps proxy 2026-08-16 11:16:17 +02:00
jubnl afd6d297c0 refactor(journey): serve public journey assets through storage 2026-08-16 11:12:28 +02:00
jubnl 1bf6517b75 refactor(share): stream shared place-photo bytes through storage 2026-08-16 11:07:04 +02:00
jubnl 786b1f5c07 refactor(maps): stream place-photo bytes through storage 2026-08-16 11:04:04 +02:00
jubnl 980f8be6e8 refactor(memories): serve photo bytes through storage 2026-08-16 10:54:06 +02:00
jubnl a0a5bb8203 refactor(files): serve trip-file downloads through storage 2026-08-16 10:50:28 +02:00
jubnl 4527e8688d refactor(platform): serve /uploads routes through storage 2026-08-16 10:46:01 +02:00
jubnl 36f3f89e14 test(storage): raise src/nest/storage coverage pin 2026-08-16 10:08:51 +02:00
jubnl 9ef886bbdd test(files): key FILE-P02 residue checks on upload size 2026-08-16 10:08:33 +02:00
jubnl db3c5da04e fix(storage): age-gate the boot spool sweep 2026-08-16 09:53:26 +02:00
jubnl 92c87d1f3a test(db): re-point the trailing-migration guard at the trip_files prefix strip 2026-08-16 09:53:26 +02:00
jubnl f9d84f5907 fix(storage): default defParamCharset to utf8 in the upload factory 2026-08-16 09:53:25 +02:00
jubnl b72645272b fix(server): consolidate allowed_file_types onto AllowedFileTypesService 2026-08-16 09:49:46 +02:00
jubnl c75d431e4c refactor(auth): spool avatar upload through storage 2026-08-16 09:46:27 +02:00
jubnl 8b0c768ba0 refactor(places): spool place-image and collection uploads through storage 2026-08-16 09:44:03 +02:00
jubnl 7e91f1abaf refactor(trips): spool trip cover upload through storage 2026-08-16 09:38:26 +02:00
jubnl b1abfd2110 refactor(journey): spool journey media uploads through storage 2026-08-16 09:34:48 +02:00
jubnl fd81ee00f1 refactor(collab): spool note attachments through storage; normalize files/ prefix 2026-08-16 09:29:46 +02:00
jubnl 428d32b18a refactor(files): spool trip-file uploads through storage 2026-08-16 09:25:19 +02:00
jubnl 7ae7295e1e feat(storage): multer upload options factory 2026-08-16 09:21:15 +02:00
jubnl bad01a2fd6 test(storage): pin src/nest/storage coverage thresholds 2026-08-16 00:27:55 +02:00
jubnl 75d5bbcfc2 feat(storage): settings-backed registry, StorageService facade, module wiring 2026-08-16 00:21:22 +02:00
jubnl 09d27d633a feat(server): add TREK_UPLOADS_DIR env var for the uploads root 2026-08-16 00:05:12 +02:00
jubnl 5ad1670bd9 feat(storage): MirrorDriver composite backend 2026-08-16 00:00:26 +02:00
jubnl 19f18a2b3a feat(storage): storage key validation, driver contract suite, LocalDriver 2026-08-15 23:54:43 +02:00
jubnl b1e4c830b2 fix(immich): strip leading / in immich url (#1948) 2026-08-15 23:05:44 +02:00
jubnl 2bffee8fd4 fix(auth): preserve remember-me when password change re-issues the session (#1927) 2026-08-15 22:48:22 +02:00
jubnl 72221a2e45 feat(auth): sliding session renewal past half the token lifetime (#1927) 2026-08-15 22:48:16 +02:00
jubnl 61e03fdfde feat(oidc): honor remember-me in SSO login (#1927) 2026-08-15 22:48:03 +02:00
Maurice 3a0604b3f5 feat(config): put the operator-owned surfaces behind TREK_MANAGED (#1946)
* feat(config): put the operator-owned surfaces behind TREK_MANAGED

The switch landed last release with nothing behind it. This is what it turns
off, plus the four fixes that came out of walking every one of these surfaces.

Nothing here changes anything for an install that does not set the flag. That
is not a hope, it is what managed-inert.test.ts asserts: the mode stays off for
any unparseable value, no other variable can turn it on, every settings key
stays writable, and splitManagedKeys hands back the same object rather than a
copy so no write path can quietly diverge on self-hosted installs only.

WHAT THE MODE WITHHOLDS

Fourteen routes carry @ManagedForbidden: key validation, static MCP tokens, JWT
rotation, the OIDC admin pair, restore and auto-backup, plugin sideload and
reload, the Ollama model list and pull, and the SMTP test send. Each names the
reason it is not the instance admin's, and the reason is mandatory.

Eighteen settings keys are filtered instead of refused. SMTP, the OIDC toggles
and the WebAuthn pair share one handler with keys the admin does own, and the
llm_* family is reachable through per-user settings, the instance defaults and
the addon config alike, so a route-level refusal would be both too coarse and
incomplete. A locked key is skipped and reported as managed_keys; the rest of
the save goes through. Refusing the request would take the registration toggles
down with the SMTP block.

GET /api/auth/me/settings stops returning the three API keys in the clear. It
is the only endpoint that decrypts a stored credential back to the caller, which
is fine when the admin pasted it in and wrong when the operator supplied it.

Two multipart routes check in the handler rather than the guard: a guard throws
before the parser drains the body and the client gets an ECONNRESET instead of
the 403 (PROFILE-015). They still carry the marker so the boot gate sees them.

WHAT KEEPS IT FROM DRIFTING

validate-managed-routes.ts refuses to boot on a marking that is not on the
reviewed list, a list entry whose route lost its marking, or a marking with no
reason. The third case has no equivalent in the guard gate next door and is the
one that matters most: a blank reason destroys the only record of why a control
was taken away, and nothing recovers that later. It found two wrong entries in
my own list while I was writing this.

managed-keys.test.ts asserts that every key in ADMIN_SETTINGS_KEYS and
DEFAULTABLE_USER_SETTING_KEYS belongs to exactly one of two sets. A key added to
either list belongs to neither until somebody decides, and that is the failure
this prevents: the lists grow, the mode does not.

PLACES_API_BASE AND PLACES_API_KEY

The nine Places endpoints spell out googleapis.com, so an install that wants
those calls to leave through something of its own has no way to say so. One
variable, substituted where every call already funnels through. A second one
supplies the key, and when it is set the users table is not consulted at all —
a per-user key would route around whatever the endpoint counts, and those
columns are readable by any admin of the instance.

FOUR FIXES THAT ARE NOT ABOUT THE MODE

An admin setting somebody's password now bumps password_version and clears
their MCP tokens. An admin sets a password for one reason, and until now
verifyJwtAndLoadUser kept accepting every cookie the intruder already had: the
one action taken to lock them out was the one action that did not.

The four OIDC provider calls go through the SSRF guard. Three are worse than
they look — the token, userinfo and JWKS URLs come out of the discovery
response, so they are chosen by the other end, not by an admin. safeFetchLlm is
renamed safeFetchAdminConfigured and keeps its old name as an alias, because it
is the variant that allows loopback and LAN: a self-hoster with Authentik on
localhost:9000 must keep working. The token exchange follows no redirect at all,
since one would hand client_secret to a second host.

DELETE /api/admin/users/:id/mfa clears another account's TOTP. The passkey half
has existed since passkeys landed; the TOTP half never did, which left
"somebody on the trip lost their phone" with no answer short of an operator in
the database. Never for the caller themselves.

TREK_PLUGIN_PERMISSIONS=off refuses to boot when the install is centrally
administered. A warning is right for a machine its owner chose to trust; where
the person who set the flag and the people whose data is in the database are
not the same person, fail closed.

TEXTS

Four strings lose the phrase "self-hosted" because they describe somebody
else's software — an Ollama endpoint, an AirTrail instance, a service a plugin
talks to — and read better without it in both modes. The one string that
describes TREK itself keeps it, and the mode picks a second key instead, so
self-hosters lose nothing.

A grep gate in shared/ keeps the canonical locale honest, because the parity
check compares the file set and the top-level keys and never looks at a value:
re-wording a string is invisible to CI today, and so is putting it back.

* feat(maps): supply the map credential with the instance when centrally administered

MAPBOX_ACCESS_TOKEN joins PLACES_API_KEY as an operator-supplied value.
SettingsService injects it when the settings are read, and picks GL as the
provider if the reader is still on Leaflet, since a token is always present.

The key moves from the customer set to the locked set. It is the one locked key
whose value is public by design — a pk.* token has to reach the browser to be
useful — so this is not about secrecy: a per-user save landing on top of the
instance token would only break the map for that user. Both settings fields for
it go away with it.

* fix(auth): count the operator key when reporting whether Google features work

has_maps_key only looked at the users table, but getMapsKey prefers the
PLACES_API_KEY env over anything stored there. On an install that supplies the
key that way and has an empty column, every lookup worked while the client was
told there was no key and hid the search, the autocomplete and the place photos.

Found by walking the running instance rather than the code: the map came up,
the search box did not.

* feat(admin): hide the settings a centrally administered install cannot act on

A control that answers 403 is worse than no control: the AI section rendered the
refusal as "Request failed with status code 403", which tells an admin they broke
something rather than that the decision is not theirs.

So the surfaces go rather than being disabled in place:

- AI parsing and AirTrail leave the addon list, and the AI addon route now
  refuses outright. The endpoint, the model and what a document costs come with
  the instance, so there is no instance decision left — not even whether it runs.
- Immich and Synology Photos leave it too. Both connect to a server the reader
  runs at home, and there is no route from here to one.
- The SMTP block, the admin webhook and ntfy targets, and the passkey RP ID and
  origins follow the same rule: they configure something the operator owns, and
  a wrong RP ID on a shared parent domain reaches past this instance entirely.
- The About tab goes. It is about the project — what TREK is, where to file a
  bug, where to support it — and is aimed at the person running the software.
  The version and the link to the source move to the sidebar footer, on both the
  desktop page and the mobile twin, because AGPL 13 wants that offer wherever
  people reach the software over a network.
- The update check stops asking GitHub and reports nothing available.

Separately, the version footers lose the "self-hosted" suffix everywhere, not
just here. It read as a label on the software rather than on the install, and
the version alone says what it needs to.

* feat(config): refuse to boot centrally administered without an at-rest key

Measured on a running instance rather than assumed: with ENCRYPTION_KEY unset,
the at-rest key falls back to a file in the data volume, and backupService
bundles that file into every archive it builds. The archive is downloadable by
any instance admin, so the encryption ships with its own key in the same zip.

The skip in backupService already handles this — it only omits the file when the
key came from the env — but nothing made sure the env actually carried one.
Provisioning sets it; forgetting it was silent.

validateEnvAtBoot now carries a second kind of check next to the schema: cross-
field rules that only apply with the flag set, for combinations where every value
is individually valid and the result is wrong. Empty without the flag, so an
install that never asked to be centrally administered cannot meet it.

* feat(client): add an attachment point for install-specific screens

Two empty exports on the client, an empty module on the server, and the four
places that read them. Nothing here adds a route, a nav entry or an endpoint —
the arrays are empty and the module contributes nothing, which managed-ext.test
pins so it stays that way.

Read unconditionally, never behind a flag. On the server a conditional import
would tie the module graph to an environment variable, and the graph is built
once per app while the flag is read live; on the client an empty array costs
nothing to spread, so every build walks the same path instead of one build
walking a path nobody tests.

Anything attached here follows the same rules as the rest of src/nest and
src/components: a real module with a controller and a provider, bodies through
the Zod pipe, routes under the global guards, tests.

* test(shared): move the wording gate out of src so shared typechecks again

CI installs shared with its own dependencies only (npm ci --workspace shared),
so there is no @types/node and the `node:fs` import inside src/ failed to
compile. The tests passed the whole time — vitest supplies the module — which is
why this only showed up in the typecheck step.

The walk moves to scripts/, next to i18n-parity.mjs, and the spec imports it the
same way that one does. It also runs on its own now
(`node scripts/i18n-selfhost-wording.mjs`), which is what you want when CI names
a line you have to go find.

* test(server): repair the call sites typecheck:tests caught

tests/ sits outside the build tsconfig, so none of this showed up in a green
test run — which is exactly the gap `npm run typecheck:tests` exists to close.
Three kinds, all mine:

- Seven `import type` paths for RuntimeEnvService carried one `../` too many.
  Type-only imports are erased, so vitest never resolved them and nothing failed.
- VersionCheckJob and PluginsController grew a constructor parameter and eleven
  hand-wired `new` calls kept passing the old count. The argument arrived as
  undefined and no assertion happened to read it.
- ConditionContext grew `managed`, and the shared context object in the
  conditions suite did not.

The condition also had no test of its own, so it has one now: both directions,
since the registry uses `is: false` to keep a notice away and a notice that only
belongs on a centrally administered install would use `is: true`.
2026-08-15 16:42:27 +02:00
Maurice bfb3bcd63c feat(config): mark an install as centrally administered
Some installs are run by somebody other than the person who administers them.
The operator owns the credentials, the configuration and the upgrade schedule,
and the admin of the instance is a user of the result rather than the one who
set it up. Nothing in the app knows that today, so it keeps offering settings
whose values are not the admin's to change, and the honest answer to half of
them would be a 403 the UI never anticipated.

TREK_MANAGED says it once. It is a single boolean and the surfaces that care
read it themselves, so adding one never comes back to the config layer.

Runtime-toggled, in the same class as DEMO_MODE, rather than a registerAs
token: roughly sixty tests mutate env mid-lifetime, and a value frozen per
built app breaks all of them. `=== true` on the way in, so an unset, empty or
misspelled value means off — a managed install sets it deliberately, and a typo
must not quietly take settings away from an admin who never asked for it.

The guard keys on a decorator, not on a path. MfaPolicyGuard documents where
the two literal path lists it replaced ended up: they stopped being maintained,
and every endpoint added afterwards silently fell on the wrong side. A marker
on the handler cannot drift away from the handler. It carries a mandatory
reason, which is for the next reader of the source rather than for the client,
so the marks stay auditable.

It registers third, after the auth and MFA guards, so a stranger still gets the
401 those two would have given them instead of a 403 that would confirm the
route exists. And it checks the mode before it touches metadata, because it
runs on every request of every install and almost all of them are self-hosted.

One 403 body, shared by REST and MCP. DEMO_MODE grew two texts for the same
condition and neither can be changed now without hunting for the other.

The flag reaches the client through app-config so the UI can stop offering what
the server would refuse anyway, but it is deliberately not persisted the way
demo_mode is: two installs can share a browser profile, and a stale "this one
is managed" would take settings away on an install that never set it.

No route carries the marker yet. The per-domain marks follow. Unset, which is
every self-hosted install, nothing about this changes anything.
2026-08-15 11:45:11 +02:00
Maurice c9193e0621 fix(maps): answer a keyless place lookup with an empty result, not a 400
An instance without a Google key already runs on the OpenStreetMap stack: search
falls back to Nominatim, autocomplete does too, photos fall back to Wikimedia.
The two details lookups were the exception. Both threw a 400 with "Google Maps
API key not configured" the moment they were handed a Google place id.

A Google id has no OpenStreetMap equivalent, so without a key there really is
nothing to resolve. That is an empty result, not a caller mistake. The callers
already treat a null place as a miss and fall back to the text-search path, so
they were doing the right thing with the wrong signal, at the cost of an error
round-trip and a logged exception each time.

It bites whenever a place that was saved while a key existed is opened after the
key is gone, and on any instance that never had one but imported Google-sourced
places from a shared trip.

The declared return type said the place was non-nullable while the OSM branch
could already return null and the wire contract has always had it nullable. It
now says what it does.
2026-08-14 23:42:51 +02:00
Maurice f02a4b5654 feat(maps): bill a place search as one session instead of per keystroke
Google charges autocomplete per request unless the requests carry a session
token. With one, the keystrokes of a single search and the details lookup that
ends it are charged together, which is also how a user experiences it: they
looked up one place.

TREK sent no token at all, so every debounced keystroke was its own billable
request. On a busy trip that is the largest line on the Places bill, and it is
paid by whoever owns the key, which for a self-hosted instance is the person
running it.

A session opens with the first suggestion request and closes when the user picks
a result. It has to close on the abandoned paths too: a token carried into the
next search would merge two searches into one billed session and scope the
suggestions wrongly. So the search box clearing it, the text-search fallback and
the error paths all end it.

The token is validated on the way in. The autocomplete body goes through the Zod
pipe, the details query does not, so the controller shape-checks it there.
Anything that does not match Google's URL-safe 36-character form is dropped
rather than forwarded, which falls back to per-request billing instead of
breaking a lookup.

A cached details hit never reaches Google and so never closes its session. That
is billing-neutral: an unclosed session is charged as a plain autocomplete
session, which is still cheaper than the per-request behaviour it replaces.
2026-08-14 23:42:51 +02:00
Maurice f23de88b74 fix(packing): authorize every single-item path against the item, not just the trip
A packing item is Common, Personal or Shared with named recipients. listItems()
always applied that rule, but every path that touches one item resolved it by
trip id alone, so trip membership plus packing_edit was enough to reach an item
the member could not see. Update, delete, clone, the contributor routes and the
sharing route all shared the lookup, and the same service methods sit behind the
MCP tools and the plugin RPC, so all three transports carried the gap.

The update was a read as well: it answered with the enriched row, which hands
back the name, category, quantity, weight, the owner's username and the recipient
list of an item the caller was never shown.

Two flows laundered restricted items into places they do not belong. Saving a
trip as a template read every row in the trip, so a template captured the names
and categories of everyone's Personal items. Copying a trip did the same and
re-inserted without is_private/owner_id, so both fell back to the column defaults
and another member's Personal or Shared item reappeared in the copy as a Common
item. That path needs only trip_create and trip access, not even packing_edit.

There is now one visibility rule in the service and every single-item lookup goes
through it. The actor is threaded through the HTTP, MCP and plugin-RPC entry
points, and a missing actor denies rather than falling through unfiltered. An
item the actor may not see is reported as missing, not as forbidden, so the
routes cannot be used to confirm that an id exists.

Two deliberate contract changes fall out of that. The sharing route answers 404
instead of 403 when the caller cannot see the item at all; a visible item they do
not own still answers 403. And a template or a trip copy now carries the Common
list plus the actor's own items, with a restricted item staying restricted and
owned by the copier.

Reported privately by Chichi (@vickyzer027-hash).
2026-08-14 22:09:21 +02:00
Maurice 4cab17a6b9 fix(airtrail): read and write the passenger list under both its names
AirTrail 3.12.0 renamed the flight's passenger list from `seats` to
`passengers` and moved flightReason from the flight onto each passenger. There
is no alias in either direction: a 3.12.0 instance returns `passengers` and
`null` for `seats`, and its save schema accepts only `passengers`.

TREK read `raw.seats` everywhere, so against a 3.12.0 instance it found nothing.
The seat never made it into the import, and because a pull rebuilds the metadata
from the AirTrail payload, flights that had already been imported lost the seat
they had. That is the report. Flights whose sync is switched off were never
re-pulled, which is why theirs survived.

Two more breaks came from the same rename and were not reported. The flight
reason stopped importing for the same reason. And the writeback pushed the seat
under `seats`, which the new schema strips, so a seat edited in TREK was quietly
dropped on the way out; it did not fail loudly because the spread of the fetched
flight carried AirTrail's own `passengers` through untouched.

Both shapes are handled rather than switched between, because instances on 3.11
and older are still out there: reads take `passengers ?? seats`, and the save
body carries the manifest under both keys. Neither schema is strict, so each
version keeps the key it knows and strips the other.

The snapshot hash deliberately keeps feeding the manifest in under the same key.
An unchanged flight on an older instance therefore hashes exactly as before and
does not re-sync, while a 3.12.0 flight hashes differently once and pulls the
missing seat back in. There is a pinned-hash test guarding the first half.

Verified against real instances rather than the docs: a 3.11.1 and a 3.12.0
container, seat imported from each, a seat edited in TREK written back and read
out of AirTrail again, and the pre-fix build failing the same checks on 3.12.0.

Closes #1931
2026-08-14 14:32:35 +02:00
Maurice 681fe1fee3 fix(dashboard): keep hotels out of the upcoming reservations widget
A stay covers a range rather than a moment, so one week in a hotel would hold a
slot in a six-entry widget for the whole week and push out the bookings that
actually happen on a day. The accommodation belongs to the day plan, which shows
it across its span.

They never worked here either, which is what the report is about. A hotel keeps
its dates on the linked stay, not on the reservation, so the two ways one can
exist behaved differently:

The booking form writes reservation_time = NULL and sends no day_id, so a
form-created hotel matched neither arm of the date test and was silently absent.
That is the "incomplete" half.

The accommodation panel auto-creates its booking with day_id and with the start
day's date stamped into reservation_time. That row did show, but updateAccommodation
syncs only metadata and the confirmation number to its linked bookings, so moving
the stay left the stamp untouched and the widget kept the old date. That is the
"no effect when I change the date" half.

The type test is NULL-safe: the column defaults to 'other' but nothing stops a
NULL, and a bare `type != 'hotel'` evaluates to NULL there and would have dropped
those rows from the widget instead.

Closes #1934
2026-08-14 13:34:13 +02:00