Adds a single window-level key dispatcher in the root layout: `g` then
c/t/f/p/s (or the digits 1–5) jump between the five sections, honouring
each section's remembered URL so you land back on the same filter and
scroll. `?` toggles a shortcuts cheat-sheet overlay. The handler stays
out of the way while typing in inputs or when a browser/OS modifier is
held. This is the foundation for the per-context keymaps (grid, viewer,
tag/filter pickers) that follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The offset-paginated lists lost their loaded items, search text and
scroll position when you left for another section, since search is local
(not in the URL) and the page unmounts on navigation. Each now snapshots
that state on departure and rehydrates it on return when the
sort/order/search still match, restoring scroll after the list paints.
Because these lists are edited on their own detail/new pages, the API
client drops the matching section's snapshot on any successful mutation
so a stale list never restores. Shared scroll-restore helper and an
OffsetListSnapshot type keep the three pages in lockstep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The grid grew without bound as you scrolled (and the section cache then
snapshotted the whole thing). It now keeps at most ~4 viewports of rows:
once it grows past the cap on one end, loadMore/loadPrev trim the
off-screen rows on the other end. The trimmed boundary cursor is dropped
and the opposite has-more flag is raised, so scrolling back refills that
side from an anchored window (?anchor=<file>), reusing the existing
prepend scroll-compensation. This bounds both live memory and the cached
snapshot regardless of how deep you scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Leaving the Files list for another section unmounted the page and lost
the loaded grid, cursors and scroll position; returning refetched page 1
from the top. A new in-memory section cache snapshots that state on
departure (beforeNavigate) and rehydrates it on the next mount when the
sort/filter still match, reapplying the scroll offset after the grid
paints. Combined with the navbar remembering the section URL, tapping
back into Files lands you exactly where you left off. The snapshot is
session-only, validated by resetKey, and skipped for in-page query
changes and the shallow-routed viewer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bottom-nav links pointed at the bare section roots, so leaving a list
and tapping its tab again dropped the active filter/sort in the query
string. The root layout (which never unmounts) now records the last list
root URL — including its query — per section on navigation, and the nav
links target that remembered URL. Only the list root is recorded, so
detail pages, the trash sub-view, and the shallow-routed file viewer
don't hijack the tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Escape now peels one layer at a time on the files page: an open tag
editor, pool picker, or delete confirm closes first, and only a second
Escape drops the multi-select. Selection-exit handling moves out of
SelectionBar into a single window handler on the page so precedence is
deterministic rather than dependent on window-listener fire order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Changing the filter (or sort/order) reset the grid to empty but never fetched
page 1 — the reset effect only loaded for the deep-link anchor case and
otherwise relied on InfiniteScroll, which doesn't re-trigger without a remount
or scroll. So filtering blanked the list until a hard refresh. Load page 1 from
the reset effect itself for the non-anchor case (guarded by `loading`, so it
doesn't double-fetch with InfiniteScroll's mount load).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New tag rules should retroactively apply to existing files by default, so flip
the tagRuleApplyToExisting default to true. (Settings already saved in
localStorage keep their stored value.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file viewer's preview is now a real link (target=_blank) to the original,
instead of fetching it into a blob. A navigation can't send the auth header, so
the access token rides in the query — the auth middleware accepts ?access_token=
as a fallback, but only for GET, so a crafted link can't drive a mutation.
GetContent gains an ?inline=1 toggle (Content-Disposition: inline) so the tab
views the original instead of downloading it; download stays the default.
Documented in openapi.yaml; TestMediaQueryTokenAuth covers GET-with-query-token
(200), missing token (401) and query-token rejected on a non-GET (401).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BulkTagEditor optimistically marked only the clicked tag as common after a bulk
add, so tags applied by auto-tag rules (resolved server-side) never appeared.
Refetch /files/bulk/common-tags after each change and rebuild the common/partial
sets from the response, so rule-applied tags and partial->common shifts show up.
Backend bulk path was already correct — covered now by TestBulkTagAutoRule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The activity.file_views table existed but nothing ever wrote to it. Add a
POST /files/{id}/views endpoint: FileRepo.RecordView inserts a history row,
FileService.RecordView enforces view ACL first. The file viewer fires it
(fire-and-forget) when a file is opened, including while paging prev/next.
Documented in openapi.yaml; covered by TestRecordFileView (204 on view,
repeatable, 404 for unknown file).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file viewer's assigned-tags list showed tags in API order, ignoring the
sort the user picked on the tags page. Add a client-side sortTags helper and
order the assigned list (in TagPicker) by tagSorting, reactively so it re-sorts
the moment the sort changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tag pickers, filter bar and batch editor loaded only /tags?limit=200 and
filtered client-side, so with more than 200 tags the rest were invisible and
unsearchable. Same for the category dropdowns on the tag forms.
Add fetchAllTags / fetchAllCategories helpers that page past the server's
per-request cap of 200, and order results by the sort the user chose on the
tags / categories page (tagSorting / categorySorting) instead of a hardcoded
name-asc. Wire them into FilterBar, TagPicker, TagRuleEditor, BulkTagEditor and
the tag new/edit category dropdowns.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The old autotags worked child -> parent (adding child_id pulled in parent_id,
per tfm__add_file_to_tag_recursive). The new tag_rules model is "when_tag
applied -> then_tag follows", so child must map to when_tag and parent to
then_tag. The transform had it reversed, so migrated rules never fired —
adding a tag added only itself.
Map child_id -> when_tag_id, parent_id -> then_tag_id. Verified on a scratch DB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both thumbnails and previews went through imaging.Thumbnail, which scales and
centre-crops to the exact dimensions — so portrait images lost their top and
bottom in the viewer (and the grid). Switch both to imaging.Fit, which scales to
fit within the bounds preserving aspect ratio, never cropping or upscaling. The
grid cell letterboxes the thumbnail via the existing object-fit: contain.
Note: cached *_thumb.jpg / *_preview.jpg are regenerated only when absent, so
clear THUMBS_CACHE_PATH after deploying to drop the old cropped renders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One-time migration from the old Python/Flask Tanabata DB into the new
core/data/acl/activity schema.
- transform.sql: reads a `legacy` schema and writes the new one in a single,
idempotent transaction. Remaps user/mime ids (uuid -> smallint by name),
inverts is_private -> is_public, lifts EXIF out of files.metadata into the
exif column, preserves pool hierarchy/created under metadata, synthesises
file_pool ordering, derives acl object types, sanitises colors/notes.
- migrate.sh: links the new DB to the old one via postgres_fdw, imports the
old public schema as `legacy`, runs the transform, tears the link down.
- README.md: mapping table, decisions/lossy points, and the separate
physical-blob copy step.
- docs/reference/schema.sql: the old DB schema the migration is built from
(referenced by the README).
Verified end-to-end on PostgreSQL 16 (synthetic legacy data, all
transformations and idempotency checked).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deploy to the production host on push to master via a self-hosted act_runner
(host/shell executor): git fetch + reset --hard in /opt/tanabata, then
docker compose up -d --build. Shell-only steps, so the host needs just git and
docker — no node, no rsync.
docs/DEPLOY.md covers the one-time setup: what a runner is, the runner user,
cloning to /opt/tanabata with a read-only deploy key, registering act_runner
with the host label, and the host .env. Notes the security reason to scope the
runner to this repository.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bundle the app + Postgres into a compose stack on top of the existing image.
- app: builds the image, publishes ${APP_PORT:-42776}, reads .env, pins
STATIC_DIR so SPA serving can't be disabled by an empty value
- db: postgres:14-alpine under the "with-db" profile; toggle it off via
COMPOSE_PROFILES to point the app at a Postgres on the host instead
(host.docker.internal), with depends_on required:false so it stays optional
Storage and the DB data dir each default to a named volume but can be bind
mounted to a host folder via FILES_DIR / THUMBS_DIR / IMPORT_DIR / DB_DIR.
Add PUID/PGID (via user:) so bind-mounted folders are writable by the
non-root container.
Run the container as a dedicated non-root user "tanabata" with uid/gid 42776,
reusing the project's signature number (also the default port). Document every
variable in .env.example.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make 42776 the project's default listen port everywhere :8080 was the default:
.env.example, the Go config fallback, the Dockerfile (ENV/EXPOSE/healthcheck),
and the docs example. 42776 is the sum of the Unicode code points of 七夕
(七 U+4E03 = 19971, 夕 U+5915 = 22805).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Run gofmt -w across the backend, normalising the manually-aligned := blocks
to the gofmt standard. No code behaviour changes.
Add Prettier (+ prettier-plugin-svelte) to the frontend with the SvelteKit
default config (tabs, single quotes) so formatting is reproducible, then run
it over the whole tree. Add format / format:check npm scripts and a
.prettierignore (build output, generated schema.ts, static assets).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a multi-stage Dockerfile that builds the SvelteKit SPA (adapter-static,
no Node runtime in the final image) and the Go server, then ships an Alpine
runtime that serves both the static frontend and the API on one port.
- Stage 1 (node): npm ci + build → static SPA (index.html, _app, fonts, sw)
- Stage 2 (golang): CGO_ENABLED=0 static binary (image processing is pure Go)
- Stage 3 (alpine): + ffmpeg for video thumbnails, non-root user, /data volume,
healthcheck on /health; secrets passed at runtime, not baked in
To serve the SPA on the API port, the Go server now optionally hosts static
files behind a new STATIC_DIR env var: a request maps to a real file when one
exists, otherwise falls back to index.html for client-side routes; unknown
/api/ paths still return JSON 404. Empty STATIC_DIR (local dev) keeps the API
standalone while Vite serves the UI. Cache-Control is tuned to adapter-static
output (immutable hashed assets, no-cache service worker) and .webmanifest is
registered so nosniff doesn't reject the PWA manifest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The light theme page background was a glaring near-white (#f5f5f5) with pure
white sheets. Mirror the dark theme's approach (its background isn't pure
black) by dimming the surfaces and adding a faint lavender tint from the brand
palette, keeping the surface relationships intact: page on the dimmest surface,
sheets brighter to pop, chips slightly darker for definition.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The service worker and web manifest were already in place; close the remaining
gaps against the PWA spec:
- pwa.ts: resetPwa() cleared caches and unregistered the service worker but
never reloaded, despite its docstring. Add the hard reload so the page
re-fetches everything from the network after a clear.
- service-worker.ts: pre-cache the SPA entry HTML ('/') alongside build/files
so the shell — and the offline navigation fallback — works from the very
first visit, not just after a navigation has been seen by the runtime cache.
- settings: resetPwa now reloads, so the post-clear success toast (which told
the user to reload manually) is unreachable and misleading. Drop the dead
pwaSuccess state and toast; keep the disabled "Clearing…" button state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>