Commit Graph

1882 Commits

Author SHA1 Message Date
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 cb41d082be feat(admin): make an admin section linkable with ?tab=
The admin page always opened on Users, whatever the address said. A support
reply, an onboarding mail or a bookmark could point at the page but not at the
panel it was about, so every one of them ended with "then click X on the left".

Read once for the initial value and written back on each change, so the address
bar keeps saying where the reader is. Replaced rather than pushed: eleven tabs
would otherwise fill the back button with the history of a single page.
2026-08-15 18:07:21 +02:00
Maurice a6c27aac51 fix(admin): stop asking for OIDC config where the route is closed
On a centrally administered install /api/admin/oidc answers 403, and the admin
page asked anyway. The promise was caught, so nothing broke, but the request
still went out and still printed a red 403 in the console of every admin who
opened the page. Swallowing a rejection hides a failure from the code, not from
the person reading the console.
2026-08-15 18:07:21 +02:00
Maurice f2329c9a63 feat(client): let a managed surface attach as an admin tab
The nav-entry seam was the wrong shape for what actually attaches here. A
subscription surface is about the install, not about a trip: /admin is already
behind an admin check, already grouped, and already where somebody looks to
change how their instance works. A top-level nav entry put it beside My Trips
for a screen opened once a month by one person.

Two lines in AdminPage rather than an overlay replacing the file: the tab list
gets the extra entries appended per group, and the body renders whichever one is
active. Appended rather than inserted because PageSidebar needs a group's tabs
contiguous.

The route and nav-entry seams stay. They are the right answer for a surface that
genuinely is a page, and both remain empty here.
2026-08-15 18:07:21 +02:00
Maurice 887e6c5026 feat(client): let a managed route require an admin
Hiding the nav entry is not access control: the path stays typeable, and a screen
about how the install is run should not open for somebody who was invited to one
trip. ProtectedRoute already knows how to refuse — the attachment point just was
not asking it to.

Optional, so a route that does not set it behaves exactly as before. It sits
next to adminOnly on the nav entry rather than being derived from it, because
the two answer different questions: who is offered the door, and who may walk
through it.
2026-08-15 18:07:21 +02:00
Maurice 7c378c574f feat(client): let a managed nav entry be admin-only
The attachment point hands the two nav bars a static list, so anything attached
there showed up for every user of the instance. What belongs there is the
operator's side of the install, and a trip's other travellers are on the instance
as guests of the person who runs it — an entry about how it is run is noise to
them.

`adminOnly` is optional and absent means "for everyone", so nothing that exists
today changes meaning. Both nav bars go through the same exported filter rather
than each writing the rule, since a rule written twice is a rule that will differ.

Still inert here: the list is empty, and the tests pin that an empty list stays
empty for either kind of user.
2026-08-15 18:07:21 +02:00
Maurice 05315ef7d9 fix(planner): pin the clock in the opening-hours test
FE-PDC-019 asserts that the collapsed row shows one specific line, but the row
shows the current day in the place's timezone — and the fixture gives Saturday
different hours from the working week. So the test passed Monday to Friday and
took the build down every weekend, which reads as a broken feature rather than a
test that forgot to say which day it is standing on.

