Commit Graph

1994 Commits

Author SHA1 Message Date
jubnl 7439bcd33c feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits 2026-08-20 08:06:02 +02:00
jubnl 5dc78dd5cf feat(server): registry snapshot() — effective storage config with provenance 2026-08-20 07:58:10 +02:00
jubnl 261c67d572 feat(server): seed-once storage-config.json boot import — fail closed on any invalid seed 2026-08-20 01:01:21 +02:00
jubnl 65a9a92c3e chore(server): drop storage-secrets import that only Task 8's probe helper will use 2026-08-20 00:52:46 +02:00
jubnl d9f6151f53 feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact) 2026-08-20 00:50:27 +02:00
jubnl a6fb82833f feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path 2026-08-20 00:41:03 +02:00
jubnl 5758471e4e feat(server): accept settings-declared s3 backends — shared-schema options, decrypt-at-build, registry preview() 2026-08-20 00:30:47 +02:00
jubnl 8bfe2d2e52 feat(shared): storage backend type registry, wire schemas, category relocation 2026-08-20 00:24:18 +02:00
jubnl b914602b8c expose admin ui 2026-08-19 22:52:53 +02:00
jubnl f86e55f104 chore(server): final-review polish — stale CI refs, dockerignore key, boundary pins, zero-byte tempfile contract case 2026-08-19 22:46:52 +02:00
jubnl 6fbff83592 ci(server): make live-MinIO contract runs manual-only (AIStor licensing)
MinIO's community line is discontinued entirely; the maintained successor, AIStor, requires a license key that can't safely live in a shared CI runner. The s3-contract CI job is removed; live-provider contract runs against MinIO/AIStor are now a manual, license-gated procedure documented in docker-compose.minio-test.yml's header.
2026-08-19 22:22:39 +02:00
jubnl f1e7885d99 docs: restore README formatting, keep S3 additions only 2026-08-19 22:14:06 +02:00
jubnl fcfca861c3 docs: S3 storage backend configuration and off-box backup mirroring 2026-08-19 22:05:23 +02:00
jubnl c04e56ecec ci(server): align s3-contract shim comment and MinIO bind with references 2026-08-19 22:02:15 +02:00
jubnl 5fa3de17c8 ci(server): MinIO-backed S3 driver contract job 2026-08-19 21:58:48 +02:00
jubnl 0a4c36e1fb fix(server): scope the zero-byte put workaround to buffered bodies; bind test MinIO to localhost 2026-08-19 21:54:44 +02:00
jubnl 0054ae8287 fix(server): S3 driver zero-byte put — work around aws4 duplicate Content-Length signing defect 2026-08-19 21:46:51 +02:00
jubnl 52b39c7201 test(server): env-gated S3 driver contract suite against MinIO 2026-08-19 21:39:50 +02:00
jubnl 8b84586db9 feat(server): env-declared s3-main storage backend; settings-declared s3 rejected 2026-08-19 21:34:50 +02:00
jubnl 6d3d57f97f fix(server): S3 driver Upload monitoring — Transform interposition, stream cleanup 2026-08-19 21:26:23 +02:00
jubnl 3906fd3841 feat(server): S3 driver put — bounded-peek routing, Upload inactivity deadline 2026-08-19 21:03:28 +02:00
jubnl 683270f911 feat(server): S3 driver getStream and list — ranged full-object stat, iterator pagination 2026-08-19 20:48:53 +02:00
jubnl b0921bcccd refactor(server): type the aws-lite S3 client via s3-types reference 2026-08-19 20:44:11 +02:00
jubnl c3c0a4cd01 feat(server): S3 driver core — aws-lite client seam, stat/delete, driver-owned deadlines 2026-08-19 20:36:56 +02:00
jubnl bc4d70986f feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule 2026-08-19 20:27:50 +02:00
jubnl 8ae73a2b5c test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract 2026-08-19 20:20:58 +02:00
jubnl 773eda63d9 Merge remote-tracking branch 'origin/dev' into feat/storage-driver
# Conflicts:
#	server/src/db/migrations.ts
#	server/src/nest/journey/journey.controller.ts
#	server/tests/integration/leg-mode-incoming.test.ts
#	server/tests/unit/nest/journey.controller.test.ts
#	server/vitest.config.ts
2026-08-19 19:52:49 +02:00
Maurice 7164f4fb11 fix(photos): drop the dead initialiser eslint flagged in the exif reader 2026-08-18 22:24:29 +02:00
Maurice 503a03792e test(photos): cover the local EXIF path so the memories ratchet holds
The capture backfill landed in src/nest/memories/ with its local branch
untested, which took the domain under its coverage floor (functions 96.71 vs 97,
branches 79.67 vs 81). Raising the tests rather than lowering the gate: the path
guard against climbing out of the uploads tree, a missing path, an unreadable
file, the CreateDate fallback, empty EXIF, a throwing row lookup, and schedule().
Plus the Immich album mapping with and without coordinates.

