Document that the frontend deliberately runs as a pure client-side SPA
(adapter-static, ssr=false) and that we stay on SvelteKit rather than migrating
to a bare Svelte + router setup: the file-based routing, client router, and —
most importantly — shallow routing (pushState + page.state, on which the
overlay file viewer depends) carry their weight, while the server half (SSR,
endpoints, form actions, hooks) is intentionally unused. Also drop the stale
hooks.* entries from the directory layout, since no such files exist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping a file in a pool did a full goto('/files/<id>') to the standalone
viewer route, whose close button always routes to /files — so returning from
a file viewed inside a pool dropped the user on the global files list instead
of back in the pool.
Open the viewer as an overlay over the still-mounted pool grid via shallow
routing, mirroring the files grid: pushState keeps the pool URL (the overlay
is driven by page.state.fileId), and the back button / close does
history.back(), returning to the pool with its list and scroll intact.
Neighbours follow the pool's own order, paging in more pool files near the
end, and closing scrolls the grid back to the last-viewed file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The IntersectionObserver fired only on enter/leave transitions, so a scroll
that ended with the sentinel already in range (scrolling straight to the
bottom) produced no callback and nothing loaded — the user had to scroll up
and back down to force a fresh transition, loading one chunk per cycle.
Replace the observer with a capture-phase window scroll listener (capture is
required since scroll events don't bubble; it catches scrolls from the grid's
nested <main> as well as the document), rAF-throttled, re-checking the
sentinel's viewport position on every scroll. Keep the re-check on load
completion / mount for short pages and already-in-range first renders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Returning to the grid at a deep position (deep link / hard reload to a
file, then back → /files?anchor=<id>) used to load only a tiny forward
window at the anchor. Now the grid fills the viewport around the anchor
and pages in both directions as the user scrolls.
- loadAroundAnchor fetches a window centred on the anchor and pre-fills a
few pages each way sequentially, then centres on the anchor once. Doing
the initial fill explicitly (rather than via the sentinels) keeps the
pages contiguous and leaves the sentinels out of range, so there's no
mount-time load storm.
- loading starts true when the URL carries an ?anchor, so the child
InfiniteScroll sentinels (whose effects run before this page's reset
effect on mount) can't fire a stray page-1 loadMore that interleaves
with loadAroundAnchor.
- loadPrev pages backward (direction=backward) and prepends, then shifts
the scroller down by the added height via flushSync (no paint between
prepend and correction) so the viewport stays visually fixed.
- InfiniteScroll gains an `edge` prop; a top instance (shown only when
hasPrev) drives upward loading. Both loaders share the `loading` guard.
- Mock: honour direction=backward and emit prev_cursor; the Go backend
already supports backward keyset pagination.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lazy load fetched the entire list at once: every list's loader had a
"fill the viewport" recursion gated on
scrollContainer.scrollHeight <= clientHeight, but <main> is not the
scroller (the window/body is), so that condition is always true and it
recursed through every page (with a 10-item window, ~all pages fired at
once).
Move the filling logic into InfiniteScroll and base it on the sentinel's
viewport rect instead: load while the sentinel is within 300px of the
viewport bottom, re-checked synchronously after each load. This works
regardless of which element scrolls and loads only enough pages to reach
past the viewport.
Drop the per-page recursion (and now-unused scrollContainer refs / tick
imports) from the files, trash, tags, categories and pools lists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
75 mock files fit in a single 100-item page, so infinite scroll never
fired. 500 yields 5 cursor pages for testing lazy loading and the
overlay viewer paging past the loaded set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The viewer was a separate /files/[id] route, so returning tore down and
reloaded the whole grid. Now opening a file uses SvelteKit shallow
routing (pushState + page.state.fileId): the list stays mounted and the
viewer renders as a full-screen overlay on top of it, like Immich. The
URL still becomes /files/<id> and the back button (or Escape/close)
dismisses the overlay via history.back(), revealing the untouched grid —
no reload — then scrolls it to the last-viewed file instantly.
- Extract the viewer UI/logic into a reusable FileViewer component
(file fetch, preview, lazy tags, save, prev/next, keyboard).
- List: neighbours come straight from its own files[]; paging past the
loaded set pulls the next page by cursor (prefetch near the end).
- Paging uses replaceState so one back press returns to the grid.
- /files/[id] remains as a thin standalone fallback for deep links /
hard reloads, resolving neighbours via the anchor API and returning
to the grid with ?anchor=<id>.
- Remove the now-unused filesCache snapshot store (the list is never
unmounted, so there's nothing to snapshot/restore).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These three lists used a manual "Load more" button while files and trash
already lazy-loaded on scroll. Wire them to the shared InfiniteScroll
component for consistent behaviour: the offset-based load() now also runs
a viewport-fill pass (keep paging until the content overflows so the
sentinel sits below the fold), and the button + its now-unused spinner
CSS are removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Returning from the file viewer left the grid scrolled to the top: the
position lived only in volatile module state and was never carried
anywhere, and the scroll restore ran before SvelteKit's own scroll reset
(on goto) clobbered it back to the top — worsened by the body, not
<main>, being the effective scroller, so scrollTop restoration was inert.
- The viewer's back/Escape now return to /files?anchor=<currentId> with
noScroll, carrying the position in the URL (survives reload, no longer
depends on hidden in-memory state).
- The list restores grid DATA from the snapshot as before, but scrolls in
afterNavigate — which runs AFTER SvelteKit's scroll handling — using
scrollIntoView so it works whether <main> or the window scrolls. The
?anchor is consumed (stripped via shallow replaceState) once applied.
- Deep link / hard reload with an anchor but no cached grid falls back to
loading a page anchored at that file, then scrolling to it.
- Snapshot is mirrored to sessionStorage so a refresh still restores.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file viewer fetched /files/:id/tags eagerly alongside the file on
open, even though the Tags section sits below a full-viewport preview
and is usually never seen — needless DB load per file open.
Defer the tags fetch until the Tags section scrolls into view via an
IntersectionObserver (200px rootMargin pre-load). Re-fetches when paging
to another file while the section stays on-screen; shows a "Loading
tags…" placeholder until loaded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Listings returned every row regardless of ownership: GET /files, /tags,
/pools and /categories exposed other users' private items (while the
single-item GET correctly returned 403), and the pool file operations
(GET /pools/:id, /pools/:id/files, add/remove/reorder) skipped ACL
entirely, so any authenticated user could read and rewrite anyone's
private pool.
- List queries now filter to rows the caller may see (public, owned, or
granted can_view) via a shared SQL condition; admins bypass. The viewer
identity is taken from the request context by the service and passed to
the repository in the list params.
- Tag/Category/Pool single-item Get now enforce CanView (File already did).
- Pool Get/ListFiles require pool view; AddFiles/RemoveFiles/Reorder
require pool edit.
Adds regression tests for private-by-default listing (hidden / public /
granted / admin) and for pool operations rejecting a non-owner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a failed token refresh the client cleared the auth store and threw, but
nothing navigated away, so an expired session left the user on a page that
only showed errors. Redirect to /login when the refresh token is missing or
rejected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EXIF values can be arrays/objects (rationals, GPS, etc.); String(val) showed
"[object Object]". Render object/array values as JSON.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reorder did DELETE all pool memberships then re-inserted only the passed
file_ids, so a paginated client that sent just the loaded pages silently
removed every other file from the pool. Reorder now places the requested
files in order and appends any members the request omitted (in their
current order), so a partial reorder is safe and correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These endpoints had no Cache-Control, so the browser re-downloaded every
thumbnail on each grid mount (the client fetches them with an auth header,
which also bypasses default image caching). Returning to the grid after
viewing a file re-fetched the whole visible page of thumbnails. Add
Cache-Control: private, max-age=3600. Content is immutable per file id from
the client's perspective (there is no replace-content UI); a future replace
flow should cache-bust via a versioned URL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- vite-mock-plugin: define the missing MockFile type and annotate the
MOCK_FILES / MOCK_TRASH arrays so restore (unshift) type-checks.
- categories/[id], tags/[id]: page.params.id is string | undefined under
noUncheckedIndexedAccess — guard loadTags and default the TagRuleEditor
tagId so the routes type-check.
svelte-check now reports 0 errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The root route rendered the default SvelteKit placeholder page. Redirect /
to /files; the layout guard still sends unauthenticated users to /login.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The add-files search built a {n~%text%} filter token, but the filter DSL
only supports t=, m= and m~, so the backend rejected it as an unknown token
and no results appeared. Use the search query param (ILIKE on name) instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a file now snapshots the grid (loaded pages, cursor, scroll offset,
opened id) into a shared store, and the viewer derives prev/next from that
list instead of a separate anchored request. Returning to the grid restores
the cached list and scroll-centres the last-viewed file rather than
reloading page 1 from the top.
This also fixes two issues:
- The viewer's "previous" arrow never appeared: the backend anchor window
is forward-inclusive, so the anchor was always item 0 and prev was null.
Neighbors now come from the cached list, so paging is symmetric.
- Paging forward in the viewer prefetches further pages into the snapshot,
so navigation continues past the initially loaded set and the grid still
restores correctly.
A deep link straight to a file (empty cache) falls back to the anchored
API window as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover the refresh-token flow (works, not usable as an access token, and
revokes the rotated-away access token), non-owner denial on object ACLs /
file tags / import, and immediate session revocation on user block.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/auth/login and /auth/refresh had no throttling, allowing unbounded
password brute-force attempts. Add a process-local fixed-window limiter
(10 requests/minute per client IP) in front of both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Set X-Content-Type-Options: nosniff (so served file bytes are not MIME
sniffed), X-Frame-Options: DENY, and Referrer-Policy: no-referrer on all
responses via middleware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gin's Run uses a default http.Server with no timeouts, so a client could
hold connections open by trickling request headers. Serve via an explicit
http.Server with a 10s ReadHeaderTimeout and 120s IdleTimeout. Body
read/write remain unbounded so large uploads and downloads still stream.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replacing a file's content left the old {id}_thumb.jpg / {id}_preview.jpg
in the cache, and the cache-hit fast path kept serving the stale image
forever; permanent deletion left those files orphaned. FileStorage gains
InvalidateCache, which Replace and PermanentDelete now call.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Thumbnail/preview generation decoded untrusted images with no size limit
(a decompression bomb could exhaust memory) and ran ffmpeg with no
timeout (a malformed video could hang the request). Image dimensions are
now checked via image.DecodeConfig before the raster is allocated and
rejected above 64 Mpx, and ffmpeg runs under a 30s timeout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The auth middleware trusted any unexpired, well-signed access token, so
logout, session termination and admin blocks had no effect until the
15-minute token expired. The middleware now validates that the token's
session is still active on every request (SessionRepo.GetByID), and
blocking a user deactivates all of their sessions, immediately revoking
their outstanding access tokens.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Upload and Replace buffered the entire request body into memory with no
size limit, so a few large uploads could OOM the server. The file
handler now wraps the request body in http.MaxBytesReader and rejects any
file larger than MAX_UPLOAD_BYTES (default 500 MiB) before it is buffered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three related auth weaknesses:
- Access and refresh tokens were structurally identical, so a 30-day
refresh token was accepted as a bearer access token. Tokens now carry a
"typ" claim; the access path rejects refresh tokens and /refresh rejects
access tokens.
- Login stored the hash of a throwaway refresh token (sid=0) but returned
a re-issued one, so the stored hash never matched and /refresh always
401'd. Tokens are no longer re-issued: the refresh token is located by
hash and carries no session id, while the access token embeds the real
session id. A random jti keeps tokens unique within the same second.
- Login skipped bcrypt for unknown users (a timing oracle) and returned
403 for blocked accounts before checking the password (leaking account
existence). It now always runs a bcrypt comparison and verifies the
password before disclosing blocked state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
007_seed_data.sql shipped a fixed admin account whose bcrypt hash decodes
to the password "admin", giving every deployment the same known
credentials. The seed row is removed; UserService.EnsureAdmin now creates
the administrator on startup from ADMIN_USERNAME / ADMIN_PASSWORD. It is
idempotent and never overwrites an existing password, so an operator who
rotates the admin password keeps it across restarts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two broken-access-control holes:
- PUT/DELETE /files/:id/tags(/:tag_id) and GET /files/:id/tags went
straight to TagService with no ACL check, letting any authenticated
user read or rewrite tags on anyone's private files. The handlers now
require view (list) or edit (mutate) on the target file via new
FileService.AuthorizeView/AuthorizeEdit helpers.
- POST /files/import accepted an arbitrary host path from any user,
turning it into an arbitrary server-side file read. It is now
admin-only and the supplied path is confined to IMPORT_PATH.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GET/PUT /acl/:object_type/:object_id performed no authorization check, so
any authenticated user could read the permission list of, or grant
themselves view/edit on, any file/tag/category/pool. ACLService now
resolves the object's owner and rejects callers who are neither the owner
nor an admin. SetPermissions also wraps its delete+insert replace in a
single transaction so a partial failure can no longer wipe permissions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add --color-nav-bg and --color-nav-active CSS variables
- Dark: semi-transparent purple-dark tone (rgba 52,50,73 / 0.45 bg)
- Light: light semi-transparent background, accent-tinted active highlight
- Footer background and active nav item now use variables instead of
hardcoded dark values
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Copy all icon PNGs from docs/reference (android, apple, ms, favicon sizes)
- Copy favicon.ico and browserconfig.xml
- Manifest: full icon set (36–310px), background/theme #312F45
- app.html: favicon links, full apple-touch-icon set, MS tile metas
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New /files/trash page: same grid as files view, deleted files only
- Tap selects (no detail page for deleted files), long-press drag-selects
- Trash selection bar: Restore (bulk) and Delete permanently (bulk, confirmed)
- Trash icon added to files header, navigates to /files/trash
- Mock: MOCK_TRASH with 6 pre-seeded files; bulk/delete now moves to trash;
handlers for POST /files/{id}/restore and DELETE /files/{id}/permanent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Layout guard redirecting non-admins to /files
- User list page with create form and delete confirmation
- User detail page with role/permission toggles and delete
- Audit log page with filters (user, action, object type, ID, date range)
- Mock data: 5 test users, 80 audit entries, full CRUD handlers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After each batch, check if the scroll container is still shorter than
the viewport (scrollHeight <= clientHeight) and keep loading until the
scrollbar appears or there are no more files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add appSettings store (localStorage-backed) with two settings:
fileLoadLimit (default 100) and tagRuleApplyToExisting (default false)
- Settings page: new Behaviour section with numeric input for files per
page (10–500) and an on/off toggle for retroactive tag rule application
- files/+page.svelte: derive LIMIT from appSettings.fileLoadLimit so
changes take effect immediately without reload
- TagRuleEditor: pass apply_to_existing from appSettings when activating
a rule via PATCH (only sent on activation, not deactivation)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extend PATCH /tags/{id}/rules/{then_id} to accept apply_to_existing bool.
When a rule is activated with apply_to_existing=true, a single recursive
CTE retroactively inserts the full transitive expansion of then_tag into
data.file_tag for all files already carrying when_tag:
WITH RECURSIVE expansion(tag_id) AS (
SELECT then_tag_id
UNION
SELECT r.then_tag_id FROM data.tag_rules r
JOIN expansion e ON r.when_tag_id = e.tag_id
WHERE r.is_active = true
)
INSERT INTO data.file_tag ... ON CONFLICT DO NOTHING
Changes:
- port/repository.go: add applyToExisting param to TagRuleRepo.SetActive
- db/postgres/tag_repo.go: implement recursive CTE retroactive apply
- service/tag_service.go: thread applyToExisting through SetRuleActive
- handler/tag_handler.go: parse apply_to_existing from PATCH body
- openapi.yaml: document apply_to_existing on PATCH endpoint
- integration test: add TestTagRuleActivateApplyToExisting covering
no-op when false, direct+transitive apply when true
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Profile editor: name and optional password change with confirm field,
saves via PATCH /users/me and updates auth store
- Appearance: theme toggle button (dark/light) with sun/moon icon
- App cache: PWA reset — unregisters service workers and clears caches
- Sessions: list active sessions with parsed user agent, start date,
expiry, current badge, and terminate button per session
- Add mock handlers: PATCH /users/me, DELETE /auth/sessions/{id}
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add BulkTagEditor component: loads common/partial tags via
POST /files/bulk/common-tags and applies changes via POST /files/bulk/tags
- Common tags shown solid with × to remove from all files
- Partial tags shown with dashed border and ~ indicator; clicking promotes
to common (adds to files that are missing it)
- Wire "Edit tags" button in SelectionBar to a bottom sheet with the editor
- Add mock handlers for /files/bulk/common-tags and /files/bulk/tags
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add /pools list page with search, sort, load-more pagination
- Add /pools/new create form (name, notes, public toggle)
- Add /pools/[id] detail page: metadata editing, ordered file grid,
drag-to-reorder, filter bar, file selection/removal, add-files overlay
- Add pool sort store (poolSorting) to sorting.ts
- Wire "Add to pool" button in SelectionBar: bottom-sheet pool picker
loads pool list, user picks one, selected files are POSTed to pool
- Add full pool mock API handlers in vite-mock-plugin.ts (CRUD + file
management: add, remove, reorder with cursor pagination)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- /categories: list with colored pills, search + clear, sort/order controls
- /categories/new: create form with name, color picker, notes, is_public
- /categories/[id]: edit form + tags-in-category section with load more
- sorting.ts: add categorySorting store (name/color/created, persisted)
- mock: category CRUD, GET /categories/{id}/tags, search/sort/offset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Toggle button (filled/hollow circle) on each rule row
- Inactive rules dim to 45% opacity
- Toggle via delete + recreate with new is_active value
- Mock: track is_active per rule (Map instead of Set)
- Show all available tags by default in add-rule picker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>