Pinned to a Wednesday, with the Saturday case kept as its own test rather than
thrown away: it is the one that proves the day lookup happens at all, and it is
the exact case that was failing.
2026-08-15 18:07:21 +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
Konstantinos Thermos 3e52695124 Merge pull request #1932 from liketrek/feat/comaps-integration
feat(planner): open places and day routes in CoMaps
2026-08-14 19:03:35 +03: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
Konstantinos Thermos 0e7c516279 Merge branch 'dev' into feat/comaps-integration
# Conflicts:
#	client/src/components/Planner/DayPlanSidebar.tsx
2026-08-14 15:05:00 +03: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
Maurice f43b56a745 fix(collab): let the empty chat state fill the panel
The empty state went in without flex-1 while the message list it stands in for
has it, so it was only as tall as its own content. The composer rode up to sit
directly under the mascot with the rest of the panel left blank, which reads as a
layout that failed rather than a chat waiting for its first message.
2026-08-14 09:53:53 +02:00
Maurice 79c460fd38 fix(map): keep the layer switcher clear of the sidebar edge
At a 12px gap the pill's own shadow ran into the sidebar it sits beside, so the
two read as one surface rather than a control floating on the map. 20px.
2026-08-14 09:53:53 +02:00
Maurice 97c725ea14 feat(planner): collapse the three export buttons into one dialog
PDF, ICS and GPX each had their own button in the day-plan toolbar, and two of
them carried a hover menu of their own. That is five controls' worth of width in
a pane the user can drag narrower, so the exports were the first thing pushed off
the edge, and a hover menu is not reachable at all once its button is gone.

One button opens a dialog instead: the day plan as a PDF, the bookings as a
calendar (a one-off .ics or a live subscription) and the map data as GPX in the
three scopes a device wants. Every row reports that it is working, which the PDF
export needed most: it fetches a 226 kB builder before it renders anything, and
until now the toolbar button simply sat there.

Two strings the old ICS menu had hardcoded in English, the download and the
subscription, come from the locale files the phone export sheet already used.

The toolbar no longer needs the icsHover flag it only held to keep its menu open,
so that comes out of the sidebar's state as well.
2026-08-14 09:53:53 +02:00
Maurice 974c240043 fix(planner): give the reservation time its own chip
The status, the time and the flight or train number sat inside one pill strung
together on a middle dot, and the dot was baked into the translation itself:
planner.resPending ended with " · " in all 23 locales. That made the row read as
one run-on sentence, and it meant the separator could never move without a
locale change.

They are separate chips now, same tint so they still belong together and nowrap
so the time cannot break away from the status it qualifies. The separator is out
of the locale files, which also retires the regex the transport detail modal
needed to strip it back off.
2026-08-14 09:53:53 +02:00
Maurice 460b215e69 fix(collections): name the phone label chip after what it opens
The chip that opens the label manager was labelled with collections.linkLabel,
which is the caption of a link in the list editor. English calls that one "Label"
so it happened to read correctly there, but German got "Bezeichnung", Russian
"Название" and Ukrainian "Підпис": wrong word, and long enough to push the filter
row out of its panel. It uses collections.labels.title now, which is the manager
it opens and is shorter in every locale.

collections.select was the other one over the edge. It is only an aria-label on
the desktop filter bar, so the eight locales whose word did not fit are on a
shorter one now.

The truncate on the chip labels never worked: a flex item keeps min-width:auto,
so the row grew past the panel instead of shrinking its chips. With min-w-0 a
long label name or a bumped-up text size ellipsises inside the panel.
2026-08-14 09:53:53 +02:00
Maurice a234653b29 fix(collections): let the phone dock add a place inside a list
The centre "+" matched /collections exactly, so it only knew it was on the
collections screen while no list was selected. Picking one moves the route to
/collections/:id, the match failed, and the action fell through to the last
branch of useCreateAction, which creates a trip on the dashboard. That is the
state the screen is normally used in, so for most people the button never added
a place at all. It also explains the report that there is no way to add one: the
workaround, adding from "All saved" and assigning afterwards, is the only path
that happened to still work.

The handoff carries the id rather than dropping back to /collections. Otherwise
adding from inside a list would land on the union and the sheet would ask for the
list the user is already looking at.

Closes #1930
2026-08-14 09:53:53 +02:00
Konstantinos Thermos 8065af28ef feat(planner): open places and day routes in CoMaps
Add CoMaps, the offline OpenStreetMap navigator, alongside the existing
map integrations. A geolocated place gains a CoMaps entry in its
navigation menu, and a day's route toolbar gains a button that hands the
day's bookended stops to CoMaps in that day's travel mode.

