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>
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>
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>
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>
- Make data.files.exif column nullable (was NOT NULL but service passes nil
for files without EXIF data, causing a constraint violation on upload)
- FileRepo.Create: include id in INSERT so disk storage path and DB record
share the same UUID (previously DB generated its own UUID, causing a mismatch)
- Integration test: use correct filter DSL format {t=<uuid>} instead of tag:<uuid>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add internal/integration/server_test.go covering the full happy-path
flow (admin login, user create, upload, tag assign, tag filter, ACL
grant, pool create/add/reorder, trash/restore/permanent-delete, audit
log). Also add targeted tests for blocked-user login prevention, pool
reorder, and tag auto-rules. Uses a disposable postgres:16-alpine
container via testcontainers-go.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add UserService (GetMe, UpdateMe, admin CRUD with block/unblock),
UserHandler (/users, /users/me), ACLHandler (GET/PUT /acl/:type/:id),
AuditHandler (GET /audit with all filters). Fix UserRepo.Update to
include is_blocked. Wire all remaining routes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add pool repo (gap-based position ordering, cursor pagination, add/remove/reorder
files), service, handler, and wire all /pools endpoints including
/pools/:id/files, /pools/:id/files/remove, and /pools/:id/files/reorder.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add category repo, service, handler, and wire all /categories endpoints
including list, create, get, update, delete, and list-tags.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
007_seed_data.sql: insert 10 MIME types (4 image, 6 video) with their
canonical extensions into core.mime_types.
disk.go: register golang.org/x/image/webp decoder so imaging.Open
handles WebP still images. Videos (mp4, mov, avi, webm, 3gp, m4v)
continue to go through the ffmpeg frame-extraction path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Files stored as {files_path}/{id} (no extension). The ext parameter
is removed from Save/Read/Delete in both the port interface and
the implementation.
Thumbnail and Preview both use imaging.Thumbnail (fit within
configured max bounds, never upscale, never crop) — the config
values THUMB_WIDTH/HEIGHT and PREVIEW_WIDTH/HEIGHT are upper limits,
not forced dimensions.
Non-decodable files (video, etc.) receive a #444455 placeholder.
Cache writes use atomic temp→rename; on cache failure the generated
image is served from memory so the request still succeeds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
filter_parser.go — recursive-descent parser for the {token,...} DSL.
Tokens: t=UUID (tag), m=INT (MIME exact), m~PATTERN (MIME LIKE),
operators & | ! ( ) with standard NOT>AND>OR precedence.
All values go through pgx parameters ($N) — SQL injection impossible.
file_repo.go — full FileRepo:
- Create/GetByID/Update via CTE RETURNING with JOIN for one round-trip
- SoftDelete/Restore/DeletePermanent with RowsAffected guards
- SetTags: full replace (DELETE + INSERT per tag)
- ListTags: delegates to loadTagsBatch (single query for N files)
- List: keyset cursor pagination (bidirectional), anchor mode,
filter DSL, search ILIKE, trash flag, 4 sort columns.
Cursor is base64url(JSON) encoding sort position; backward
pagination fetches in reversed ORDER BY then reverses the slice.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AuditRepo.Log resolves action_type_id/object_type_id via SQL subqueries.
AuditRepo.List supports dynamic filtering by user, action, object type/ID,
and date range with COUNT(*) OVER() for total count.
AuditService.Log reads user from context, marshals details to JSON,
and delegates to the repo.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add postgres ACLRepo (List/Get/Set) and ACLService with CanView/CanEdit
checks (admin bypass, public flag, creator shortcut, explicit grants)
and GetPermissions/SetPermissions for the /acl endpoints.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Login: bcrypt credential validation, session creation, JWT pair issuance.
Logout/TerminateSession: soft-delete session (is_active = false).
Refresh: token rotation — deactivate old session, issue new pair.
ListSessions: marks IsCurrent by comparing session IDs.
ParseAccessToken: for use by auth middleware.
Claims carry uid (int16), adm (bool), sid (int). Refresh tokens are
stored as SHA-256 hashes; raw tokens never reach the database.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
db/db.go: TxFromContext/ContextWithTx for transaction propagation,
Querier interface (QueryRow/Query/Exec), ScanRow generic helper,
ClampLimit/ClampOffset pagination guards.
db/postgres/postgres.go: NewPool with ping validation, Transactor
backed by pgxpool (BeginTx → fn → commit/rollback), connOrTx helper
that returns the active transaction from context or falls back to pool.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Define all repository interfaces in port/repository.go:
FileRepo, TagRepo, TagRuleRepo, CategoryRepo, PoolRepo, UserRepo,
SessionRepo, ACLRepo, AuditRepo, MimeRepo, and Transactor.
Add OffsetParams and PoolFileListParams as shared parameter structs.
Define FileStorage interface in port/storage.go with Save, Read,
Delete, Thumbnail, and Preview methods.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- internal/config: typed Config struct loaded from env vars via godotenv;
all fields from docs (listen addr, JWT, DB, storage, thumbs, import)
- migrations/embed.go: embed FS so goose SQL files are baked into the binary
- cmd/server/main.go: load config → connect pgxpool → goose migrations
(embedded) → Gin server with GET /health returning 200 OK
- .env.example: documents all required and optional env vars
- go.mod: bump to Go 1.26, add gin/pgx/goose/godotenv as direct deps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>