One note left in the test file for the next person: a mock that *rejects* makes
vitest fail the run even when the code catches it, because the mock's recorded
result is a second reference to that promise with nothing attached. A bare
mockReset() is enough to trip it, so the beforeEach sets a benign default.
2026-08-18 22:24:29 +02:00
Maurice 2c333cb643 perf(journey): render the gallery in pages instead of all at once
A long trip's gallery is hundreds of tiles and every one of them was in the DOM
from the first paint, which is what made scrolling it a chore (#1614). It now
renders 60 and grows as the sentinel comes into view — the same mechanism the
provider picker already uses, rather than a second one invented for this.

Deliberately not pagination over the wire: the gallery arrives inside the one
getJourneyFull payload that the client, the MCP tool, the MCP resource and the
PDF export all read, so cutting it there would change the answer for four
consumers at once and would silently shorten the book. The reported symptom was
the scrolling, and that is client-side.

The reported 500-photo limit is not in the code — no query limit, no upload cap,
no client slice — so nothing was raised.
2026-08-18 22:24:29 +02:00
Maurice 06b7f3e690 feat(weather): answer for the hour when a past entry carries one
A journal entry made at 14:30 wants the weather of that hour. The archive path
only ever asked for daily figures, so it answered with a rainy morning and a
bright afternoon averaged into one number (#1614).

With a time in hand it now asks for the hourly series as well and reads that
hour, falling back to the daily figures when the hour has no reading — an
archive gap must not turn into no answer at all. An unparseable time is treated
as no time rather than as midnight, which would have been a confident wrong
answer. The cache key carries the hour, so the two do not shadow each other.

Nothing changes for callers that pass no time, which is every existing one.
2026-08-18 22:24:29 +02:00
Maurice 97f86dcf46 feat(journey): let quick capture take a note, not just a photo
The ask was "photos or quick notes" while travelling, and the capture sheet only
took photos (#1614) — so a moment worth catching without a picture had no way in
short of opening the full editor, which is exactly what capture mode exists to
avoid.

Two rows rather than the full sheet's three: this is a note, not the story. The
story is written later, when the entry is finished.
2026-08-18 22:24:29 +02:00
Maurice 6e4fda897f refactor(journey): stop pulling trip photos into journeys
Photos live in journeys (#1614). Linking a trip used to copy its trip_photos
rows into the journey gallery, which is how a photo one member had chosen not
to share could reach a journey at all — PR #1967 filtered that at the door, and
this removes the door.

Nothing is lost by it: the trip-photo surface lost its UI in 3.1.0, so nothing
writes to trip_photos on an install newer than that, and the client calls none
of its routes. The table and those routes stay for one more release rather than
being dropped in an append-only migration; the read paths keep working for
installs that still hold rows.
2026-08-18 22:24:29 +02:00
Maurice 56b4509b03 feat(journey): put photos on the map where they were taken
The visible half of the capture-metadata work (#1614). Photos that know their
coordinates are drawn as thumbnails on the Journey map, in the shape the report
asked for: a picture rather than an abstract pin, and a count when several
collapse into one.

Clustering is a screen-space grid rather than a library. The Journey maps have
never had clustering, and the one the planner uses hangs off react-leaflet while
this map drives Leaflet directly; bucketing by rounded pixel position is a few
lines, is deterministic, and does the job — photos of one place collapse into one
thumbnail with a count, and pulling the map apart separates them again. The layer
sits below the entry pins, because the itinerary is the point of the map and the
photos are context.

Its own effect, so photos arriving do not tear down and rebuild the map the way
the entry effect does, and redrawn on zoom and pan because the buckets are in
screen space.

The thumbnail URL is built in the page rather than the hook: it needs the share
token, which is the credential the byte proxy checks.

Also re-points the trailing-migration guard at the new last slot, which is what
its own comment asks of anyone appending one.
2026-08-18 22:24:29 +02:00
Maurice 11686be264 feat(journey): let a shared journey read newest-first
A journey shared while the trip is still running is read like a blog: the family
following along wants the latest entry at the top, not to scroll past three weeks
to find it (#1614). The owner sets it per share link, because it is a property of
how the link is meant to be read rather than of the journey, and the reader can
flip it either way.

Off by default, so an already-published link does not reorder itself under its
readers.

Only the reading order changes. Day colours and stop numbers stay chronological,
so the timeline still matches the map when the feed is upside down — which is why
the day index is looked up rather than taken from the render loop. Reuses
memories.newest/oldest, already translated in all 23 locales.
2026-08-18 22:24:29 +02:00
Maurice e809808572 fix(journey): give the public map one numbering instead of two
The marker showed the stop's position within its day; the day heading showed the
day number. Same colour, different meaning, and on mobile the heading is not
rendered at all, so the number had no key — which is exactly what the report
described: the same digits over and over in different colours (#1962).

Both now count stops across the whole journey, so every number appears once, and
the timeline carries the same number next to each geocoded entry. The day colour
still groups the day. The badge sits outside the photo-count gate on purpose: an
entry with a single photo puts its title and location in the image overlay, but
the stop number has to be readable either way.
2026-08-18 22:24:29 +02:00
Maurice 97220b72f6 feat(journey): carry capture metadata into the payload, and keep it behind share_map
The gallery rows now hand the client taken_at, lat and lng, which is what the map
and a capture-ordered gallery will read.

The coordinates needed a projection before they could ship. A photo's capture
location is a place the owner never typed and may not expect to publish, so it
follows share_map — the same switch the entry coordinates already follow. Without
that, turning the map off and leaving the gallery on would have handed out exactly
the locations the owner had just withheld. The capture time is not a location and
stays either way; a blog-style view sorts by it.
2026-08-18 22:24:29 +02:00
Maurice d2cf694882 feat(photos): read the EXIF of an uploaded photo
Closes the third source of capture metadata (#1614). Provider photos are asked
for, uploaded files carry their own answer — and until now nobody read it, so
the most common case of all, a photo taken on the trip and uploaded straight
into the journey, could never appear on the map.

exifr rather than a heavier image library: pure JS, no native build, so no
musl-pin dance in the Alpine image. Only four tags are picked, so a large file
is not fully parsed. The resolved path is checked against the uploads root
before it is opened.

One thing worth knowing before someone files a bug about it: the client
converts HEIC before upload and that conversion drops GPS, so an iPhone photo
arrives here already stripped. Nothing to read is the expected outcome more
often than not, which is why the empty case is silent.
2026-08-18 22:24:29 +02:00
Maurice 359c3afc21 feat(photos): ask the provider where and when, and stop the album path dropping it
Second half of the capture-metadata foundation (#1614).

The album paths were the asymmetry: Immich's search has always carried
exifInfo.latitude/longitude, but the album mapping quietly left them out, and
the Synology album listing never asked for the gps block at all. So a photo
picked out of an album could never be placed on a map, and the distance sort
that PR #1632 added silently had nothing to sort by. Both now carry the pair.

For the add itself the client only sends an asset id. Widening that contract
would have meant trusting the client for something the provider can be asked
for directly, and would have left the MCP path and every already-imported photo
without it — so PhotoCaptureBackfillService asks afterwards instead, detached,
skipping rows that already know both so an album import is not one provider
call per photo. A slow or unreachable provider cannot hold up the add, and a
photo without capture metadata stays the normal case.
2026-08-18 22:24:29 +02:00
Maurice 6185cf96b6 feat(photos): record when and where a photo was taken
The foundation the Journey map needs before photos can sit on it by their own
coordinates, and the gallery before it can order by when a picture was taken
rather than when it happened to be added (#1614). Three nullable columns on
trek_photos plus a partial index over the coordinate pair; a photo with neither
is the normal case, not a failure.

recordCaptureMetadata merges rather than overwrites, for the same reason
recordLocalThumbnail does: the sources disagree about how much they know. A
provider search answers with coordinates, the same provider's album listing does
not, and a local file gives them up only once its EXIF has been read. Whichever
arrives first wins, and a later, emptier answer must not blank it. Coordinates
are stored as a pair or not at all — half a pair is not a place, it is the null
island.
2026-08-18 22:24:29 +02:00
Maurice 3e4b36ac71 fix(weather): give the Open-Meteo calls a deadline
All six outbound calls ran without an AbortSignal, so a hung connection held
the caller until the socket gave up on its own. Every other outbound client in
the codebase carries a timeout; this one is the reference domain and did not.
2026-08-18 20:17:57 +02:00
Maurice 85794a5a59 fix(journey): keep the picker's date headings chronological, and name any provider
Since the provider picker started sorting by distance to the entry, the date
groups were built from the already-sorted list, so first-seen order put the day
holding the nearest photo at the top and the headings stopped reading in order.
Grouping now sorts its own output; the order within a day is untouched, so the
distance sort still does its job where it belongs.

The picker also called every non-Immich provider "Synology Photos". It now
falls back to the provider's own id, the way the gallery view already did.
2026-08-18 20:17:57 +02:00
Maurice edb487fbef feat(journey): let the desktop editor set the entry time, and reach the camera
The time was already stored, sent to the server and shown in the timeline, on
the map, in the PDF and on the public share — the desktop editor just never
rendered an input for it. So a draft carried the wall-clock time it was created
at, it showed up everywhere, and there was no way to correct it (#1614). The
column holds HH:MM:SS and a time input wants HH:MM, so both the initial value
and the dirty check now trim.

The camera gets its own hidden input rather than a capture attribute on the
existing picker: putting it there would take the photo library away on a phone,
which is the more common way in. The phone sheet already works this way.
2026-08-18 20:17:57 +02:00
Maurice 630e8efca1 fix(journey): stop unshared trip photos leaking into journeys, and gate the link
Two things found while reading the domain for discussion #1614.

A photo a trip member left unshared is denied on the trip path — memories-access
requires tp.shared = 1 — but linking that trip to a journey copied every row of
trip_photos into the gallery regardless. From there it was readable by every
journey contributor, and with an open gallery by anonymous visitors of the share
link. journey_photos.shared cannot gate that after the fact: a direct upload
writes 0 into the same column, so filtering on it would empty most galleries.
The fix is at the door instead — only shared rows are copied in.

addTripToJourney checked that the caller could reach the trip but never that
they could reach the journey, so any logged-in user could link a trip of theirs
into a stranger's journey and seed skeleton entries and photos there. The MCP
tool has always checked this; only the REST route was open. Now the service
checks it, which covers both and the internal call from createJourney.

Neither path had a test. They do now.
2026-08-18 20:17:57 +02:00
Maurice b267771772 fix(mobile): let the map show one day again
Collapsing a day card has hidden that day's places from the map since #216, and
it was the only way to reduce a busy map to one day. The phone shell does not
render DayPlanSidebar, so when it replaced the desktop page on phones that
control disappeared with it: expandedDayIds stayed null and every day's pins sat
on top of each other with nothing to do about it (#1962).

A day chip in map view now drives the same declutter the desktop chevron drives,
rather than introducing a second filter. Entering the map focuses the selected
day, tapping another chip moves the focus with it, and leaving the map clears it
so the timeline is never looking at a filtered state. Unplanned places stay
visible, exactly as the collapse path has always left them.
2026-08-18 17:40:56 +02:00
Maurice a3d1fc7808 feat(share): number the day's stops on the map and connect them in order
The public share page drew every geocoded place of the trip as an identical pin
with no sequence and no line, so an itinerary could not be read off the map at
all (#1962). It does filter to a day, but the only control for that sat below a
300px map, which is why the reporter never found it on a phone.

Markers of the selected day now carry their position in that day's order, with
the same contract the planner uses: a stop the day visits twice shows both, e.g.
"1 · 3". A dashed line connects them in order. It is a straight connector on
purpose, not a road route — drawing a real one would mean sending the itinerary
to a third-party routing service for every anonymous visitor of a shared link,
with no way for the trip's owner to opt out. A day picker now sits directly
above the map, driving the same state as the day cards below, so the two can
never disagree.

Two things nobody reported, both found while in here. Outside a day selection
every marker fell back to the placeholder indigo, because the payload nests the
category on a day's assignments but sends it flat on the trip-wide pool and only
the nested shape was read. And the map refit itself whenever the page
re-rendered for an unrelated reason — the language picker, a late FX response —
because the effect keyed on the array identity of a list the page rebuilds every
render, throwing away the viewer's pan and zoom. It keys on the coordinates now.
2026-08-18 17:40:56 +02:00
Maurice f4ee06e931 fix(share): stop the day header collapsing the title into a one-word column
The day card header on /shared/ is a single nowrap flex row, and its title block
carried `flex: 1`. That is a flex base size of 0, so the moment an accommodation
chip pushes the row past the available width the block freezes at its min-content
width while the chip and the place count stay at full content width — the ~37px
column with one word, or one CJK character, per line from #1955.

It is content-driven, not viewport-driven, which is why it also showed up on a
1149px-wide desktop window: one long hotel name, or a day carrying both a
check-out and a check-in, is enough. A breakpoint would have been the wrong fix.

The title block now shrinks proportionally and truncates, the chip can shrink and
truncates too, and the place count is pinned so it never wraps or gets pushed out
of the card. The chip also moves off its hard-coded 9px onto the 10.5px the day
sidebar uses in the app, with an icon to match — at 9px it was barely readable.
2026-08-18 17:40:56 +02:00
Maurice 28765e220b fix(places): keep the Google feature id, and give list imports the free address lookup
Two halves of #1954, both about a place arriving through a list coming out
thinner than the same place pasted as a single link.

The feature id was never dropped on purpose. googleFtidFromMapsUrl only looked
at the `?ftid=` query parameter, while a share link that resolveGoogleMapsUrl
has followed to its final hop carries the id in the `data=!…!1s0x…:0x…` path
blob — the same run the coordinates are already parsed out of two lines further
down. So every pasted maps.app.goo.gl link lost it, and the Maps button opened
a name search rather than the entry the link pointed at. The parser now reads
the blob too, but only on a /place/ URL: a /maps/dir/ route carries one !1s per
waypoint and the first is the origin, which would attach the wrong place.

List imports never had a reverse lookup at all. The only pass behind them is
the Google enrichment, which returns immediately without a Maps key, so an
instance without one imported a list and left every address empty forever.
They now get the same free Nominatim lookup the single-link path has always
had: detached after the import answers, on the throttled background lane so it
never queues in front of someone's search, through COALESCE so it can only fill
an empty column, and each row pushed over the websocket so the sidebar fills in
without a reload. Capped at 250 places — past that it stops being a backfill
and starts being bulk geocoding.

Not included: an osm_id for imported places. Single links do not produce one
either, and one derived from a zoom-18 reverse lookup would name the building
or the street rather than the POI.
2026-08-18 17:40:56 +02:00
Maurice c9278c02ce chore(deps): lift the four packages the Scout gate flags as high
The security scan on the release PR has been red on six HIGH CVEs, all of
them transitive production dependencies of the server workspace, which is
what ends up in the runtime image via `npm ci --workspace=server --omit=dev`:

  brace-expansion  2.1.1 -> 2.1.4   CVE-2026-13149 / -69152 / -14257, ReDoS
                                     reached through archiver -> glob -> minimatch
  ip-address      10.2.0 -> 10.5.0  CVE-2026-69192, improper input validation
                                     reached through the MCP SDK -> express-rate-limit
  fast-uri         3.1.4 -> 3.1.5   CVE-2026-18446, reached through the MCP SDK -> ajv
  js-yaml          4.3.0 -> gone    GHSA-5p4m-2wfm-xmqj

Every one of them resolves inside a range the manifests already declare, so
this is a lockfile change and nothing else: no new dependency, no override,
no package.json edit.

js-yaml was the awkward one. @nestjs/swagger pins it exactly rather than by
range, and 11.4.5 pins the affected 4.3.0. 11.4.7 pins 5.3.0 instead, and
server already asks for ^11.4.5, so the affected copy leaves the production
tree without forcing anything. The root js-yaml 4.3.0 stays for eslint, which
never reaches the image.

Walking the production graph over the lockfile gives the same 482 packages
before and after, with only those four differing. Server suite is green
(409 files, 7805 tests), as are typecheck, typecheck:tests and client lint;
/api/docs-yaml still renders through swagger's new js-yaml.

Still open and deliberately not in here, both MEDIUM and below the gate's
critical/high filter: dompurify (via isomorphic-dompurify, and #1694 reverted
a bump of it once already) and hono (via the MCP SDK).
2026-08-18 16:11:03 +02:00
34ezz623-create 3011bf50b4 refactor(i18n): improve Turkish translations for vacay module
Replaces the machine-translated strings in the Vacay domain. The visible ones:
'Doygunluk' (saturation) and 'Güneş' (the star) were the Saturday and Sunday
column headers, 'Sol' (left, as in direction) stood for the remaining days,
'Efsane' (myth) for the legend, and 'Sigortala' (insure) read "fuse" as an
electrical fuse. Buttons move from the infinitive to the imperative the rest
of the locale already uses.

Rebased onto dev and resolved as a union: the branch carried the file as it
stood on 25 July, which would have dropped the 35 keys dev has gained since
from #1074, #1631, #1633 and #1637. Those keep their existing translations;
only the 47 strings this change actually rewords differ.

vacay.selectRegion keeps '(zorunlu)'. en says '(required)' and the Add button
stays disabled until a region is picked, so '(isteğe bağlı)' would have sent
Turkish users looking for a button that never enables.
2026-08-18 13:58:58 +02:00