CoMaps' route link carries only a start and a destination, so a two-stop
day goes as a route and a longer one as named pins rather than silently
losing the stops in between. Links use the https form so a device without
CoMaps installed lands on comaps.at instead of a dead scheme.

The hotel-bookend logic behind the Google Maps export was duplicated
between desktop and mobile; both now share dayExportStops so the two
links cannot disagree about which hotel legs are real.

Addresses discussion #1904
2026-08-14 10:24:14 +03:00
Maurice 041997e5e6 fix(feed): keep the TZID when a clock already carries seconds
fmtDateTime's time-only branch appended '00' for seconds no matter what it was
given. The booking import stores what KItinerary hands it, which is '10:00:00',
so the value came out 17 characters long. dtLine only attaches the TZID to a
well-formed 15-character value, so those events lost their zone and went out as
floating times, which is the #1453 regression by another route.

It pads the way the ISO branch above it already does, so every caller is covered
rather than only the rental markers.
v4.0.0-pre.1
2026-08-13 16:10:25 +02:00
michael-bohr 7d580efce9 feat(feed): car rental pickup and drop-off events in the ICS feed
Rentals now emit two additional timed markers, Pickup and Drop-off, the
same additive shape hotels already get for check-in/check-out (#1721,
last unported piece). The rental's existing single-block event is
untouched.

Sides are derived in priority order: the endpoint role (from = pickup,
to = drop-off, reusing the already-loaded endpoints map); if no
endpoint on the reservation carries either role, positional first/last
by sequence (only once there's more than one endpoint, so a lone
endpoint is never both sides); otherwise reservation_time /
reservation_end_time, since imports frequently carry only that window.
Per side, an event only fires once a usable date and clock resolve;
zone resolution prefers the endpoint's stored timezone, then its
coordinates, then the reservation's linked place.

Follows the hotel `marker` closure's pattern closely (same esc/uid/
dtLine/isDate/isTime/usedZones usage, same Pickup:/Drop-off: summary
convention, same per-kind UID shape) without touching the hotel path
itself.
2026-08-13 16:10:25 +02:00
dependabot[bot] 54a3c8d50c chore(deps): bump @hono/node-server from 1.19.14 to 1.19.17 (#1918)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* chore(deps): bump @hono/node-server from 1.19.14 to 1.19.17

Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.14 to 1.19.17.
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](https://github.com/honojs/node-server/compare/v1.19.14...v1.19.17)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 1.19.17
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-08-13 16:08:45 +02:00
Maurice 5f4fa30416 docs: say where the outbound proxy switch actually lives
The variables only take effect when Node runs with NODE_USE_ENV_PROXY=1, and
that is set in the image. This page also covers Helm, Unraid and source
installs, where nothing sets it, so following it there changed nothing.

Also recommend NO_PROXY. Without it the proxy sees every request TREK makes to
itself, including the container health check.
2026-08-13 16:07:28 +02:00
Cynosure159 eecde11061 docs(docker): document outbound proxy configuration 2026-08-13 16:07:28 +02:00
Cynosure159 84a84078f6 feat: enable environment proxy support in container 2026-08-13 16:07:28 +02:00
Ida Rogalska 0d737c2466 fix(dayplan): resolve per-leg transit departure time within its own day
Review of the previous commit surfaced that the departure-time lookup keyed a
trip-wide coordinate index, so a POI revisited on another day (a supported
pattern) leaked the other day's time into the prefill. Resolve the departure
time from the leg's own day instead; the name index stays trip-wide (a name is
coordinate-stable).

Also from review: wire the per-leg option on the mobile sidebar too (it was
desktop-only), drop the now-dead route-leg fallback in the outgoing menu opener,
and accept single-digit hours in the time normalisation. Adds a regression test
for the revisited-stop case and pins the hotel-bookend fallback time.
2026-08-13 16:05:20 +02:00