178 Commits

Author SHA1 Message Date
H1K0 b8d08925a2 feat(backend): take video thumbnails from the middle frame
deploy / deploy (push) Successful in 1m1s
Video thumbnails and previews were extracted ~1s in, which lands on
shared intros, title cards or black lead-in frames. Take the frame from
the middle (duration/2) instead — the same frame used for the perceptual
hash — so the thumbnail/preview reflects what dedup compared. Fold the
midpoint logic into a shared extractVideoFrameMiddle helper reused by
both the thumbnail/preview path and VideoFrameMiddle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:14:04 +03:00
H1K0 dc40729646 fix(frontend): shift-select in gesture direction, not grid order
Shift range-select normalized the range with Math.min/Math.max and
always iterated ascending, so the selection's insertion order (which the
Set preserves and which carries through to e.g. pool add order) ignored
the gesture direction. Iterate anchor → target instead via a shared
selectRange helper, so selecting first→last and last→first yield
correspondingly ordered selections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:54:32 +03:00
H1K0 432b2d5b1e feat(backend): audit event for pool file reordering
deploy / deploy (push) Successful in 59s
Reorder was the only pool-file operation that didn't record an audit
entry, unlike AddFiles (file_pool_add) and RemoveFiles
(file_pool_remove). Log file_pool_reorder on success and seed the new
action type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:04:41 +03:00
H1K0 47d9cae15b feat(backend): progress bar for the dedup CLI
deploy / deploy (push) Successful in 53s
Both phases now share a small, dependency-free progress indicator: an in-place
bar on a TTY (e.g. `docker compose run`), and a line every 10% when stdout is
piped (cron/CI) so logs don't fill with carriage returns. Also fixes the pairs
phase, which mislabelled its progress as "hashed" — it now reads "matching".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:27:14 +03:00
H1K0 595eb5e06a docs(project): document DUPLICATE_HASH_THRESHOLD in .env.example
deploy / deploy (push) Successful in 1m3s
The dedup pairs rebuild reads this tunable (default 10/64); it was added to the
backend config but never documented for operators. No other new env vars were
introduced by duplicate detection — the dedup compose service reuses the
existing PUID/PGID/FILES_DIR/THUMBS_DIR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:21:23 +03:00
H1K0 19bdd3faa9 build(project): add one-shot dedup compose service
Adds a `dedup` task service under the "tools" profile so it's kept out of
`docker compose up` and run on demand:

  docker compose run --rm dedup            # hashes, then rebuild pairs
  docker compose run --rm dedup -pairs     # only rebuild pairs
  docker compose run --rm dedup -hashes    # only backfill hashes

It reuses the app image, .env, volumes and networks, overriding only the
entrypoint to /app/dedup. Unlike `docker exec` on the live server, this runs in
its own container and is self-documented for cron/CI use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:20:27 +03:00
H1K0 16e68236a0 build(project): ship the dedup CLI in the runtime image
The backend build stage compiled only ./cmd/server, so the dedup maintenance
tool was never available on deploy. Build it alongside the server and copy
/out/dedup to /app/dedup in the runtime image (which already has ffmpeg/ffprobe
for video frames and the /data volume). Run it with `docker exec`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:13:53 +03:00
H1K0 dcbe640fae feat(frontend): duplicates view, field-by-field merge dialog, api module
Adds the duplicate-detection UI:

- api/duplicates.ts: getDuplicates / dismissDuplicate / resolveDuplicate, plus
  the cluster and merge-field types.
- /files/duplicates: an offset-paginated list of clusters. Each cluster shows its
  files (auth-loaded thumbnails via a reusable Thumb component); the user clicks a
  file to mark it the survivor, then per other file: Merge, Delete, or "Not a dup"
  (dismiss). The list reloads after each action so it stays consistent with the
  rescan-gated server state.
- DuplicateMergeDialog: a bottom sheet to merge two files field-by-field — each
  scalar from the kept or other file, metadata keep/other/merge, tags & pools
  keep-or-union, with a swap-survivor toggle and an optional trash-the-other box.
- Entry point: a Duplicates action in the files Header next to Trash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:08:46 +03:00
H1K0 96a903aaff docs(project): document duplicate detection endpoints
Adds GET /files/duplicates, POST /files/duplicates/dismiss and POST
/files/duplicates/resolve to the OpenAPI spec, plus the DuplicateCluster,
DuplicateClusterPage and DuplicateResolve (with MergeScalarChoice /
MergeRelationChoice) schemas describing the field-by-field merge contract.

Also fills a pre-existing gap in the File schema: it now documents the `tags`
array (always returned by the API) and marks the always-present fields required,
so generated clients type these as non-optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:07:37 +03:00
H1K0 6e3e6a4194 feat(backend): dedup CLI (hash backfill + pairs rescan)
cmd/dedup is the offline maintenance tool for duplicate detection. It reuses the
server's config and runs two phases (both by default; -hashes / -pairs to pick):

- hashes: compute the perceptual hash of every live image/video missing one —
  images from their bytes, videos from a middle frame via DiskStorage.
  VideoFrameMiddle. Per-file failures are reported and counted, not fatal.
- pairs: rebuild data.duplicate_pairs from all current hashes (DuplicateService.
  Rescan).

Idempotent and safe to re-run: hashing only touches NULL phashes, the pairs
rebuild is a full replace. This is how video phashes and any backlog get
computed, and how newly uploaded duplicates become visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:46:40 +03:00
H1K0 9216a8687f feat(backend): duplicate pairs, dismissals, and merge resolution
Adds the duplicate-detection backend on top of perceptual hashing:

- Two tables (edited into the original migrations): data.duplicate_pairs holds
  precomputed near-duplicate candidates (rebuilt wholesale by the rescan), and
  data.duplicate_dismissals is a global "not a duplicate" overlay that survives
  rescans. New audit actions file_merge / duplicate_dismiss.
- DuplicateService:
  - Rescan builds every pair within DUPLICATE_HASH_THRESHOLD via a BK-tree over
    the perceptual hashes and replaces the pairs table. This is the only thing
    that populates pairs, so GET never compares all-vs-all (scales to 110k+).
  - Clusters reads the precomputed pairs (ACL-filtered, non-trashed, non-
    dismissed), groups them into connected components via union-find, and
    paginates whole clusters.
  - Resolve merges a pair field-by-field: each scalar from keep or discard,
    metadata keep/discard/shallow-merge, tags/pools keep or union; then trashes
    the discarded file. Enforces edit ACL on both.
  - Dismiss records a canonical pair (view ACL on both).
- Endpoints under /files: GET /files/duplicates, POST /files/duplicates/dismiss,
  POST /files/duplicates/resolve (registered before /:id to avoid collision).
  Plain delete reuses /files/bulk/delete.
- Repo support: ListMissingPHash, ListAllPHashes, CopyPoolMemberships, plus the
  DuplicatePairRepo (ReplaceAll via COPY, ListVisible) and DismissalRepo.

Unit tests cover the BK-tree pairing, union-find clustering, metadata merge and
field validation; an integration test covers rescan -> list -> merge -> dismiss
(including that a dismissal survives a re-rescan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:42:37 +03:00
H1K0 88849cc16b feat(backend): perceptual hashing for images and video
Adds a 64-bit dHash perceptual hash (internal/imagehash, built on the existing
disintegration/imaging — no new dependency) and starts populating the long-unused
data.files.phash column:

- Upload sets phash inline for images (cheap, from the in-memory bytes).
- Replace recomputes it from new content for images and clears it for anything
  else, so a stale hash never survives a content swap.
- FileRepo.SetPHash sets/clears the hash (used by Replace and, later, the dedup
  backfill).
- DiskStorage.VideoFrameMiddle extracts a frame from the middle of a clip
  (ffprobe duration -> ffmpeg -ss duration/2), avoiding the shared-intro collision
  a fixed early offset causes. It is a concrete method, not part of the storage
  port: only the dedup CLI needs it, keeping ffmpeg off the upload path. Video
  phashes are therefore computed by that CLI, not at upload time.
- DUPLICATE_HASH_THRESHOLD config (default 10/64) for the later pair rescan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:20:52 +03:00
H1K0 58cea88f52 fix(project): drop external dockerfile frontend directive
deploy / deploy (push) Successful in 3s
The `# syntax=docker/dockerfile:1` line made BuildKit fetch its frontend
image from Docker Hub on every build, even when all base images and layers
were already cached. On a host that briefly can't resolve registry-1.docker.io
this is the first and only mandatory network round-trip, so the build fails at
"resolve image config for docker-image://docker.io/docker/dockerfile:1" before
any stage runs.

This Dockerfile uses no frontend-specific syntax (no heredocs, no RUN --mount,
no COPY --link) — only multi-stage, COPY --from/--chown, RUN, ENV, etc., all
handled by the engine's built-in frontend. Dropping the directive removes the
Docker Hub dependency and lets a fully cached build complete offline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:15:23 +03:00
H1K0 b25085bcd7 feat(backend): set application_name on database connections
deploy / deploy (push) Successful in 1m38s
Tag pooled connections with application_name="tanabata-backend" via the
parsed pgxpool config, so the backend's sessions are identifiable in
pg_stat_activity and server logs. An application_name supplied in the
DSN (or PGAPPNAME) still takes precedence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:51:37 +03:00
H1K0 cf6c312e04 fix(frontend): don't treat empty 2xx bodies as request failures
The API client only skipped JSON parsing for 204, so a success with an
empty body (e.g. 201 from POST /pools/:id/files) hit res.json() on an
empty stream, threw, and surfaced as "Failed to add to pool" even though
the add had committed. Read the body as text and parse only when present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:43:23 +03:00
H1K0 da4ce37aff fix(frontend): use opaque background icons for PWA install
The manifest's largest, maskable icon (ms-icon-310x310) was transparent,
so installing the PWA produced a transparent app icon. Generate opaque
192/512 "any" + maskable icons from favicon-bg.png (solid #524B6B
background, maskable variants padded into the inner 80% safe zone) and
point the manifest at them instead of the transparent ms-icon entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:39:41 +03:00
H1K0 70d12615b8 feat(frontend): keyboard navigation for tag and category grids
deploy / deploy (push) Successful in 23s
Mirror the Files grid's roving keyboard focus on the tag and category
lists (and the tags shown on a category page): arrows move a focus ring,
Enter opens the focused item, "/" jumps to search, Escape drops the ring.
Extracts the model into a reusable createRovingGrid controller; vertical
movement is geometric since the pills wrap at variable widths. The
tag/category edit pages gain Escape-to-leave parity with the file viewer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:36:36 +03:00
H1K0 97d6daaa13 fix(frontend): stop trimming the file grid while scrolling
The grid windowed to ~4 viewports, dropping off-screen rows as it grew.
That broke large multi-selects: range/drag selection could not span past
trimmed cards, which silently vanished mid-scroll. Accumulate all loaded
rows for the visit instead; the grid is still cleared on sort/filter
change and on leaving the page (reset effect + section cache). Removes
the now-dead trim/anchor-refill fallbacks in loadMore/loadPrev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:27:19 +03:00
H1K0 95db88388b feat(frontend): add MIME filter controls to FilterBar
deploy / deploy (push) Successful in 24s
The DSL already supported m~/m= tokens but the filter UI had no way to
add them. Add Images/Video quick buttons and a free-text MIME input that
append m~<pattern> tokens (LIKE on the type name), plus friendly token
labels in dsl.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:52:12 +03:00
H1K0 a864ca4f7b feat(frontend): review-status filter, badge, and review toggles
deploy / deploy (push) Successful in 1m28s
FilterBar gains an Any/Needs review/Reviewed segment (r=1/r=0 token);
FileCard shows a "needs review" dot; FileViewer gets a header toggle that
propagates back to the grid; SelectionBar gains a bulk "Mark reviewed"
action. Adds a --color-success theme token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:17:11 +03:00
H1K0 5bb53d7f9d docs(project): document review status (filter token + bulk/review)
Add File.needs_review, the POST /files/bulk/review path, and the r=1/r=0
filter tokens to the DSL description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:17:02 +03:00
H1K0 48e901cac1 feat(backend): per-file review status with DSL filter and bulk endpoint
Replaces the old "untagged" sentinel tag with a proper per-file workflow
status: needs_review starts true on upload/import and is cleared by an
explicit action (no auto-clear on tagging). Surfaced as a filter token
(r=1 needs review, r=0 done) so it combines with tag/MIME conditions, and
toggled via POST /files/bulk/review (single id or many, edit-ACL enforced,
audit-logged as file_review).

needs_review lives on data.files (column added to the original 003 migration,
partial index in 006, action type seeded in 007).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:16:47 +03:00
H1K0 4d11beb296 feat(backend): import server-folder files oldest-first by mtime
os.ReadDir returns entries in name order; sort them by ascending mtime
before importing so each Upload's created_at reflects the files'
chronological order. mtimes are cached once for the sort and reused as the
content_datetime fallback, dropping the redundant per-file Info() call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:16:24 +03:00
H1K0 57192a49f9 fix(backend): apply auto-tag rule to existing files on creation
CreateRule accepted apply_to_existing but ignored it, so enabling the
checkbox while creating a rule never retroactively tagged files already
carrying the when-tag — only activating an existing rule did. Extract the
retroactive expansion into TagRuleRepo.ApplyToExisting (reused by SetActive)
and call it from CreateRule when the rule is active, inside one transaction
so a file is never left half-tagged. Mirrors SetRuleActive semantics,
including following only active downstream rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:09:31 +03:00
H1K0 9937984a5a feat(backend): reject non-positive token TTLs at config load
Every duration in the config is a token TTL (access, refresh, content). A zero
or negative value mints already-expired tokens — no login, no media playback —
and previously loaded silently. parseDuration now rejects <= 0 with a clear
error, so misconfiguration fails fast at startup instead of mysteriously at
runtime. The AuthService itself stays permissive (it's constructed directly in
tests with arbitrary TTLs); config load is the gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:56:25 +03:00
H1K0 6fba04cd00 docs(project): document the content-token endpoint and CONTENT_TOKEN_TTL
Add POST /files/{file_id}/content-token to the spec, note that the content
GET's access_token parameter also accepts a content token, and document the
CONTENT_TOKEN_TTL knob (default 6h) and its leak/revocation trade-off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:53:30 +03:00
H1K0 35b1b2e6d5 feat(frontend): use a content token for the open-original link
Mint a content token on file load (POST /files/:id/content-token) and put it in
the original-content URL instead of the access token, so opening an original —
especially a long video — in a new tab keeps working past the 15-minute access
token expiry. Falls back to the access token until the content token arrives,
and re-mints when paging to another file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:53:20 +03:00
H1K0 98de298e5b feat(backend): file-scoped content tokens for media URLs
Opening an original by URL (?access_token=) baked in the 15-minute access
token, so a long video opened in a new tab stopped streaming once that token
expired mid-playback: the access token can't be refreshed in an already-opened
tab, and its next Range request 401'd.

Add a content token: a signed, single-file capability (typ=content, fid claim)
with its own longer TTL (CONTENT_TOKEN_TTL, default 6h) and — crucially — no
session id, so it survives refresh rotation and outlives the short access TTL.
POST /files/:id/content-token mints one after the same view-ACL check content
serving does; GET /files/:id/content now runs under content-aware auth that
accepts either a normal access token or a content token scoped to that file.
View permission is still enforced against the token's user, so the token only
changes when a file may be read by URL, never which files. It's a bearer
capability for that one file until expiry, hence the bounded, configurable TTL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:53:10 +03:00
H1K0 b470782e97 docs(project): add README with reverse-proxy and quick-start guide
Root README covering the stack, quick start, and dev commands, plus a Reverse
proxy (nginx) section: client_max_body_size for large uploads, forwarded headers
feeding the rate limiter, and buffering-off for streaming large media.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:52:09 +03:00
H1K0 5a05bb86e1 build(project): publish app on loopback and segment Docker networks
Bind the published port to 127.0.0.1 so the app is reachable only through the
host reverse proxy, not on the LAN/WAN — a 0.0.0.0 publish would also bypass
ufw/firewalld, since Docker's DNAT rules sit ahead of the host firewall.

Split the stack onto two networks with deterministic bridge names: `web`
(dk-tanabata) for the public-facing side, and `backend` (dk-tanabata-bnd,
internal:true) for the private app↔DB tier. The DB sits only on `backend`, which
has no gateway, so it has no route off-host.

Document TRUSTED_PROXIES and the loopback publish in .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:51:57 +03:00
H1K0 99668ec0d8 feat(backend): trust reverse-proxy X-Forwarded-For for the client IP
The auth rate limiter keys on c.ClientIP(), but the router was built with
gin.New() and never called SetTrustedProxies — so Gin trusted all proxies by
default. Behind a host reverse proxy that meant the limiter either bucketed
every request under the proxy's IP, or (with the port reachable directly) could
be bypassed by a forged X-Forwarded-For.

NewRouter now takes a trusted-proxy list and configures SetTrustedProxies,
returning an error on an invalid list so misconfiguration fails fast at startup.
The list comes from a new TRUSTED_PROXIES config (CSV of CIDRs/IPs), defaulting
to loopback plus the Docker bridge ranges a host proxy reaches the container
through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:51:44 +03:00
H1K0 88e07f0723 build(project): install vips-tools for image thumbnailing
deploy / deploy (push) Successful in 57s
Add vips-tools (vipsthumbnail) to the runtime image alongside ffmpeg and
exiftool, and document THUMB_MAX_PIXELS as the pure-Go fallback guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:25:55 +03:00
H1K0 384386a34e feat(backend): generate thumbnails/previews via vipsthumbnail
Use vipsthumbnail as the primary still-image path for both thumbnails
and previews (shared serveGenerated), falling back to the pure-Go
imaging pipeline when vips isn't on PATH. vips shrinks on load (e.g.
JPEG DCT scaling), so a 200+ Mpx photo is resized in a fraction of the
memory and CPU of a full in-process decode and no longer exceeds the
decode cap — the source that previously got only a placeholder now gets
a real thumbnail and preview. The output JPEG is written straight to the
cache (atomic temp→rename), fit within the target box and never upscaled.

The in-process pixel cap now guards only the pure-Go fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:25:55 +03:00
H1K0 e694f17be9 docs(project): document THUMB_MAX_PIXELS and THUMB_CONCURRENCY
deploy / deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:07:30 +03:00
H1K0 2d2a42d523 fix(backend): bound thumbnail generation and decode larger images
Thumbnails/previews are generated lazily per request with no concurrency
limit, and the imaging resize already fans out across every core — so
scrolling to a handful of large images spawned that many all-core,
hundreds-of-MB decodes at once and pegged the server. Add a generation
semaphore (THUMB_CONCURRENCY, default = half the CPUs) so only a bounded
number run at a time; queued requests wait and re-check the cache.

Also raise the decode cap from 64 Mpx to a configurable ~300 Mpx default
(THUMB_MAX_PIXELS) so genuinely large photos (e.g. 13000×17000 ≈ 221
Mpx) get a real thumbnail instead of falling back to a placeholder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:07:30 +03:00
H1K0 a371045b41 fix(frontend): keep auth tokens in sync across browser tabs
deploy / deploy (push) Successful in 1m4s
Refresh tokens rotate on every use and each refresh deletes the old
session server-side, so when one tab refreshed, other open tabs were
left holding a dead access token and a rotated-away refresh token —
their next request 401'd and bounced them to the login screen.

Sync the auth store across tabs via the storage event (propagating
logins, refreshes, and logouts), and make refresh race-resilient: if a
refresh fails but a newer token has meanwhile synced in from another
tab, adopt it and retry instead of ending a still-valid session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:13:13 +03:00
H1K0 da867406e2 fix(frontend): let a tag be created/edited without a colour
A native <input type="color"> always holds a value, so the form always
sent the input's default colour and a tag could never be colourless. Add
a "Color" checkbox gating the swatch: off by default on the new-tag form
(so tags are colourless unless opted in) and initialised from the tag on
the edit form, which can now clear a colour. Sends color: null when off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:31:59 +03:00
H1K0 21c7aa31ea fix(backend): store an empty tag colour as NULL
The PATCH "clear colour" path sent an empty string, which violates the
hex CHECK constraint and never falls back to the category colour. Map ''
to NULL via NULLIF in the tag insert/update so a cleared or omitted
colour is stored as NULL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:31:59 +03:00
H1K0 4def59c86d feat(frontend): log a pool view when the pool page opens
Fire POST /pools/{id}/views fire-and-forget after the pool loads, the
same way the file viewer logs file views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:20:46 +03:00
H1K0 d345839634 docs(project): document the pool view endpoint
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:20:46 +03:00
H1K0 7d0ea4e388 feat(backend): record pool views to activity.pool_views
Add POST /pools/{id}/views, mirroring the file-view endpoint: it
enforces view ACL and appends a row to activity.pool_views (viewed_at
defaults to statement_timestamp(), so each view is its own history row).
The table existed but nothing wrote to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:20:46 +03:00
H1K0 38572b1c80 feat(frontend): add "add to pool" button on the file viewer
deploy / deploy (push) Successful in 58s
Extract the bottom-sheet pool picker (load, search, add) into a
reusable PoolPicker component and use it both for the grid's bulk
selection and from a new button in the file viewer's top bar, which adds
the single open file to a chosen pool. While the picker is open the
viewer hands it the keyboard so Escape closes the sheet (even from its
search) instead of the viewer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:58:01 +03:00
H1K0 fedfa8df3a feat(frontend): tie-break category-name tag sort by tag name
Mirror the server's secondary sort in the client-side sortTags (used for
a file's assigned tags) so the assigned and available lists agree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:47:55 +03:00
H1K0 cb1588ecc0 feat(backend): sort tags by category then tag name
The category_name tag sort now breaks ties within a category by the
tag's own name (same direction), so tags group by category and read
alphabetically inside each group; uncategorized tags stay last.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:47:42 +03:00
H1K0 73ae8a046f feat(backend): record tag usage in filters to activity.tag_uses
Listing files with a tag filter now logs each referenced tag to
activity.tag_uses, flagging it included (positive) or excluded (negated
under an odd number of NOTs); the untagged pseudo-token is skipped. The
filter AST is reused to determine polarity, so grouped negations like
!(A|B) mark both tags excluded.

Recording happens only when a filter is first applied — not on cursor
pagination or an anchored return — so one browse counts once. The write
is best-effort and never fails the listing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:40:13 +03:00
H1K0 6a3bb9ff51 feat(frontend): stage Escape in the tag editors to clear, then exit
In the single-file viewer's tag filter, Escape now clears a non-empty
filter first; on an empty filter it blurs and scrolls the preview back
to the top. In the bulk (multi-file) editor it clears, then releases
focus, so only the next Escape reaches the page handler and closes the
popup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:27:06 +03:00
H1K0 ca3bca59e7 docs(project): document the import progress NDJSON stream
deploy / deploy (push) Successful in 1m3s
Describe /files/import's application/x-ndjson response and the start/file/
done/error event schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:15:50 +03:00
H1K0 c8bd8512ce feat(frontend): show import progress bar and per-file status
Consume the import endpoint's NDJSON progress stream via a new postStream
client helper (reuses the bearer token and 401 refresh, but keeps the body
as a stream). The Settings import card now renders a live progress bar
(processed/total) and a scrolling per-file list where each entry shows its
status — imported, skipped or error — with the failure reason inline and the
newest row kept in view. A final summary replaces the old single-shot result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:15:50 +03:00
H1K0 129cc59793 feat(backend): stream folder-import progress as NDJSON
The import endpoint did all the work in one request and returned only an
aggregate summary, so the UI couldn't show progress or per-file status.

Refactor FileService.Import to take an optional progress callback and emit
a "start" event (with the total entry count), one "file" event per entry as
it finishes (index, filename, status, optional reason), and a final "done"
event with the tallies. The handler streams these as newline-delimited JSON
and flushes after each, deferring the response headers until the first event
so a validation error raised before any file is touched is still returned as
a normal JSON error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:15:40 +03:00
H1K0 5571dfa46d chore(project): install exiftool in the runtime image
The backend now shells out to exiftool for metadata extraction, so it must
be present alongside ffmpeg in the Alpine runtime stage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:55:33 +03:00
H1K0 52c62b5c8d feat(backend): extract rich numeric metadata via exiftool
The previous goexif reader only understood EXIF in JPEG/TIFF, so videos,
PNGs and any image without an EXIF block were stored with no metadata at
all. Shell out to exiftool instead (the same tool the prior version used),
which covers images, video and audio in one pass.

Run it with `-n` so every tag comes back as a raw numeric/machine value
(FileSize in bytes, Duration in seconds, AvgBitrate as a number) rather
than human-readable strings — the metadata is the basis for analytics, not
decoration. Temp-file artifacts (SourceFile/Directory/permissions/inode
dates) are stripped and FileName is set to the original.

content_datetime now resolves from the first real capture date in the
metadata (DateTimeOriginal, then the video CreateDate atoms), still falling
back to the import mtime. When exiftool isn't on PATH the pure-Go EXIF
reader remains as a graceful fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:55:23 +03:00
H1K0 0b0f797fae chore(project): name the app container tfm
Give the app service an explicit container_name so it shows up as `tfm`
instead of the generated `tanabata-app-1`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:34:30 +03:00
H1K0 48ea1fe720 fix(frontend): make the grid actually follow keyboard focus
deploy / deploy (push) Successful in 20s
The previous attempt wrote scrollContainer.scrollTop, but <main> isn't the
scroll element here (the window/document scrolls, as getScroller and the
infinite-scroll listeners assume) — so it was a no-op and the grid stopped
following the focus. Move back to scrollIntoView({block:'nearest'}), which
scrolls whatever element actually scrolls, and give the card
scroll-margin-top/-bottom so it clears the sticky header and the fixed
navbar instead of sliding under them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:19:08 +03:00
H1K0 af9f4c77db fix(frontend): let Escape leave the tag search field in the viewer
deploy / deploy (push) Successful in 17s
The viewer's tag picker focuses its search input on `e`, but the input
swallowed every key and the viewer's own handler bails on input targets,
so there was no keyboard way out of the field. Escape now blurs the input
back to the page, restoring arrow/Escape navigation in the viewer (a
second Escape then closes it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:47:34 +03:00
H1K0 76dcb8721a fix(backend): serve original content with byte-range support
deploy / deploy (push) Successful in 1m1s
GetContent streamed the whole file with a plain 200/io.Copy and no
Accept-Ranges, so the browser couldn't seek or scrub audio/video opened
from the viewer. It now serves seekable bodies (the disk store returns an
*os.File) via http.ServeContent, which advertises Accept-Ranges and
answers Range requests with 206 Partial Content; non-seekable bodies
still fall back to a plain stream. Adds an integration test asserting a
ranged request returns 206 with the right Content-Range and bytes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:40:49 +03:00
H1K0 cefa33c00d feat(frontend): focus the returned file when leaving the viewer
Closing the viewer (or returning via a deep-link ?anchor=) already
scrolled the grid back to the file you were on, but the keyboard
roving-focus stayed unset, so the next arrow press jumped to the top.
Both return paths now place the focus on that file and show the ring, so
arrow navigation resumes exactly where you left off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:33:30 +03:00
H1K0 6e5c4dc623 feat(frontend): admin "Import from server" panel in Settings
deploy / deploy (push) Successful in 57s
Surfaces the previously UI-less POST /files/import: an admin-only Settings
card with an optional subfolder field, an Import button, and a result
summary (imported / skipped / per-file errors). Notes that imported files
are drained from the folder and that mtime is kept as the date when EXIF
is absent. Also documents the endpoint's drain + mtime behaviour in the
OpenAPI spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:23:22 +03:00
H1K0 1b04d67e20 feat(backend): drain import folder and keep mtime on server-side import
The directory import now removes each source file after it is safely
ingested, so the import folder drains and re-running doesn't create
duplicates (a removal failure is reported per-file but doesn't undo the
import). It also captures the source file's mtime and passes it as a new
ContentDatetimeFallback on Upload, used for content_datetime only when
the file has no EXIF date — so non-photo files keep a meaningful date
instead of the zero value once the source is gone. Adds an integration
test covering ingest, directory skip, source removal and the mtime
fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:19:59 +03:00
H1K0 bce79867e4 fix(frontend): scroll the grid to follow the keyboard focus
deploy / deploy (push) Successful in 17s
Arrowing up/down moved the focus ring but the view didn't follow: the
card was scrolled with scrollIntoView({block:'nearest'}), which aligns to
the scroller's edges and is unaware of the fixed bottom navbar overlaying
the scroll area — so the newly focused row slid under the navbar. Replace
it with a manual scroll that keeps the focused card inside the scroller
with a top margin and a bottom margin sized for the navbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:01:35 +03:00
H1K0 e39cda9ec4 feat(frontend): keyboard range-select with Shift+Space / Shift+x
Plain Space/x toggles the focused card and drops a range anchor there;
Shift+Space / Shift+x now selects everything from that anchor to the
focused card, sharing the same anchor (lastSelectedIdx) as Shift+click so
mouse and keyboard range-selection are interchangeable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:59:16 +03:00
H1K0 94d100675e feat(frontend): make keyboard shortcuts layout-independent
Command keys were matched by character (e.key), so on a non-Latin layout
(e.g. Russian) the physical g/f/e/p/x/j/k keys emitted Cyrillic letters
and nothing fired. Letter and digit commands now match by physical
position (e.code: KeyG, Digit1, Slash, …) across the global nav, the file
grid, and the viewer, so the same physical keys work on any layout. Named
keys (arrows, Enter, Esc, Delete), the Mod combos, and the filter's
literal operators (& | ! ( )) stay on e.key, where character matching is
correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:55:39 +03:00
H1K0 19ec96c544 feat(frontend): focus the page search box with / on the list sections
deploy / deploy (push) Successful in 18s
Extends the global `/` shortcut to focus the always-on search input on
Tags/Categories/Pools, matching what the help overlay advertises. Files
keeps its own `/` handler since it has no persistent input and instead
opens the filter bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:43:24 +03:00
H1K0 49e68cc263 feat(frontend): keyboard nav for the bulk tag editor
The grid's `e` opens the bulk tag editor, which has its own UI rather than
the shared TagPicker, so it needed the same keyboard handling: from the
search input, ↓/↑ highlight a suggestion and Enter adds it to all
selected files (focus stays for chaining); with the input empty ←/→ walk
the assigned tags and Del removes the focused one. Mirrors the viewer's
tag picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:42:02 +03:00
H1K0 3a0dbc9ba7 feat(frontend): keyboard nav for the viewer, tag editor and filter
Viewer: j/k mirror the arrow keys, and `e` scrolls to the (lazy) Tags
section and drops the cursor into its filter, forcing the load so focus
lands even before the section is reached.

Tag picker & filter bar: from the search input, ↓/↑ highlight a
suggestion and Enter adds it (focus stays for chaining); with the input
empty ←/→ walk the added tags/tokens and Del removes the focused one. The
filter bar also inserts an operator token on & | ! ( ), applies on
Ctrl+Enter, resets on Ctrl+Backspace and closes on Esc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:40:03 +03:00
H1K0 9a20cc1c84 feat(frontend): keyboard roving-focus and bulk-action keys on the file grid
Arrow keys move a focus ring across the grid (column count derived from
the layout, scrolling the focused card into view and pulling the next
page near the end). Enter opens the focused file; Space/x select; e edits
tags (opening the sheet and focusing its tag filter); p adds to a pool;
Del moves to trash — each falling back to the focused card when nothing
is selected. / opens the filter and focuses its search. The ring only
appears once keyboard navigation starts and is dismissed on pointer use,
so it never distracts mouse users. Escape layering is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:30:13 +03:00
H1K0 49de9fe42b feat(frontend): global keyboard navigation and shortcuts help overlay
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>
2026-06-11 17:24:39 +03:00
H1K0 2b39af8c1c feat(frontend): cache Tags/Categories/Pools lists across section switches
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>
2026-06-11 17:20:48 +03:00
H1K0 05af819b3e feat(frontend): cap the Files grid at ~4 viewports with edge windowing
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>
2026-06-11 17:03:43 +03:00
H1K0 e93240ff79 feat(frontend): restore the Files grid and scroll on return via section cache
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>
2026-06-11 16:56:33 +03:00
H1K0 370dfd95bc feat(frontend): remember each section's last list URL in the navbar
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>
2026-06-11 16:53:00 +03:00
H1K0 e97b7282ff fix(frontend): make Escape dismiss overlays before clearing selection
deploy / deploy (push) Successful in 18s
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>
2026-06-11 16:04:11 +03:00
H1K0 9a14c50250 fix(frontend): reload the file grid on filter/sort change
deploy / deploy (push) Successful in 57s
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>
2026-06-11 15:49:34 +03:00
H1K0 e7d24f0677 feat(frontend): default the apply-to-existing tag-rule toggle on
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>
2026-06-11 15:49:23 +03:00
H1K0 d357ae3156 feat: open file original in a new tab via authenticated direct link
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>
2026-06-11 15:40:50 +03:00
H1K0 03936243e4 fix(frontend): reflect rule-applied tags in batch edit
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>
2026-06-11 15:06:25 +03:00
H1K0 a78fc5ba9a feat(backend): log file views
deploy / deploy (push) Successful in 1m0s
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>
2026-06-11 14:49:14 +03:00
H1K0 8f213e780c fix(frontend): sort a file's assigned tags by the chosen tag order
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>
2026-06-11 14:22:23 +03:00
H1K0 88a8cac048 fix(frontend): load full tag/category lists in pickers, honoring sort
deploy / deploy (push) Successful in 19s
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>
2026-06-11 13:58:23 +03:00
H1K0 aae0c587e8 fix(scripts): correct autotag rule direction in legacy migration
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>
2026-06-11 13:45:52 +03:00
H1K0 f73f954b1a fix(backend): show whole image in thumbnails and previews (contain)
deploy / deploy (push) Successful in 51s
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>
2026-06-11 12:56:21 +03:00
H1K0 76942721ad chore(scripts): add legacy data migration
deploy / deploy (push) Successful in 5s
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>
2026-06-11 12:43:43 +03:00
H1K0 437b66e73a ci(project): add Gitea Actions deploy workflow and docs
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>
2026-06-11 12:15:33 +03:00
H1K0 fce71bb946 feat(project): add Docker Compose with flexible storage and DB modes
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>
2026-06-11 11:26:25 +03:00
H1K0 69650b6464 chore(project): set the default port to 42776
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>
2026-06-11 11:03:26 +03:00
H1K0 0e7890a465 style(project): format Go with gofmt, set up Prettier for the frontend
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>
2026-06-11 11:01:29 +03:00
H1K0 f5f7db6c2a feat(project): containerize as a single image serving SPA + API
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>
2026-06-11 10:52:27 +03:00
H1K0 aab62cbe41 style(frontend): mute the light theme background
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>
2026-06-11 10:40:05 +03:00
H1K0 7a0c57a79c feat(frontend): finish PWA reset reload and pre-cache the HTML shell
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>
2026-06-11 10:36:20 +03:00
H1K0 e8479ee4fb docs(project): record SPA-mode decision and SvelteKit feature usage
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>
2026-06-11 10:28:56 +03:00
H1K0 1f3bc2acf4 fix(frontend): open pool files in an overlay so back returns to the pool
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>
2026-06-11 10:22:34 +03:00
H1K0 5968a7b593 fix(frontend): drive infinite scroll from scroll position, not observer transitions
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>
2026-06-11 10:17:59 +03:00
H1K0 e801eec47d feat(frontend): bidirectional lazy load for anchored grid returns
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>
2026-06-11 01:16:32 +03:00
H1K0 dc1af8c585 fix(frontend): make infinite scroll viewport-relative, stop eager-loading
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>
2026-06-11 00:14:04 +03:00
H1K0 ffb8848a96 chore(frontend): bump mock files to 500 to exercise lazy load
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>
2026-06-11 00:05:43 +03:00
H1K0 fa491487b7 feat(frontend): open file viewer as overlay over the mounted list
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>
2026-06-11 00:02:25 +03:00
H1K0 4f8d6a41f9 feat(frontend): lazy infinite scroll on tags/categories/pools lists
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>
2026-06-10 23:46:29 +03:00
H1K0 18f1dbc052 fix(frontend): restore grid position via URL anchor on return from viewer
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>
2026-06-10 16:30:26 +03:00
H1K0 a1ec25a441 perf(frontend): lazy-load file tags on scroll into view
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>
2026-06-10 15:18:57 +03:00
H1K0 89ba6bae82 fix(backend): enforce private-by-default visibility and pool-op ACL
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>
2026-06-10 15:07:17 +03:00
H1K0 2af3c481bb fix(frontend): redirect to /login when the session can't be refreshed
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>
2026-06-10 14:55:12 +03:00
H1K0 00f63697b0 fix(frontend): render nested EXIF values instead of [object Object]
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>
2026-06-10 14:55:12 +03:00
H1K0 2ef055a41a fix(backend): pool reorder no longer drops omitted members
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>
2026-06-10 14:53:26 +03:00
H1K0 ec96fced40 perf(backend): cache thumbnail, preview and content responses
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>
2026-06-10 14:50:32 +03:00
H1K0 5b973cf534 fix(frontend): resolve svelte-check type errors
- 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>
2026-06-10 14:49:17 +03:00
H1K0 bcbe0b5e8c fix(frontend): redirect root route to /files
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>
2026-06-10 14:40:47 +03:00
H1K0 a360cab2fc fix(frontend): use search param for pool "add files" name search
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>
2026-06-10 14:40:47 +03:00
H1K0 f8f58434d5 feat(frontend): restore files grid position when returning from a file
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>
2026-06-10 14:39:50 +03:00
H1K0 12d4dbcbb2 test(backend): regression tests for the security fixes
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>
2026-06-10 14:19:41 +03:00
H1K0 aff270fa44 fix(backend): rate-limit login and refresh endpoints
/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>
2026-06-10 14:14:51 +03:00
H1K0 40c91cec55 fix(backend): add baseline security response headers
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>
2026-06-10 14:13:56 +03:00
H1K0 591b3d2fe3 fix(backend): set HTTP server timeouts to mitigate Slowloris
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>
2026-06-10 14:13:22 +03:00
H1K0 f4545ff107 fix(backend): invalidate thumbnail cache on replace and permanent delete
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>
2026-06-10 14:12:38 +03:00
H1K0 3b79f12ec0 fix(backend): bound image decode and ffmpeg during thumbnailing
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>
2026-06-10 14:11:31 +03:00
H1K0 4645107ea1 fix(backend): make access tokens revocable via session validation
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>
2026-06-10 14:09:25 +03:00
H1K0 fa2acca858 fix(backend): cap upload size to prevent memory exhaustion
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>
2026-06-10 14:07:34 +03:00
H1K0 f069fccd96 fix(backend): harden JWT handling and login
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>
2026-06-10 14:04:33 +03:00
H1K0 9ea939ccf6 fix(backend): bootstrap admin from env instead of seeding admin/admin
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>
2026-06-10 14:01:48 +03:00
H1K0 945df7ef8a fix(backend): enforce file ACL on file-tag and import endpoints
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>
2026-06-10 13:59:33 +03:00
H1K0 a6680b1c05 fix(backend): require owner/admin to read or modify object ACLs
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>
2026-06-10 13:59:10 +03:00
H1K0 eb2eb00d96 fix(frontend): use $appSettings.tagRuleApplyToExisting on creating a new tag rule also 2026-04-07 11:59:09 +03:00
H1K0 135c71ae4d fix(frontend): theme-aware footer navbar colors
- 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>
2026-04-07 01:10:12 +03:00
H1K0 d38e54e307 feat(frontend): use reference icons for PWA manifest and favicons
- 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>
2026-04-07 01:04:36 +03:00
H1K0 c6e91c2eaf feat(frontend): add PWA support (service worker, manifest, pwa util)
- src/service-worker.ts: cache-first app shell (build + static assets),
  network-only for /api/, offline fallback to SPA shell
- static/manifest.webmanifest: name/short_name Tanabata, theme #312F45,
  standalone display, start_url /files, icon paths for 192/512/maskable
- src/lib/utils/pwa.ts: resetPwa() — unregisters SW + clears all caches
- app.html: link manifest, theme-color meta, Apple PWA metas
- settings page: refactored to use resetPwa() from utils

Note: add /static/images/icon-192.png, icon-512.png, icon-maskable-512.png
for full installability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 01:02:53 +03:00
H1K0 d6e9223f61 feat(frontend): implement trash view with restore and permanent delete
- 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>
2026-04-07 00:56:55 +03:00
H1K0 004ff0b45e fix(frontend): admin section fixes (pagination, actions, navbar)
- Audit log: replace load-more with page-based pagination
- Audit log: add all 29 action types to the dropdown
- Audit log: fix pagination bar hidden behind footer
- Root layout: hide footer navbar on /admin/* routes
- Users pages: fix curly-quote parse error in ConfirmDialog messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:56:43 +03:00
H1K0 6e052efebf feat(frontend): implement admin section (users + audit log)
- 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>
2026-04-07 00:27:44 +03:00
H1K0 70cbb45b01 fix(frontend): auto-fill viewport on file list load
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>
2026-04-07 00:06:45 +03:00
H1K0 012c6f9c48 feat(frontend): add configurable app settings (file load limit, tag rule apply_to_existing)
- 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>
2026-04-07 00:00:55 +03:00
H1K0 8cfcd39ab6 feat(backend): apply tag rules retroactively to existing files on activation
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>
2026-04-07 00:00:45 +03:00
H1K0 6da25dc696 feat(frontend): implement settings page
- 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>
2026-04-06 23:37:44 +03:00
H1K0 9b1aa40522 feat(frontend): implement bulk tag editing for multi-file selection
- 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>
2026-04-06 08:40:53 +03:00
H1K0 d79e76e9b7 feat(frontend): implement pool views and add-to-pool from file list
- 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>
2026-04-06 08:31:06 +03:00
H1K0 1f591f3a3f feat(frontend): replace JS confirm() with native dialog component
- ConfirmDialog: centered <dialog> with backdrop blur, cancel + confirm (danger variant)
- tags/[id]: delete tag uses ConfirmDialog
- categories/[id]: delete category uses ConfirmDialog
- files: bulk delete calls POST /files/bulk/delete, removes files from list,
  text updated to "Move to trash" (soft delete)
- mock: add POST /files/bulk/delete handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:48:21 +03:00
H1K0 1931adcd38 feat(frontend): implement category list, create, and edit pages
- /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>
2026-04-05 23:38:52 +03:00
H1K0 21f3acadf0 feat: add PATCH /tags/{id}/rules/{then_id} to activate/deactivate rules
- openapi.yaml: new PATCH endpoint with is_active body, returns TagRule
- backend/service: SetRuleActive calls repo.SetActive then returns updated rule
- backend/handler: PatchRule validates body and delegates to service
- backend/router: register PATCH /:tag_id/rules/:then_tag_id
- frontend: TagRuleEditor uses PATCH instead of delete+recreate
- mock: handle PATCH /tags/{id}/rules/{then_id}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:31:12 +03:00
H1K0 871250345a feat(frontend): add activate/deactivate toggle for tag rules
- 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>
2026-04-05 23:26:07 +03:00
H1K0 6e24060d99 feat(frontend): add clear button to TagPicker search input
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:16:49 +03:00
H1K0 f7d7e8ce37 feat(frontend): implement tag list, create, and edit pages
- /tags: list with search + clear button, sort/order controls, offset pagination
  Fix infinite requests when search matches no tags (track initialLoaded flag)
- /tags/new: create form with name, notes, color picker, category, is_public
- /tags/[id]: edit form + TagRuleEditor for implied-tag rules + delete
- TagBadge: colored pill with optional onclick and size prop
- TagRuleEditor: manage implied-tag rules (search to add, × to remove)
- Mock: tag/category CRUD, rules CRUD, search/sort, 5 mock categories

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:14:04 +03:00
H1K0 b9cace2997 feat(frontend): implement file upload with drag-and-drop and per-file progress
- client.ts: add uploadWithProgress() using XHR for upload progress events
- FileUpload.svelte: drag-drop zone wrapper, multi-file queue with individual
  progress bars, success/error status, MIME rejection message, dismiss panel
- Header.svelte: optional onUpload prop renders upload icon button
- files/+page.svelte: wire upload button, prepend uploaded files to grid
- vite-mock-plugin.ts: handle POST /files, unshift new file into mock array
- Fix crypto.randomUUID() crash on non-secure HTTP context (use Date.now + Math.random)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:02:26 +03:00
H1K0 a5b610d472 feat(frontend): implement file viewer page with metadata editing and tag picker
- files/[id]/+page.svelte: full-screen preview (100dvh), sticky top bar,
  prev/next nav via anchor API, notes/datetime/is_public editing, TagPicker,
  EXIF display, keyboard navigation (←/→/Esc)
- TagPicker.svelte: assigned tags with remove, searchable available tags to add
- Fix infinite request loop: previewSrc read inside $effect tracked as dependency;
  wrapped in untrack() to prevent re-triggering on blob URL assignment
- vite-mock-plugin: add GET/PATCH /files/{id}, preview endpoint, tags CRUD,
  anchor-based pagination, in-memory mutable state for file overrides and tags
- files/+page.svelte: migrate from deprecated $app/stores to $app/state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:55:04 +03:00
H1K0 84c47d0282 feat(frontend): expand mock tags to 207 entries for filter bar testing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:35:05 +03:00
H1K0 6fa340b17c feat(frontend): make header and filter bar sticky on scroll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:32:35 +03:00
H1K0 aebf7127af feat(frontend): implement file selection with long-press, shift+click, and touch drag
- selection.ts: store with select/deselect/toggle/enter/exit, derived count and active
- FileCard: long-press (400ms) enters selection mode, shows check overlay, blocks context menu
- Header: Select/Cancel button toggles selection mode
- SelectionBar: floating bar above navbar with count, Edit tags, Add to pool, Delete
- Shift+click range-selects between last and current index (desktop)
- Touch drag-to-select/deselect after long-press; non-passive touchmove blocks scroll only during drag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:30:26 +03:00
H1K0 63ea1a4d6a feat(frontend): make filter expression tokens draggable for reordering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:57:45 +03:00
H1K0 27d8215a0a feat(frontend): add header, filter bar, and sorting store for files page
- sorting.ts: per-section sort store (sort field + order) persisted to localStorage
- dsl.ts: build/parse DSL filter strings ({t=uuid,&,|,!,...})
- Header.svelte: sort dropdown, asc/desc toggle, filter toggle button
- FilterBar.svelte: tag token picker with operator buttons, search, apply/reset
- files/+page.svelte: wired header + filter bar, resets pagination on sort/filter change
- vite-mock-plugin.ts: added 5 mock tags for filter bar development

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:47:18 +03:00
H1K0 e72d4822e9 feat(frontend): implement file gallery page with infinite scroll
Adds InfiniteScroll component (IntersectionObserver, 300px margin,
CSS spinner). Adds FileCard component (fetch thumbnail with JWT auth
header, blob URL, shimmer placeholder). Adds files/+page.svelte with
160×160 flex-wrap grid and cursor pagination. Updates mock plugin with
75 sample files, cursor pagination, and colored SVG thumbnail handler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 03:34:33 +03:00
H1K0 9e341a0fc6 feat(frontend): add dev mock API plugin
Adds a Vite dev-only middleware that intercepts /api/v1/* requests
and returns mock responses for auth, users, files, tags, categories,
and pools. Login with any username and password "password".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 03:26:03 +03:00
H1K0 7770960cbf feat(frontend): add root layout with auth guard and bottom navbar
Adds +layout.ts auth guard (redirects to /login when no token).
Adds bottom navbar with inline SVGs for Categories/Tags/Files/Pools/
Settings, active-route highlight (#343249), muted-to-bright color
transition. Adds theme store (dark/light, persisted to localStorage,
applies data-theme attribute). Hides navbar on /login route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 03:21:00 +03:00
H1K0 e21d0ef67b feat(frontend): implement auth store and login page
Rewrites auth store with typed AuthUser shape (id, name, isAdmin) and
localStorage persistence. Adds login page with tanabata decorative
images, centered form, purple primary button matching the reference
design.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 03:06:32 +03:00
H1K0 fde8672bb1 feat(frontend): implement API client and auth module
Adds the base fetch wrapper (client.ts) with JWT auth headers,
automatic token refresh on 401 with request deduplication, and
typed ApiError. Adds auth.ts with login/logout/refresh/listSessions/
terminateSession. Adds authStore (stores/auth.ts) persisted to
localStorage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 03:02:35 +03:00
H1K0 071829a79e fix(backend): fix file upload and integration test suite
- 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>
2026-04-05 02:56:04 +03:00
H1K0 0784605267 feat(backend): add integration tests with testcontainers-go
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>
2026-04-05 02:34:16 +03:00
H1K0 e767b07b23 feat(backend): implement user, ACL, and audit stacks
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>
2026-04-05 02:25:16 +03:00
H1K0 3a49036507 feat(backend): implement pool stack
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>
2026-04-04 22:04:27 +03:00
H1K0 21debf626d feat(backend): implement category stack
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>
2026-04-04 21:50:57 +03:00
H1K0 04d2dfa16e docs(project): document scoped commit naming convention in CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:41:22 +03:00
H1K0 595b8fa671 feat(backend): implement full tag stack (repo, service, handler, routes)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:29:20 +03:00
H1K0 5050dbea3c feat(backend): implement file handler and wire all /files endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:40:04 +03:00
H1K0 99508cdbf8 feat(backend): implement file service with upload, CRUD, ACL, and audit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:28:59 +03:00
H1K0 0ae8b81a0b feat(backend): seed MIME types and support all image/video formats
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>
2026-04-04 18:21:27 +03:00
H1K0 fae87ad05c feat(backend): implement DiskStorage with on-demand thumbnail/preview cache
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>
2026-04-04 18:11:54 +03:00
H1K0 1a873949f4 feat(backend): implement FileRepo and filter DSL parser
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>
2026-04-04 16:55:23 +03:00
H1K0 0724892e29 feat(backend): implement audit repo and service
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>
2026-04-04 01:19:24 +03:00
H1K0 559f891d8d feat(backend): implement ACL repo and service
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>
2026-04-04 01:13:21 +03:00
H1K0 5a617af22c fix(backend): wire handler layer in main.go and fix migration issues
cmd/server/main.go: replace stub router with full wiring —
  UserRepo, SessionRepo, AuthService, AuthMiddleware, AuthHandler,
  NewRouter; use postgres.NewPool instead of pgxpool.New directly.

migrations/001_init_schemas.sql: wrap uuid_v7 and uuid_extract_timestamp
  function bodies with goose StatementBegin/End so semicolons inside
  dollar-quoted strings are not treated as statement separators.

migrations/007_seed_data.sql: add seed admin user (admin/admin,
  bcrypt cost 10, is_admin=true, can_create=true) for manual testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:54:54 +03:00
H1K0 1766dc2b3c feat(backend): implement auth handler, middleware, and router
domain/context.go: extend WithUser/UserFromContext with session ID

handler/response.go: respondJSON/respondError with domain→HTTP status
  mapping (404/403/401/409/400/415/500)
handler/middleware.go: Bearer JWT extraction, ParseAccessToken,
  domain.WithUser injection; aborts with 401 JSON on failure
handler/auth_handler.go: Login, Refresh, Logout, ListSessions,
  TerminateSession
handler/router.go: /health, /api/v1/auth routes; login and refresh
  are public, session routes protected by AuthMiddleware

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:43:41 +03:00
H1K0 277f42035c feat(backend): implement auth service with JWT and session management
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>
2026-04-04 00:38:21 +03:00
H1K0 0e9b4637b0 feat(backend): implement db helpers and postgres pool/transactor
- Add is_blocked to core.users (002_core_tables.sql)
- Add is_active to activity.sessions for soft deletes (005_activity_tables.sql)
- Implement UserRepo: List, GetByID, GetByName, Create, Update, Delete
- Implement MimeRepo: List, GetByID, GetByName
- Implement SessionRepo: Create, GetByTokenHash, ListByUser,
  UpdateLastActivity, Delete, DeleteByUserID
- Session deletes are soft (SET is_active = false); is_active is a
  SQL-only filter, not mapped to the domain type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:34:45 +03:00
H1K0 2c83073903 feat(backend): implement db helpers and postgres pool/transactor
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>
2026-04-04 00:15:17 +03:00
H1K0 83fda85bea feat(backend): implement port interfaces (repository and storage)
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>
2026-04-04 00:11:06 +03:00
H1K0 1e2a2a61de refactor(backend): strengthen domain layer types and add missing page types
- DomainError struct with Code() string method replaces plain errors.New
  sentinels; errors.Is() still works via pointer equality
- UUIDCreatedAt(uuid.UUID) time.Time helper extracts timestamp from UUID v7
- Add TagOffsetPage, CategoryOffsetPage, PoolOffsetPage
- FileListParams fields grouped with comments matching openapi.yaml params
- Fix mismatched comment on UserPage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:06:44 +03:00
H1K0 36d9488f21 feat(frontend): initialize SvelteKit frontend with Tailwind and OpenAPI types
- SvelteKit SPA mode with adapter-static (index.html fallback)
- Tailwind CSS v4 via @tailwindcss/vite with custom color palette
- CSS custom properties for dark/light theme (dark is default)
- Epilogue variable font with preload
- openapi-typescript generates src/lib/api/schema.ts from openapi.yaml
- Friendly domain type aliases in src/lib/api/types.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:00:26 +03:00
H1K0 8565bf9200 feat(backend): config, migrations embed, and server entrypoint
- 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>
2026-04-03 18:57:17 +03:00
H1K0 ecad017274 refactor(backend): split monolithic migration into 7 goose files
001_init_schemas  — extensions, schemas, uuid_v7 functions
002_core_tables   — core.users, mime_types, object_types
003_data_tables   — data.categories, tags, tag_rules, files, file_tag, pools, file_pool
004_acl_tables    — acl.permissions
005_activity_tables — activity.action_types, sessions, file_views, pool_views, tag_uses, audit_log
006_indexes       — all indexes across all schemas
007_seed_data     — object_types and action_types reference rows

Each file has -- +goose Up / Down annotations; downs drop in reverse
dependency order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:40:36 +03:00
H1K0 a2823337b6 docs(project): add reference Python/Flask implementation
Previous version of Tanabata used as visual and functional reference
for the new Go + SvelteKit rewrite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:36:05 +03:00
H1K0 4c10553549 chore(project): add .gitignore and .gitattributes
.gitignore covers env/secrets, OS files, IDE, Go build artifacts,
frontend build output, data dirs, and vendored reference libs.
.gitattributes enforces LF line endings, marks binaries, configures
diff drivers per language, and sets Linguist hints for repo stats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:35:22 +03:00
H1K0 1d341eef24 feat(backend): initialize Go module and implement domain layer
- Add go.mod (module tanabata/backend, Go 1.21) with uuid dependency
- Implement internal/domain: File, Tag, TagRule, Category, Pool, PoolFile,
  User, Session, Permission, ObjectType, AuditEntry + all pagination types
- Add domain error sentinels (ErrNotFound, ErrForbidden, etc.)
- Add context helpers WithUser/UserFromContext for JWT propagation
- Fix migration: remove redundant DEFAULT on exif jsonb column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:28:33 +03:00
H1K0 dbdc80b3a0 chore(project): initial project structure 2026-04-01 16:17:37 +03:00
296 changed files with 46078 additions and 907 deletions
+33
View File
@@ -0,0 +1,33 @@
# Keep the build context small and reproducible: never ship local state,
# dependencies, or build outputs — they are rebuilt inside the image.
# VCS / tooling
.git
.gitignore
**/.DS_Store
# Compose file — used to build/run, not needed inside the image context
docker-compose.yml
docker-compose.*.yml
# Secrets and local env
.env
.env.*
!.env.example
# Node
frontend/node_modules
frontend/.svelte-kit
frontend/build
frontend/.vite
# Go
backend/server
backend/**/*.test
# Docs / reference (not needed to build the image)
docs/reference
# Editor / OS
.vscode
.idea
+146
View File
@@ -0,0 +1,146 @@
# =============================================================================
# Tanabata File Manager — environment variables
#
# Copy to .env and fill in the secrets:
# cp .env.example .env
# docker compose up -d --build
# =============================================================================
# ---------------------------------------------------------------------------
# Docker Compose (read by the compose CLI, ignored by the app)
# ---------------------------------------------------------------------------
# Profiles to enable. "with-db" runs the bundled Postgres container. Leave
# EMPTY to skip it and use a Postgres running on the host instead — then point
# DATABASE_URL at host.docker.internal (see the Database section below).
COMPOSE_PROFILES=with-db
# Host port the app is published on, bound to 127.0.0.1 (loopback) — a reverse
# proxy on the host fronts it (see README → Reverse proxy). The container always
# listens on 42776. To expose the app directly without a proxy, drop the
# "127.0.0.1:" prefix on the ports line in docker-compose.yml.
APP_PORT=42776
# ---------------------------------------------------------------------------
# Volume mounts (Docker Compose; ignored by the app)
# ---------------------------------------------------------------------------
# By default the app's data and the database live in named Docker volumes
# (app_files, app_thumbs, app_import, db_data). To keep them in specific folders
# on the host instead, point any of these at a host path — absolute, or relative
# to this file (e.g. ./data/files). Unset = named volume.
# FILES_DIR=/var/lib/tanabata/files
# THUMBS_DIR=/var/lib/tanabata/thumbs
# IMPORT_DIR=/var/lib/tanabata/import
# DB_DIR=/var/lib/tanabata/db
# When bind-mounting the app folders above, the container must be able to write
# to them. Set PUID/PGID to the owner of those folders and create them with
# matching ownership first, e.g.:
# sudo mkdir -p /var/lib/tanabata/{files,thumbs,import}
# sudo chown -R 1000:1000 /var/lib/tanabata
# PUID=1000
# PGID=1000
# Defaults match the image's tanabata user (42776), which owns the named volumes. The
# DB folder is handled by Postgres itself and needs no PUID/PGID.
# PUID=42776
# PGID=42776
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
# 42776 is the project's default port: the sum of the Unicode code points of
# 七夕 (七 U+4E03 = 19971, 夕 U+5915 = 22805).
LISTEN_ADDR=:42776
JWT_SECRET=change-me-to-a-random-32-byte-secret
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h
# How long a content token is valid. It's a single-file capability the client
# puts in a media URL to open/stream an original by link (e.g. a long video in a
# new tab), so playback survives the short access-token expiry and session
# rotation. Longer = fewer interruptions but a wider window in which a leaked URL
# can read that one file; it can't be revoked before expiry. Keep it roughly as
# long as a viewing session lasts.
CONTENT_TOKEN_TTL=6h
# Reverse-proxy hops (comma-separated CIDRs/IPs) whose X-Forwarded-For is trusted,
# so the auth rate limiter sees real client IPs instead of the proxy's. The default
# covers loopback and the Docker bridge ranges a host nginx reaches the container
# through; widen/narrow it to match your proxy. Leave at the default for the
# standard "host nginx → 127.0.0.1" setup.
TRUSTED_PROXIES=127.0.0.1/32,::1/128,172.16.0.0/12
# Initial administrator, created on first startup if it does not yet exist.
# Changing the password later (via the API) is preserved across restarts.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-before-first-run
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
# Credentials for the bundled Postgres container (the "with-db" profile).
# Keep these in sync with DATABASE_URL below.
POSTGRES_DB=tanabata
POSTGRES_USER=tanabata
POSTGRES_PASSWORD=password
# Connection string the app uses. Pick ONE to match your database mode:
#
# • Bundled container DB (COMPOSE_PROFILES=with-db) — host is the "db" service:
DATABASE_URL=postgres://tanabata:password@db:5432/tanabata?sslmode=disable
#
# • Postgres on the host (COMPOSE_PROFILES empty):
# DATABASE_URL=postgres://tanabata:password@host.docker.internal:5432/tanabata?sslmode=disable
#
# • Bare-metal `go run` (no Docker):
# DATABASE_URL=postgres://tanabata:password@localhost:5432/tanabata?sslmode=disable
# ---------------------------------------------------------------------------
# Storage (paths inside the container; backed by named volumes in compose)
# ---------------------------------------------------------------------------
FILES_PATH=/data/files
THUMBS_CACHE_PATH=/data/thumbs
# Maximum accepted upload size in bytes (default 500 MiB).
MAX_UPLOAD_BYTES=524288000
# ---------------------------------------------------------------------------
# Thumbnails
# ---------------------------------------------------------------------------
THUMB_WIDTH=160
THUMB_HEIGHT=160
PREVIEW_WIDTH=1920
PREVIEW_HEIGHT=1080
# Pixel cap (width×height) for the pure-Go fallback decoder, used only when
# vipsthumbnail is NOT installed; larger images then get a placeholder. With vips
# present (the default image) thumbnails shrink on load, so this limit — and its
# RAM cost — don't apply. Also bounds a decompression bomb. Default ~300 Mpx.
THUMB_MAX_PIXELS=300000000
# How many thumbnails/previews may be generated at once. Each resize already uses
# every core, so a burst of large images otherwise pegs the CPU and RAM. 0 = auto
# (half the available CPUs).
THUMB_CONCURRENCY=0
# ---------------------------------------------------------------------------
# Import
# ---------------------------------------------------------------------------
IMPORT_PATH=/data/import
# ---------------------------------------------------------------------------
# Duplicate detection
# ---------------------------------------------------------------------------
# Maximum perceptual-hash distance (Hamming, out of 64 bits) for two files to be
# treated as duplicate candidates. Lower = stricter (fewer, more confident
# matches); higher = looser (catches more re-encodes/resizes but risks false
# positives). Used only by the dedup tool's pairs rebuild — see the dedup CLI /
# `docker compose run --rm dedup`. Default 10.
DUPLICATE_HASH_THRESHOLD=10
# ---------------------------------------------------------------------------
# Static SPA
# ---------------------------------------------------------------------------
# Leave UNSET here. The Docker image already serves the built SPA from
# /app/static and compose pins STATIC_DIR for the container — an empty value in
# .env would be injected into the container and disable SPA serving. Set this
# only for a bare-metal deploy where the Go server serves a built SPA; leave it
# unset in local dev, where the Vite dev server serves the UI.
# STATIC_DIR=/path/to/frontend/build
+67
View File
@@ -0,0 +1,67 @@
# =============================================================================
# Tanabata File Manager — .gitattributes
# =============================================================================
# ---------------------------------------------------------------------------
# Line endings: normalize to LF in repo, native on checkout
# ---------------------------------------------------------------------------
* text=auto eol=lf
# ---------------------------------------------------------------------------
# Explicitly text
# ---------------------------------------------------------------------------
*.go text eol=lf
*.mod text eol=lf
*.sum text eol=lf
*.sql text eol=lf
*.ts text eol=lf
*.js text eol=lf
*.svelte text eol=lf
*.html text eol=lf
*.css text eol=lf
*.json text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.md text eol=lf
*.txt text eol=lf
*.env* text eol=lf
*.sh text eol=lf
*.toml text eol=lf
*.xml text eol=lf
*.svg text eol=lf
Dockerfile text eol=lf
Makefile text eol=lf
# ---------------------------------------------------------------------------
# Explicitly binary
# ---------------------------------------------------------------------------
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.ttf binary
*.woff binary
*.woff2 binary
*.eot binary
*.otf binary
*.zip binary
*.gz binary
*.tar binary
# ---------------------------------------------------------------------------
# Diff behavior
# ---------------------------------------------------------------------------
*.sql diff=sql
*.go diff=golang
*.css diff=css
*.html diff=html
# ---------------------------------------------------------------------------
# Linguist: set repo language stats correctly
# ---------------------------------------------------------------------------
docs/reference/** linguist-documentation
frontend/static/** linguist-vendored
*.min.js linguist-vendored
*.min.css linguist-vendored
+44
View File
@@ -0,0 +1,44 @@
name: deploy
# Build the image and (re)start the compose stack on the production host
# whenever master moves. Also runnable manually from the Gitea Actions tab.
on:
push:
branches: [master]
workflow_dispatch: {}
# One deploy at a time; queue rather than cancel an in-flight run.
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
deploy:
# Self-hosted act_runner registered on the prod host with the "host" label
# (shell executor), so the job uses the host's git + Docker daemon and the
# existing clone in /opt/tanabata. See docs/DEPLOY.md for runner setup.
#
# Only shell steps here (no `uses:` actions), so the host needs git + docker
# and nothing else — no node, no rsync.
runs-on: host
env:
DEPLOY_DIR: /opt/tanabata
steps:
- name: Pull latest master
# DEPLOY_DIR is a git clone set up once at deploy time. reset --hard
# makes it match origin exactly; .env is untracked (.gitignore) so it
# is never touched.
run: |
cd "$DEPLOY_DIR"
git fetch --prune origin
git reset --hard origin/master
- name: Build image and start the stack
working-directory: /opt/tanabata
# .env must already exist in DEPLOY_DIR on the host (secrets + DB mode).
run: docker compose up -d --build --remove-orphans
- name: Prune dangling build layers
run: docker image prune -f
+86
View File
@@ -0,0 +1,86 @@
# =============================================================================
# Tanabata File Manager — .gitignore
# =============================================================================
# ---------------------------------------------------------------------------
# Environment & secrets
# ---------------------------------------------------------------------------
.env
.env.local
.env.*.local
*.pem
*.key
# ---------------------------------------------------------------------------
# OS
# ---------------------------------------------------------------------------
.DS_Store
Thumbs.db
Desktop.ini
*.swp
*.swo
*~
# ---------------------------------------------------------------------------
# IDE
# ---------------------------------------------------------------------------
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
*.iml
*.sublime-project
*.sublime-workspace
# ---------------------------------------------------------------------------
# Backend (Go)
# ---------------------------------------------------------------------------
backend/tmp/
backend/cmd/server/server
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
*.prof
coverage.out
coverage.html
# ---------------------------------------------------------------------------
# Frontend (SvelteKit / Node)
# ---------------------------------------------------------------------------
frontend/node_modules/
frontend/.svelte-kit/
frontend/build/
frontend/dist/
frontend/src/lib/api/schema.ts
# ---------------------------------------------------------------------------
# Docker
# ---------------------------------------------------------------------------
docker-compose.override.yml
# ---------------------------------------------------------------------------
# Data directories (runtime, not in repo)
# ---------------------------------------------------------------------------
data/
*.sqlite
*.sqlite3
# ---------------------------------------------------------------------------
# Misc
# ---------------------------------------------------------------------------
*.log
*.pid
*.seed
# ---------------------------------------------------------------------------
# Reference: exclude vendored libs, keep design sources
# ---------------------------------------------------------------------------
docs/reference/**/bootstrap.min.css
docs/reference/**/bootstrap.min.css.map
docs/reference/**/jquery-*.min.js
docs/reference/**/__pycache__/
docs/reference/**/*.pyc
+58
View File
@@ -0,0 +1,58 @@
# Tanabata File Manager
Multi-user, tag-based web file manager for images and video.
## Architecture
Monorepo: `backend/` (Go) + `frontend/` (SvelteKit).
- Backend: Go + Gin + pgx v5 + goose migrations. Clean Architecture.
- Frontend: SvelteKit SPA + Tailwind CSS + CSS custom properties.
- DB: PostgreSQL 14+.
- Auth: JWT Bearer tokens.
## Key documents (read before coding)
- `openapi.yaml` — full REST API specification (36 paths, 58 operations)
- `docs/GO_PROJECT_STRUCTURE.md` — backend architecture, layer rules, DI pattern
- `docs/FRONTEND_STRUCTURE.md` — frontend architecture, CSS approach, API client
- `docs/Описание.md` — product requirements in Russian
- `backend/migrations/001_init.sql` — database schema (4 schemas, 16 tables)
## Design reference
The `docs/reference/` directory contains the previous Python/Flask version.
Use its visual design as the basis for the new frontend:
- Color palette: #312F45 (bg), #9592B5 (accent), #444455 (tag default), #111118 (elevated)
- Font: Epilogue (variable weight)
- Dark theme is primary
- Mobile-first layout with bottom navbar
- 160×160 thumbnail grid for files
- Colored tag pills
- Floating selection bar for multi-select
## Backend commands
```bash
cd backend
go run ./cmd/server # run dev server
go test ./... # run all tests
```
## Frontend commands
```bash
cd frontend
npm run dev # vite dev server
npm run build # production build
npm run generate:types # regenerate API types from openapi.yaml
```
## Conventions
- Go: gofmt, no global state, context.Context as first param in all service methods
- TypeScript: strict mode, named exports
- SQL: snake_case, all migrations via goose
- API errors: { code, message, details? }
- Git: conventional commits with scope — `type(scope): message`
- `(backend)` for Go backend code
- `(frontend)` for SvelteKit/TypeScript code
- `(project)` for root-level files (.gitignore, docs/reference, structure)
+96
View File
@@ -0,0 +1,96 @@
# =============================================================================
# Tanabata File Manager — single-image build
#
# Produces one container that serves the SvelteKit SPA (built to static files)
# and the Go API on the same port. There is no Node runtime in the final image:
# the frontend uses adapter-static, so stage 1 emits plain HTML/CSS/JS that the
# Go binary serves directly (see STATIC_DIR).
# =============================================================================
# -----------------------------------------------------------------------------
# Stage 1 — build the frontend (static SPA)
# -----------------------------------------------------------------------------
FROM node:22-alpine AS frontend
WORKDIR /src/frontend
# Install dependencies first so this layer is cached unless the lockfile changes.
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# `npm run build` runs `generate:types`, which reads ../openapi.yaml relative to
# the frontend directory — place the spec one level up to match the repo layout.
COPY openapi.yaml /src/openapi.yaml
COPY frontend/ ./
RUN npm run build
# Output: /src/frontend/build (index.html, _app/, fonts, service-worker.js, …)
# -----------------------------------------------------------------------------
# Stage 2 — build the Go server (static binary)
# -----------------------------------------------------------------------------
FROM golang:1.26-alpine AS backend
WORKDIR /src/backend
# Download modules first so this layer is cached unless go.mod/go.sum changes.
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
# CGO is disabled: the binary shells out to external tools at runtime
# (vipsthumbnail for image thumbnails, ffmpeg for video frames, exiftool for
# metadata) and falls back to pure-Go image processing (disintegration/imaging)
# when vips is absent, so it stays fully static and portable across base images.
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
# dedup: offline maintenance CLI for duplicate detection (hash backfill + pairs
# rescan). Shipped alongside the server so it can be run with `docker exec`.
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/dedup ./cmd/dedup
# -----------------------------------------------------------------------------
# Stage 3 — minimal runtime
#
# Alpine (not distroless/scratch) because thumbnailing and metadata extraction
# invoke external processes (vipsthumbnail, ffmpeg, exiftool) that must be present
# on the runtime image.
# -----------------------------------------------------------------------------
FROM alpine:3.21 AS runtime
# vips-tools: fast, low-memory image thumbnails (shrink-on-load, so multi-hundred-
# Mpx photos cost little). ffmpeg: video frame extraction. exiftool: rich metadata.
# ca-certificates/tzdata: TLS + time zones.
RUN apk add --no-cache vips-tools ffmpeg exiftool ca-certificates tzdata
# Run as an unprivileged user.
RUN addgroup -S -g 42776 tanabata && adduser -S -G tanabata -u 42776 tanabata
WORKDIR /app
# The built SPA, served by the Go binary (matches STATIC_DIR below).
COPY --from=frontend --chown=tanabata:tanabata /src/frontend/build /app/static
# The server binary.
COPY --from=backend --chown=tanabata:tanabata /out/server /app/server
# The dedup maintenance CLI (run via `docker exec`, not the entrypoint).
COPY --from=backend --chown=tanabata:tanabata /out/dedup /app/dedup
# Data directories (overridable via FILES_PATH/THUMBS_CACHE_PATH/IMPORT_PATH).
# Created and owned by the tanabata user so a fresh named volume inherits write access.
RUN mkdir -p /data/files /data/thumbs /data/import && chown -R tanabata:tanabata /data
# Non-secret defaults mirroring .env.example. Secrets (JWT_SECRET, ADMIN_PASSWORD,
# DATABASE_URL) are intentionally NOT baked in — pass them at `docker run`.
ENV LISTEN_ADDR=:42776 \
STATIC_DIR=/app/static \
FILES_PATH=/data/files \
THUMBS_CACHE_PATH=/data/thumbs \
IMPORT_PATH=/data/import
EXPOSE 42776
VOLUME ["/data"]
USER tanabata
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:42776/health >/dev/null 2>&1 || exit 1
ENTRYPOINT ["/app/server"]
+98
View File
@@ -0,0 +1,98 @@
# Tanabata File Manager
A multi-user, tag-based web file manager for images and video. Go + Gin backend
(Clean Architecture, pgx, goose migrations), SvelteKit SPA frontend, PostgreSQL,
JWT auth — shipped as a single Docker image that serves both the API and the
built SPA on one port.
## Documentation
- [`openapi.yaml`](openapi.yaml) — full REST API specification
- [`docs/DEPLOY.md`](docs/DEPLOY.md) — production deploy (Gitea Actions → host)
- [`docs/GO_PROJECT_STRUCTURE.md`](docs/GO_PROJECT_STRUCTURE.md) — backend architecture
- [`docs/FRONTEND_STRUCTURE.md`](docs/FRONTEND_STRUCTURE.md) — frontend architecture
- [`.env.example`](.env.example) — every configuration variable, documented
## Quick start
```bash
cp .env.example .env # then edit the secrets (JWT_SECRET, ADMIN_PASSWORD, …)
docker compose up -d --build
```
By default this runs the app plus a bundled PostgreSQL container
(`COMPOSE_PROFILES=with-db`). To point at a Postgres already on the host, set
`COMPOSE_PROFILES=` empty and aim `DATABASE_URL` at `host.docker.internal`. See
[`.env.example`](.env.example) for the full matrix.
The app is published on **127.0.0.1** only and expects a reverse proxy in front
(see below). The default port is **42776** — the sum of the Unicode code points
of 七夕.
## Reverse proxy (nginx)
The container publishes its port on loopback (`127.0.0.1:${APP_PORT}:42776` in
[`docker-compose.yml`](docker-compose.yml)), so a reverse proxy on the host
terminates TLS and forwards to it. Three settings matter for this app:
1. **`client_max_body_size`** — uploads go up to `MAX_UPLOAD_BYTES` (500 MiB by
default). nginx caps request bodies at **1 MiB** out of the box, so without
this every large upload fails with `413`.
2. **Forwarded headers** — the app trusts `X-Forwarded-For` only from the hops in
`TRUSTED_PROXIES` (default: loopback + Docker bridge ranges) and keys its
login/refresh rate limiter on the resulting client IP. If the proxy doesn't
send the header, every request looks like it comes from the proxy and shares
one rate-limit bucket.
3. **Streaming for big media** — turning request/response buffering off lets
large uploads stream straight to the app and lets video range-seeks work
without nginx spooling whole files to disk first.
```nginx
server {
listen 443 ssl;
server_name tanabata.example.com;
# ssl_certificate / ssl_certificate_key ... (e.g. from certbot)
# Match MAX_UPLOAD_BYTES (500 MiB default); nginx defaults to 1m → 413.
client_max_body_size 512m;
location / {
proxy_pass http://127.0.0.1:42776; # APP_PORT
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Stream large uploads/downloads instead of buffering to disk; keeps
# video range-seek responsive. Scope these to file/preview locations
# instead if you'd rather keep buffering for small JSON responses.
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
```
If you run the app **without** a proxy and want it reachable on the LAN, drop the
`127.0.0.1:` prefix from the `ports` line in
[`docker-compose.yml`](docker-compose.yml) and adjust `TRUSTED_PROXIES`
accordingly.
## Development
```bash
# Backend
cd backend
go run ./cmd/server # dev server
go test ./... # all tests
# Frontend
cd frontend
npm run dev # Vite dev server
npm run build # production build
npm run generate:types # regenerate API types from openapi.yaml
```
+217
View File
@@ -0,0 +1,217 @@
// Command dedup is the offline maintenance tool for duplicate detection. It runs
// in two phases:
//
// hashes — compute the perceptual hash of every live image/video that has none
// yet (images from their bytes, videos from a middle frame via ffmpeg).
// pairs — rebuild data.duplicate_pairs from all current hashes.
//
// Both phases run by default; pass -hashes or -pairs to run only one. It reuses
// the server's configuration (DATABASE_URL, FILES_PATH, THUMBS_CACHE_PATH, …) and
// is safe to re-run: hashing only touches files whose phash is NULL, and the
// pairs rebuild is a full replace.
//
// go run ./cmd/dedup # hashes, then pairs
// go run ./cmd/dedup -pairs # only rebuild pairs
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"strings"
"github.com/google/uuid"
"tanabata/backend/internal/config"
"tanabata/backend/internal/db/postgres"
"tanabata/backend/internal/imagehash"
"tanabata/backend/internal/service"
"tanabata/backend/internal/storage"
)
func main() {
hashesOnly := flag.Bool("hashes", false, "only (re)compute missing perceptual hashes")
pairsOnly := flag.Bool("pairs", false, "only rebuild the duplicate pairs table")
flag.Parse()
// No flag, or both, means run everything.
doHashes := *hashesOnly || !*pairsOnly
doPairs := *pairsOnly || !*hashesOnly
cfg, err := config.Load()
if err != nil {
fatal("load config", err)
}
ctx := context.Background()
pool, err := postgres.NewPool(ctx, cfg.DatabaseURL)
if err != nil {
fatal("connect to database", err)
}
defer pool.Close()
diskStorage, err := storage.NewDiskStorage(
cfg.FilesPath, cfg.ThumbsCachePath,
cfg.ThumbWidth, cfg.ThumbHeight,
cfg.PreviewWidth, cfg.PreviewHeight,
cfg.ThumbMaxPixels, cfg.ThumbConcurrency,
)
if err != nil {
fatal("init storage", err)
}
fileRepo := postgres.NewFileRepo(pool)
pairRepo := postgres.NewDuplicatePairRepo(pool)
dismissalRepo := postgres.NewDismissalRepo(pool)
aclRepo := postgres.NewACLRepo(pool)
auditRepo := postgres.NewAuditRepo(pool)
tagRepo := postgres.NewTagRepo(pool)
categoryRepo := postgres.NewCategoryRepo(pool)
poolRepo := postgres.NewPoolRepo(pool)
transactor := postgres.NewTransactor(pool)
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
auditSvc := service.NewAuditService(auditRepo)
dupSvc := service.NewDuplicateService(
fileRepo, pairRepo, dismissalRepo, aclSvc, auditSvc, transactor, cfg.DuplicateHashThreshold,
)
if doHashes {
if err := backfillHashes(ctx, fileRepo, diskStorage); err != nil {
fatal("backfill hashes", err)
}
}
if doPairs {
fmt.Printf("rebuilding duplicate pairs (threshold %d)...\n", cfg.DuplicateHashThreshold)
// total is only known once Rescan has loaded the hashes, so create the bar
// lazily on the first progress callback.
var prog *progress
if err := dupSvc.Rescan(ctx, func(done, total int) {
if prog == nil {
prog = newProgress("matching", total)
}
prog.set(done)
}); err != nil {
fatal("rescan pairs", err)
}
if prog != nil {
prog.finish()
}
fmt.Println(" done")
}
}
// backfillHashes computes and stores a perceptual hash for every live image/video
// that lacks one. Failures on individual files are counted and reported, not
// fatal, so one unreadable file doesn't abort the whole run.
func backfillHashes(ctx context.Context, files *postgres.FileRepo, store *storage.DiskStorage) error {
pending, err := files.ListMissingPHash(ctx)
if err != nil {
return err
}
total := len(pending)
fmt.Printf("hashing %d files without a perceptual hash...\n", total)
var hashed, skipped, failed int
prog := newProgress("hashing", total)
for i, f := range pending {
ph, err := hashOne(ctx, store, f.ID, f.MIMEType)
switch {
case err != nil:
failed++
fmt.Fprintf(os.Stderr, "\n %s (%s): %v\n", f.ID, f.MIMEType, err)
case ph == nil:
skipped++ // not decodable; leave phash NULL
default:
if err := files.SetPHash(ctx, f.ID, ph); err != nil {
return fmt.Errorf("set phash for %s: %w", f.ID, err)
}
hashed++
}
prog.set(i + 1)
}
prog.finish()
fmt.Printf(" hashed %d, skipped %d, failed %d\n", hashed, skipped, failed)
return nil
}
// hashOne returns the perceptual hash for one file, or nil when it isn't hashable
// (e.g. an image that won't decode). Images are hashed from their bytes; videos
// from a middle frame.
func hashOne(ctx context.Context, store *storage.DiskStorage, id uuid.UUID, mime string) (*int64, error) {
switch {
case strings.HasPrefix(mime, "image/"):
rc, err := store.Read(ctx, id)
if err != nil {
return nil, err
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return nil, err
}
if h, ok := imagehash.FromBytes(data); ok {
return &h, nil
}
return nil, nil
case strings.HasPrefix(mime, "video/"):
img, err := store.VideoFrameMiddle(ctx, id)
if err != nil {
return nil, err
}
h := imagehash.FromImage(img)
return &h, nil
default:
return nil, nil
}
}
// progress renders a dependency-free progress indicator. On a TTY it draws an
// in-place bar; otherwise (pipe, cron, CI) it prints a line every 10% so logs
// stay readable instead of filling with carriage returns.
type progress struct {
label string
tty bool
total int
lastDec int // last 10%-decile printed in non-TTY mode
}
func newProgress(label string, total int) *progress {
fi, _ := os.Stdout.Stat()
tty := fi != nil && fi.Mode()&os.ModeCharDevice != 0
return &progress{label: label, tty: tty, total: total, lastDec: -1}
}
func (p *progress) set(done int) {
if p.total <= 0 {
return
}
pct := done * 100 / p.total
if p.tty {
const w = 30
filled := done * w / p.total
fmt.Printf("\r %s [%s%s] %3d%% (%d/%d)",
p.label,
strings.Repeat("#", filled), strings.Repeat("-", w-filled),
pct, done, p.total)
return
}
if dec := pct / 10; dec != p.lastDec {
p.lastDec = dec
fmt.Printf(" %s %d%% (%d/%d)\n", p.label, pct, done, p.total)
}
}
// finish ends the in-place bar with a newline (TTY only).
func (p *progress) finish() {
if p.tty && p.total > 0 {
fmt.Println()
}
}
func fatal(what string, err error) {
fmt.Fprintf(os.Stderr, "dedup: %s: %v\n", what, err)
os.Exit(1)
}
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"tanabata/backend/internal/config"
"tanabata/backend/internal/db/postgres"
"tanabata/backend/internal/handler"
"tanabata/backend/internal/service"
"tanabata/backend/internal/storage"
"tanabata/backend/migrations"
)
func main() {
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load config", "err", err)
os.Exit(1)
}
pool, err := postgres.NewPool(context.Background(), cfg.DatabaseURL)
if err != nil {
slog.Error("failed to connect to database", "err", err)
os.Exit(1)
}
defer pool.Close()
slog.Info("database connected")
migDB := stdlib.OpenDBFromPool(pool)
goose.SetBaseFS(migrations.FS)
if err := goose.SetDialect("postgres"); err != nil {
slog.Error("goose dialect error", "err", err)
os.Exit(1)
}
if err := goose.Up(migDB, "."); err != nil {
slog.Error("migrations failed", "err", err)
os.Exit(1)
}
migDB.Close()
slog.Info("migrations applied")
// Storage
diskStorage, err := storage.NewDiskStorage(
cfg.FilesPath,
cfg.ThumbsCachePath,
cfg.ThumbWidth, cfg.ThumbHeight,
cfg.PreviewWidth, cfg.PreviewHeight,
cfg.ThumbMaxPixels, cfg.ThumbConcurrency,
)
if err != nil {
slog.Error("failed to initialise storage", "err", err)
os.Exit(1)
}
// Repositories
userRepo := postgres.NewUserRepo(pool)
sessionRepo := postgres.NewSessionRepo(pool)
fileRepo := postgres.NewFileRepo(pool)
mimeRepo := postgres.NewMimeRepo(pool)
aclRepo := postgres.NewACLRepo(pool)
auditRepo := postgres.NewAuditRepo(pool)
tagRepo := postgres.NewTagRepo(pool)
tagRuleRepo := postgres.NewTagRuleRepo(pool)
categoryRepo := postgres.NewCategoryRepo(pool)
poolRepo := postgres.NewPoolRepo(pool)
duplicatePairRepo := postgres.NewDuplicatePairRepo(pool)
dismissalRepo := postgres.NewDismissalRepo(pool)
transactor := postgres.NewTransactor(pool)
// Services
authSvc := service.NewAuthService(
userRepo,
sessionRepo,
cfg.JWTSecret,
cfg.JWTAccessTTL,
cfg.JWTRefreshTTL,
cfg.ContentTokenTTL,
)
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
auditSvc := service.NewAuditService(auditRepo)
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
duplicateSvc := service.NewDuplicateService(
fileRepo, duplicatePairRepo, dismissalRepo, aclSvc, auditSvc, transactor, cfg.DuplicateHashThreshold,
)
fileSvc := service.NewFileService(
fileRepo,
mimeRepo,
diskStorage,
aclSvc,
auditSvc,
tagSvc,
transactor,
cfg.ImportPath,
)
userSvc := service.NewUserService(userRepo, sessionRepo, auditSvc)
// Bootstrap the initial administrator (idempotent).
if err := userSvc.EnsureAdmin(context.Background(), cfg.AdminUsername, cfg.AdminPassword); err != nil {
slog.Error("failed to bootstrap admin user", "err", err)
os.Exit(1)
}
// Handlers
authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc)
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, authSvc, cfg.MaxUploadBytes)
duplicateHandler := handler.NewDuplicateHandler(duplicateSvc)
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
categoryHandler := handler.NewCategoryHandler(categorySvc)
poolHandler := handler.NewPoolHandler(poolSvc)
userHandler := handler.NewUserHandler(userSvc)
aclHandler := handler.NewACLHandler(aclSvc)
auditHandler := handler.NewAuditHandler(auditSvc)
r, err := handler.NewRouter(
authMiddleware, authHandler,
fileHandler, duplicateHandler, tagHandler, categoryHandler, poolHandler,
userHandler, aclHandler, auditHandler,
cfg.StaticDir,
cfg.TrustedProxies,
)
if err != nil {
slog.Error("building router", "err", err)
os.Exit(1)
}
// ReadHeaderTimeout bounds slow-header (Slowloris) attacks; body read/write
// are left unbounded so large file uploads and downloads can stream.
srv := &http.Server{
Addr: cfg.ListenAddr,
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
slog.Info("starting server", "addr", cfg.ListenAddr)
if err := srv.ListenAndServe(); err != nil {
slog.Error("server error", "err", err)
os.Exit(1)
}
}
+96 -11
View File
@@ -1,19 +1,104 @@
module tanabata
module tanabata/backend
go 1.23.0
go 1.26
toolchain go1.23.10
require github.com/jackc/pgx/v5 v5.7.5
toolchain go1.26.1
require (
github.com/disintegration/imaging v1.6.2
github.com/gabriel-vasile/mimetype v1.4.12
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.5.5
github.com/joho/godotenv v1.5.1
github.com/pressly/goose/v3 v3.21.1
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.41.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0
golang.org/x/crypto v0.48.0
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx v3.6.2+incompatible // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/stretchr/testify v1.9.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.2 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+257 -14
View File
@@ -1,32 +1,275 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=
github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.21.1 h1:5SSAKKWej8LVVzNLuT6KIvP1eFDuPvxa+B6H0w78buQ=
github.com/pressly/goose/v3 v3.21.1/go.mod h1:sqthmzV8PitchEkjecFJII//l43dLOCzfWh8pHEe+vE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais=
github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI=
github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0 h1:AOtFXssrDlLm84A2sTTR/AhvJiYbrIuCO59d+Ro9Tb0=
github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0/go.mod h1:k2a09UKhgSp6vNpliIY0QSgm4Hi7GXVTzWvWgUemu/8=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4=
modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+194
View File
@@ -0,0 +1,194 @@
package config
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
// Config holds all application configuration loaded from environment variables.
type Config struct {
// Server
ListenAddr string
JWTSecret string
JWTAccessTTL time.Duration
JWTRefreshTTL time.Duration
// ContentTokenTTL is how long a content token stays valid. The token is a
// single-file capability used to open or stream an original by URL (e.g. a
// long video in a new tab); it is deliberately longer-lived than the access
// token and independent of the session, so playback survives access-token
// expiry and refresh rotation. Keep it only as long as a viewing session
// plausibly lasts — it is a bearer credential for that one file until expiry.
ContentTokenTTL time.Duration
// TrustedProxies lists the reverse-proxy hops (CIDRs or IPs) whose
// X-Forwarded-For header is trusted. The auth rate limiter keys on the
// client IP, so this must match the proxy in front of the app — otherwise
// every request appears to come from the proxy (one shared bucket) or a
// direct caller could forge the header. Default covers loopback and the
// Docker bridge ranges a host reverse proxy reaches the container through.
TrustedProxies []string
// Initial admin bootstrap (applied on startup if the user does not exist)
AdminUsername string
AdminPassword string
// Database
DatabaseURL string
// Storage
FilesPath string
ThumbsCachePath string
MaxUploadBytes int64 // reject uploads larger than this (bytes)
// Thumbnails
ThumbWidth int
ThumbHeight int
PreviewWidth int
PreviewHeight int
// ThumbMaxPixels caps the pixel count of a source image decoded in-process by
// the pure-Go fallback (a decompression-bomb guard and memory bound); larger
// images then get a placeholder. It does not apply when vipsthumbnail is
// installed, which shrinks on load regardless of source size.
ThumbMaxPixels int
// ThumbConcurrency bounds how many thumbnails/previews are generated at once,
// so a burst of large images can't saturate every core or exhaust RAM. 0 =
// auto (half the available CPUs).
ThumbConcurrency int
// Import
ImportPath string
// DuplicateHashThreshold is the maximum Hamming distance (out of 64) between
// two perceptual hashes for the files to be treated as duplicate candidates.
// Lower = stricter (fewer, more confident matches); higher = looser. Used only
// by the dedup rescan that (re)builds data.duplicate_pairs.
DuplicateHashThreshold int
// Static SPA. When set, the server serves the built frontend (and falls
// back to index.html for client routes) on the same port as the API. Empty
// in local development, where the Vite dev server serves the UI separately.
StaticDir string
}
// Load reads a .env file (if present) then loads all configuration from
// environment variables. Returns an error listing every missing or invalid var.
func Load() (*Config, error) {
// Non-fatal: .env may not exist in production.
_ = godotenv.Load()
var errs []error
requireStr := func(key string) string {
v := os.Getenv(key)
if v == "" {
errs = append(errs, fmt.Errorf("%s is required", key))
}
return v
}
defaultStr := func(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// parseDuration parses a duration env var. Every duration in this config is a
// token TTL, which must be strictly positive — a zero/negative TTL would mint
// already-expired tokens (no login, no media playback) — so reject those here
// rather than fail mysteriously at runtime.
parseDuration := func(key, def string) time.Duration {
raw := defaultStr(key, def)
d, err := time.ParseDuration(raw)
if err != nil {
errs = append(errs, fmt.Errorf("%s: invalid duration %q: %w", key, raw, err))
return 0
}
if d <= 0 {
errs = append(errs, fmt.Errorf("%s must be positive, got %q", key, raw))
return 0
}
return d
}
parseInt := func(key string, def int) int {
raw := os.Getenv(key)
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil {
errs = append(errs, fmt.Errorf("%s: invalid integer %q: %w", key, raw, err))
return def
}
return n
}
parseCSV := func(key, def string) []string {
raw := defaultStr(key, def)
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
parseInt64 := func(key string, def int64) int64 {
raw := os.Getenv(key)
if raw == "" {
return def
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
errs = append(errs, fmt.Errorf("%s: invalid integer %q: %w", key, raw, err))
return def
}
return n
}
cfg := &Config{
ListenAddr: defaultStr("LISTEN_ADDR", ":42776"),
JWTSecret: requireStr("JWT_SECRET"),
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
ContentTokenTTL: parseDuration("CONTENT_TOKEN_TTL", "6h"),
TrustedProxies: parseCSV("TRUSTED_PROXIES", "127.0.0.1/32,::1/128,172.16.0.0/12"),
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
AdminPassword: requireStr("ADMIN_PASSWORD"),
DatabaseURL: requireStr("DATABASE_URL"),
FilesPath: requireStr("FILES_PATH"),
ThumbsCachePath: requireStr("THUMBS_CACHE_PATH"),
MaxUploadBytes: parseInt64("MAX_UPLOAD_BYTES", 500<<20), // 500 MiB
ThumbWidth: parseInt("THUMB_WIDTH", 160),
ThumbHeight: parseInt("THUMB_HEIGHT", 160),
PreviewWidth: parseInt("PREVIEW_WIDTH", 1920),
PreviewHeight: parseInt("PREVIEW_HEIGHT", 1080),
ThumbMaxPixels: parseInt("THUMB_MAX_PIXELS", 300_000_000), // ~300 Mpx (e.g. 13000×17000)
ThumbConcurrency: parseInt("THUMB_CONCURRENCY", 0), // 0 = auto
ImportPath: requireStr("IMPORT_PATH"),
DuplicateHashThreshold: parseInt("DUPLICATE_HASH_THRESHOLD", 10),
StaticDir: defaultStr("STATIC_DIR", ""),
}
if len(errs) > 0 {
return nil, errors.Join(errs...)
}
return cfg, nil
}
+57
View File
@@ -0,0 +1,57 @@
package config
import (
"strings"
"testing"
)
// setValidEnv sets every required variable to a valid dummy value, so a test can
// then override one var to exercise a single validation path.
func setValidEnv(t *testing.T) {
t.Helper()
t.Setenv("JWT_SECRET", "test-secret")
t.Setenv("ADMIN_PASSWORD", "test-password")
t.Setenv("DATABASE_URL", "postgres://u:p@localhost:5432/db?sslmode=disable")
t.Setenv("FILES_PATH", "/tmp/files")
t.Setenv("THUMBS_CACHE_PATH", "/tmp/thumbs")
t.Setenv("IMPORT_PATH", "/tmp/import")
// Pin the TTLs to valid values so an ambient env var can't perturb the case
// under test; individual tests override the one they exercise.
t.Setenv("JWT_ACCESS_TTL", "15m")
t.Setenv("JWT_REFRESH_TTL", "720h")
t.Setenv("CONTENT_TOKEN_TTL", "6h")
}
func TestLoadValid(t *testing.T) {
setValidEnv(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.JWTAccessTTL <= 0 || cfg.JWTRefreshTTL <= 0 || cfg.ContentTokenTTL <= 0 {
t.Fatalf("TTLs should be positive: access=%v refresh=%v content=%v",
cfg.JWTAccessTTL, cfg.JWTRefreshTTL, cfg.ContentTokenTTL)
}
}
func TestLoadRejectsNonPositiveTTL(t *testing.T) {
cases := []struct{ key, val string }{
{"JWT_ACCESS_TTL", "0"},
{"JWT_REFRESH_TTL", "-1h"},
{"CONTENT_TOKEN_TTL", "0s"},
}
for _, tc := range cases {
t.Run(tc.key, func(t *testing.T) {
setValidEnv(t)
t.Setenv(tc.key, tc.val)
_, err := Load()
if err == nil {
t.Fatalf("expected error for %s=%q", tc.key, tc.val)
}
if !strings.Contains(err.Error(), tc.key) || !strings.Contains(err.Error(), "must be positive") {
t.Fatalf("error should name %s and mention positivity, got: %v", tc.key, err)
}
})
}
}
+70
View File
@@ -0,0 +1,70 @@
// Package db provides shared helpers used by all database adapters.
package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// txKey is the context key used to store an active transaction.
type txKey struct{}
// TxFromContext returns the pgx.Tx stored in ctx by the Transactor, along
// with a boolean indicating whether a transaction is active.
func TxFromContext(ctx context.Context) (pgx.Tx, bool) {
tx, ok := ctx.Value(txKey{}).(pgx.Tx)
return tx, ok
}
// ContextWithTx returns a copy of ctx that carries tx.
// Called by the Transactor before invoking the user function.
func ContextWithTx(ctx context.Context, tx pgx.Tx) context.Context {
return context.WithValue(ctx, txKey{}, tx)
}
// Querier is the common query interface satisfied by both *pgxpool.Pool and
// pgx.Tx, allowing repo helpers to work with either.
type Querier interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
// ScanRow executes a single-row query against q and scans the result using
// scan. It wraps pgx.ErrNoRows so callers can detect missing rows without
// importing pgx directly.
func ScanRow[T any](ctx context.Context, q Querier, sql string, args []any, scan func(pgx.Row) (T, error)) (T, error) {
row := q.QueryRow(ctx, sql, args...)
val, err := scan(row)
if err != nil {
var zero T
if err == pgx.ErrNoRows {
return zero, fmt.Errorf("%w", pgx.ErrNoRows)
}
return zero, fmt.Errorf("ScanRow: %w", err)
}
return val, nil
}
// ClampLimit enforces the [1, max] range on limit, returning def when limit
// is zero or negative.
func ClampLimit(limit, def, max int) int {
if limit <= 0 {
return def
}
if limit > max {
return max
}
return limit
}
// ClampOffset returns 0 for negative offsets.
func ClampOffset(offset int) int {
if offset < 0 {
return 0
}
return offset
}
+118
View File
@@ -0,0 +1,118 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
type permissionRow struct {
UserID int16 `db:"user_id"`
UserName string `db:"user_name"`
ObjectTypeID int16 `db:"object_type_id"`
ObjectID uuid.UUID `db:"object_id"`
CanView bool `db:"can_view"`
CanEdit bool `db:"can_edit"`
}
func toPermission(r permissionRow) domain.Permission {
return domain.Permission{
UserID: r.UserID,
UserName: r.UserName,
ObjectTypeID: r.ObjectTypeID,
ObjectID: r.ObjectID,
CanView: r.CanView,
CanEdit: r.CanEdit,
}
}
// ACLRepo implements port.ACLRepo using PostgreSQL.
type ACLRepo struct {
pool *pgxpool.Pool
}
// NewACLRepo creates an ACLRepo backed by pool.
func NewACLRepo(pool *pgxpool.Pool) *ACLRepo {
return &ACLRepo{pool: pool}
}
var _ port.ACLRepo = (*ACLRepo)(nil)
func (r *ACLRepo) List(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error) {
const sql = `
SELECT p.user_id, u.name AS user_name, p.object_type_id, p.object_id,
p.can_view, p.can_edit
FROM acl.permissions p
JOIN core.users u ON u.id = p.user_id
WHERE p.object_type_id = $1 AND p.object_id = $2
ORDER BY u.name`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, objectTypeID, objectID)
if err != nil {
return nil, fmt.Errorf("ACLRepo.List: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[permissionRow])
if err != nil {
return nil, fmt.Errorf("ACLRepo.List scan: %w", err)
}
perms := make([]domain.Permission, len(collected))
for i, row := range collected {
perms[i] = toPermission(row)
}
return perms, nil
}
func (r *ACLRepo) Get(ctx context.Context, userID int16, objectTypeID int16, objectID uuid.UUID) (*domain.Permission, error) {
const sql = `
SELECT p.user_id, u.name AS user_name, p.object_type_id, p.object_id,
p.can_view, p.can_edit
FROM acl.permissions p
JOIN core.users u ON u.id = p.user_id
WHERE p.user_id = $1 AND p.object_type_id = $2 AND p.object_id = $3`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, userID, objectTypeID, objectID)
if err != nil {
return nil, fmt.Errorf("ACLRepo.Get: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[permissionRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("ACLRepo.Get scan: %w", err)
}
p := toPermission(row)
return &p, nil
}
func (r *ACLRepo) Set(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error {
q := connOrTx(ctx, r.pool)
const del = `DELETE FROM acl.permissions WHERE object_type_id = $1 AND object_id = $2`
if _, err := q.Exec(ctx, del, objectTypeID, objectID); err != nil {
return fmt.Errorf("ACLRepo.Set delete: %w", err)
}
if len(perms) == 0 {
return nil
}
const ins = `
INSERT INTO acl.permissions (user_id, object_type_id, object_id, can_view, can_edit)
VALUES ($1, $2, $3, $4, $5)`
for _, p := range perms {
if _, err := q.Exec(ctx, ins, p.UserID, objectTypeID, objectID, p.CanView, p.CanEdit); err != nil {
return fmt.Errorf("ACLRepo.Set insert: %w", err)
}
}
return nil
}
+169
View File
@@ -0,0 +1,169 @@
package postgres
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/backend/internal/db"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// auditRowWithTotal matches the columns returned by the audit log SELECT.
// object_type is nullable (LEFT JOIN), object_id and details are nullable columns.
type auditRowWithTotal struct {
ID int64 `db:"id"`
UserID int16 `db:"user_id"`
UserName string `db:"user_name"`
Action string `db:"action"`
ObjectType *string `db:"object_type"`
ObjectID *uuid.UUID `db:"object_id"`
Details json.RawMessage `db:"details"`
PerformedAt time.Time `db:"performed_at"`
Total int `db:"total"`
}
func toAuditEntry(r auditRowWithTotal) domain.AuditEntry {
return domain.AuditEntry{
ID: r.ID,
UserID: r.UserID,
UserName: r.UserName,
Action: r.Action,
ObjectType: r.ObjectType,
ObjectID: r.ObjectID,
Details: r.Details,
PerformedAt: r.PerformedAt,
}
}
// AuditRepo implements port.AuditRepo using PostgreSQL.
type AuditRepo struct {
pool *pgxpool.Pool
}
// NewAuditRepo creates an AuditRepo backed by pool.
func NewAuditRepo(pool *pgxpool.Pool) *AuditRepo {
return &AuditRepo{pool: pool}
}
var _ port.AuditRepo = (*AuditRepo)(nil)
// Log inserts one audit record. action_type_id and object_type_id are resolved
// from the reference tables inside the INSERT via subqueries.
func (r *AuditRepo) Log(ctx context.Context, entry domain.AuditEntry) error {
const sql = `
INSERT INTO activity.audit_log
(user_id, action_type_id, object_type_id, object_id, details)
VALUES (
$1,
(SELECT id FROM activity.action_types WHERE name = $2),
CASE WHEN $3::text IS NOT NULL
THEN (SELECT id FROM core.object_types WHERE name = $3)
ELSE NULL END,
$4,
$5
)`
q := connOrTx(ctx, r.pool)
_, err := q.Exec(ctx, sql,
entry.UserID,
entry.Action,
entry.ObjectType,
entry.ObjectID,
entry.Details,
)
if err != nil {
return fmt.Errorf("AuditRepo.Log: %w", err)
}
return nil
}
// List returns a filtered, offset-paginated page of audit log entries ordered
// newest-first.
func (r *AuditRepo) List(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error) {
var conds []string
args := make([]any, 0, 8)
n := 1
if filter.UserID != nil {
conds = append(conds, fmt.Sprintf("a.user_id = $%d", n))
args = append(args, *filter.UserID)
n++
}
if filter.Action != "" {
conds = append(conds, fmt.Sprintf("at.name = $%d", n))
args = append(args, filter.Action)
n++
}
if filter.ObjectType != "" {
conds = append(conds, fmt.Sprintf("ot.name = $%d", n))
args = append(args, filter.ObjectType)
n++
}
if filter.ObjectID != nil {
conds = append(conds, fmt.Sprintf("a.object_id = $%d", n))
args = append(args, *filter.ObjectID)
n++
}
if filter.From != nil {
conds = append(conds, fmt.Sprintf("a.performed_at >= $%d", n))
args = append(args, *filter.From)
n++
}
if filter.To != nil {
conds = append(conds, fmt.Sprintf("a.performed_at <= $%d", n))
args = append(args, *filter.To)
n++
}
where := ""
if len(conds) > 0 {
where = "WHERE " + strings.Join(conds, " AND ")
}
limit := db.ClampLimit(filter.Limit, 50, 200)
offset := db.ClampOffset(filter.Offset)
args = append(args, limit, offset)
sql := fmt.Sprintf(`
SELECT a.id, a.user_id, u.name AS user_name,
at.name AS action,
ot.name AS object_type,
a.object_id, a.details,
a.performed_at,
COUNT(*) OVER() AS total
FROM activity.audit_log a
JOIN core.users u ON u.id = a.user_id
JOIN activity.action_types at ON at.id = a.action_type_id
LEFT JOIN core.object_types ot ON ot.id = a.object_type_id
%s
ORDER BY a.performed_at DESC
LIMIT $%d OFFSET $%d`, where, n, n+1)
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, args...)
if err != nil {
return nil, fmt.Errorf("AuditRepo.List: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[auditRowWithTotal])
if err != nil {
return nil, fmt.Errorf("AuditRepo.List scan: %w", err)
}
page := &domain.AuditPage{Offset: offset, Limit: limit}
if len(collected) > 0 {
page.Total = collected[0].Total
}
page.Items = make([]domain.AuditEntry, len(collected))
for i, row := range collected {
page.Items[i] = toAuditEntry(row)
}
return page, nil
}
@@ -0,0 +1,303 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// ---------------------------------------------------------------------------
// Row struct
// ---------------------------------------------------------------------------
type categoryRow struct {
ID uuid.UUID `db:"id"`
Name string `db:"name"`
Notes *string `db:"notes"`
Color *string `db:"color"`
Metadata []byte `db:"metadata"`
CreatorID int16 `db:"creator_id"`
CreatorName string `db:"creator_name"`
IsPublic bool `db:"is_public"`
}
type categoryRowWithTotal struct {
categoryRow
Total int `db:"total"`
}
// ---------------------------------------------------------------------------
// Converter
// ---------------------------------------------------------------------------
func toCategory(r categoryRow) domain.Category {
c := domain.Category{
ID: r.ID,
Name: r.Name,
Notes: r.Notes,
Color: r.Color,
CreatorID: r.CreatorID,
CreatorName: r.CreatorName,
IsPublic: r.IsPublic,
CreatedAt: domain.UUIDCreatedAt(r.ID),
}
if len(r.Metadata) > 0 && string(r.Metadata) != "null" {
c.Metadata = json.RawMessage(r.Metadata)
}
return c
}
// ---------------------------------------------------------------------------
// Shared SQL
// ---------------------------------------------------------------------------
const categorySelectFrom = `
SELECT
c.id,
c.name,
c.notes,
c.color,
c.metadata,
c.creator_id,
u.name AS creator_name,
c.is_public
FROM data.categories c
JOIN core.users u ON u.id = c.creator_id`
func categorySortColumn(s string) string {
if s == "name" {
return "c.name"
}
return "c.id" // "created"
}
// ---------------------------------------------------------------------------
// CategoryRepo — implements port.CategoryRepo
// ---------------------------------------------------------------------------
// CategoryRepo handles category CRUD using PostgreSQL.
type CategoryRepo struct {
pool *pgxpool.Pool
}
var _ port.CategoryRepo = (*CategoryRepo)(nil)
// NewCategoryRepo creates a CategoryRepo backed by pool.
func NewCategoryRepo(pool *pgxpool.Pool) *CategoryRepo {
return &CategoryRepo{pool: pool}
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
func (r *CategoryRepo) List(ctx context.Context, params port.OffsetParams) (*domain.CategoryOffsetPage, error) {
order := "ASC"
if strings.ToLower(params.Order) == "desc" {
order = "DESC"
}
sortCol := categorySortColumn(params.Sort)
args := []any{}
n := 1
var conditions []string
if params.Search != "" {
conditions = append(conditions, fmt.Sprintf("lower(c.name) LIKE lower($%d)", n))
args = append(args, "%"+params.Search+"%")
n++
}
// Restrict to categories the viewer may see (private-by-default), unless admin.
if !params.ViewerIsAdmin {
var aclCond string
aclCond, n, args = aclVisibilityCond("c", objTypeCategory, params.ViewerID, n, args)
conditions = append(conditions, aclCond)
}
where := ""
if len(conditions) > 0 {
where = "WHERE " + strings.Join(conditions, " AND ")
}
limit := params.Limit
if limit <= 0 {
limit = 50
}
offset := params.Offset
if offset < 0 {
offset = 0
}
query := fmt.Sprintf(`
SELECT
c.id, c.name, c.notes, c.color, c.metadata,
c.creator_id, u.name AS creator_name, c.is_public,
COUNT(*) OVER() AS total
FROM data.categories c
JOIN core.users u ON u.id = c.creator_id
%s
ORDER BY %s %s NULLS LAST, c.id ASC
LIMIT $%d OFFSET $%d`, where, sortCol, order, n, n+1)
args = append(args, limit, offset)
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("CategoryRepo.List query: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[categoryRowWithTotal])
if err != nil {
return nil, fmt.Errorf("CategoryRepo.List scan: %w", err)
}
items := make([]domain.Category, len(collected))
total := 0
for i, row := range collected {
items[i] = toCategory(row.categoryRow)
total = row.Total
}
return &domain.CategoryOffsetPage{
Items: items,
Total: total,
Offset: offset,
Limit: limit,
}, nil
}
// ---------------------------------------------------------------------------
// GetByID
// ---------------------------------------------------------------------------
func (r *CategoryRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Category, error) {
const query = categorySelectFrom + `
WHERE c.id = $1`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, id)
if err != nil {
return nil, fmt.Errorf("CategoryRepo.GetByID: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[categoryRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("CategoryRepo.GetByID scan: %w", err)
}
c := toCategory(row)
return &c, nil
}
// ---------------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------------
func (r *CategoryRepo) Create(ctx context.Context, c *domain.Category) (*domain.Category, error) {
const query = `
WITH ins AS (
INSERT INTO data.categories (name, notes, color, metadata, creator_id, is_public)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
)
SELECT ins.id, ins.name, ins.notes, ins.color, ins.metadata,
ins.creator_id, u.name AS creator_name, ins.is_public
FROM ins
JOIN core.users u ON u.id = ins.creator_id`
var meta any
if len(c.Metadata) > 0 {
meta = c.Metadata
}
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query,
c.Name, c.Notes, c.Color, meta, c.CreatorID, c.IsPublic)
if err != nil {
return nil, fmt.Errorf("CategoryRepo.Create: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[categoryRow])
if err != nil {
if isPgUniqueViolation(err) {
return nil, domain.ErrConflict
}
return nil, fmt.Errorf("CategoryRepo.Create scan: %w", err)
}
created := toCategory(row)
return &created, nil
}
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
// Update replaces all mutable fields. The caller must merge current values
// with the patch before calling (read-then-write semantics).
func (r *CategoryRepo) Update(ctx context.Context, id uuid.UUID, c *domain.Category) (*domain.Category, error) {
const query = `
WITH upd AS (
UPDATE data.categories SET
name = $2,
notes = $3,
color = $4,
metadata = COALESCE($5, metadata),
is_public = $6
WHERE id = $1
RETURNING *
)
SELECT upd.id, upd.name, upd.notes, upd.color, upd.metadata,
upd.creator_id, u.name AS creator_name, upd.is_public
FROM upd
JOIN core.users u ON u.id = upd.creator_id`
var meta any
if len(c.Metadata) > 0 {
meta = c.Metadata
}
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query,
id, c.Name, c.Notes, c.Color, meta, c.IsPublic)
if err != nil {
return nil, fmt.Errorf("CategoryRepo.Update: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[categoryRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
if isPgUniqueViolation(err) {
return nil, domain.ErrConflict
}
return nil, fmt.Errorf("CategoryRepo.Update scan: %w", err)
}
updated := toCategory(row)
return &updated, nil
}
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
func (r *CategoryRepo) Delete(ctx context.Context, id uuid.UUID) error {
const query = `DELETE FROM data.categories WHERE id = $1`
q := connOrTx(ctx, r.pool)
ct, err := q.Exec(ctx, query, id)
if err != nil {
return fmt.Errorf("CategoryRepo.Delete: %w", err)
}
if ct.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
@@ -0,0 +1,145 @@
package postgres
import (
"bytes"
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// ---------------------------------------------------------------------------
// DuplicatePairRepo
// ---------------------------------------------------------------------------
// DuplicatePairRepo implements port.DuplicatePairRepo using PostgreSQL.
type DuplicatePairRepo struct {
pool *pgxpool.Pool
}
// NewDuplicatePairRepo creates a DuplicatePairRepo backed by pool.
func NewDuplicatePairRepo(pool *pgxpool.Pool) *DuplicatePairRepo {
return &DuplicatePairRepo{pool: pool}
}
var _ port.DuplicatePairRepo = (*DuplicatePairRepo)(nil)
// ReplaceAll atomically replaces the entire pairs table with the given set.
// The rescan recomputes pairs from scratch, so a full DELETE + COPY is both
// correct and the simplest way to drop pairs that no longer qualify.
func (r *DuplicatePairRepo) ReplaceAll(ctx context.Context, pairs []domain.DuplicatePair) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("DuplicatePairRepo.ReplaceAll begin: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after a successful commit
if _, err := tx.Exec(ctx, `DELETE FROM data.duplicate_pairs`); err != nil {
return fmt.Errorf("DuplicatePairRepo.ReplaceAll delete: %w", err)
}
if len(pairs) > 0 {
rows := make([][]any, len(pairs))
for i, p := range pairs {
rows[i] = []any{p.FileA, p.FileB, int16(p.Distance)}
}
if _, err := tx.CopyFrom(ctx,
pgx.Identifier{"data", "duplicate_pairs"},
[]string{"file_a", "file_b", "distance"},
pgx.CopyFromRows(rows),
); err != nil {
return fmt.Errorf("DuplicatePairRepo.ReplaceAll copy: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("DuplicatePairRepo.ReplaceAll commit: %w", err)
}
return nil
}
type pairRow struct {
FileA uuid.UUID `db:"file_a"`
FileB uuid.UUID `db:"file_b"`
Distance int16 `db:"distance"`
}
// ListVisible returns every stored pair where both files are live (not trashed),
// the pair is not dismissed, and — for non-admins — both files are visible to the
// viewer under the private-by-default model. This is the input to clustering.
func (r *DuplicatePairRepo) ListVisible(ctx context.Context, viewerID int16, isAdmin bool) ([]domain.DuplicatePair, error) {
args := make([]any, 0, 4)
n := 1
aclWhere := ""
if !isAdmin {
var ca, cb string
ca, n, args = aclVisibilityCond("fa", objTypeFile, viewerID, n, args)
cb, n, args = aclVisibilityCond("fb", objTypeFile, viewerID, n, args)
aclWhere = "AND " + ca + " AND " + cb
}
sqlStr := fmt.Sprintf(`
SELECT p.file_a, p.file_b, p.distance
FROM data.duplicate_pairs p
JOIN data.files fa ON fa.id = p.file_a AND fa.is_deleted = false
JOIN data.files fb ON fb.id = p.file_b AND fb.is_deleted = false
WHERE NOT EXISTS (
SELECT 1 FROM data.duplicate_dismissals d
WHERE d.file_a = p.file_a AND d.file_b = p.file_b
)
%s
ORDER BY p.file_a, p.file_b`, aclWhere)
rows, err := r.pool.Query(ctx, sqlStr, args...)
if err != nil {
return nil, fmt.Errorf("DuplicatePairRepo.ListVisible: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[pairRow])
if err != nil {
return nil, fmt.Errorf("DuplicatePairRepo.ListVisible scan: %w", err)
}
out := make([]domain.DuplicatePair, len(collected))
for i, row := range collected {
out[i] = domain.DuplicatePair{FileA: row.FileA, FileB: row.FileB, Distance: int(row.Distance)}
}
return out, nil
}
// ---------------------------------------------------------------------------
// DismissalRepo
// ---------------------------------------------------------------------------
// DismissalRepo implements port.DismissalRepo using PostgreSQL.
type DismissalRepo struct {
pool *pgxpool.Pool
}
// NewDismissalRepo creates a DismissalRepo backed by pool.
func NewDismissalRepo(pool *pgxpool.Pool) *DismissalRepo {
return &DismissalRepo{pool: pool}
}
var _ port.DismissalRepo = (*DismissalRepo)(nil)
// Add records a pair as "not a duplicate". The two ids are stored in canonical
// (file_a < file_b) order to match the table's CHECK and avoid (a,b)/(b,a)
// duplicates; a repeated dismissal is a no-op.
func (r *DismissalRepo) Add(ctx context.Context, a, b uuid.UUID, userID int16) error {
if bytes.Compare(a[:], b[:]) > 0 {
a, b = b, a
}
const sqlStr = `
INSERT INTO data.duplicate_dismissals (file_a, file_b, dismissed_by)
VALUES ($1, $2, $3)
ON CONFLICT (file_a, file_b) DO NOTHING`
q := connOrTx(ctx, r.pool)
if _, err := q.Exec(ctx, sqlStr, a, b, userID); err != nil {
return fmt.Errorf("DismissalRepo.Add: %w", err)
}
return nil
}
+957
View File
@@ -0,0 +1,957 @@
package postgres
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/backend/internal/db"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// ---------------------------------------------------------------------------
// Row structs
// ---------------------------------------------------------------------------
type fileRow struct {
ID uuid.UUID `db:"id"`
OriginalName *string `db:"original_name"`
MIMEType string `db:"mime_type"`
MIMEExtension string `db:"mime_extension"`
ContentDatetime time.Time `db:"content_datetime"`
Notes *string `db:"notes"`
Metadata json.RawMessage `db:"metadata"`
EXIF json.RawMessage `db:"exif"`
PHash *int64 `db:"phash"`
CreatorID int16 `db:"creator_id"`
CreatorName string `db:"creator_name"`
IsPublic bool `db:"is_public"`
IsDeleted bool `db:"is_deleted"`
NeedsReview bool `db:"needs_review"`
}
// fileTagRow is used for both single-file and batch tag loading.
// file_id is always selected so the same struct works for both cases.
type fileTagRow struct {
FileID uuid.UUID `db:"file_id"`
ID uuid.UUID `db:"id"`
Name string `db:"name"`
Notes *string `db:"notes"`
Color *string `db:"color"`
CategoryID *uuid.UUID `db:"category_id"`
CategoryName *string `db:"category_name"`
CategoryColor *string `db:"category_color"`
Metadata json.RawMessage `db:"metadata"`
CreatorID int16 `db:"creator_id"`
CreatorName string `db:"creator_name"`
IsPublic bool `db:"is_public"`
}
// anchorValRow holds the sort-column values fetched for an anchor file.
type anchorValRow struct {
ContentDatetime time.Time `db:"content_datetime"`
OriginalName string `db:"original_name"` // COALESCE(original_name,'') applied in SQL
MIMEType string `db:"mime_type"`
}
// ---------------------------------------------------------------------------
// Converters
// ---------------------------------------------------------------------------
func toFile(r fileRow) domain.File {
return domain.File{
ID: r.ID,
OriginalName: r.OriginalName,
MIMEType: r.MIMEType,
MIMEExtension: r.MIMEExtension,
ContentDatetime: r.ContentDatetime,
Notes: r.Notes,
Metadata: r.Metadata,
EXIF: r.EXIF,
PHash: r.PHash,
CreatorID: r.CreatorID,
CreatorName: r.CreatorName,
IsPublic: r.IsPublic,
IsDeleted: r.IsDeleted,
NeedsReview: r.NeedsReview,
CreatedAt: domain.UUIDCreatedAt(r.ID),
}
}
func toTagFromFileTag(r fileTagRow) domain.Tag {
return domain.Tag{
ID: r.ID,
Name: r.Name,
Notes: r.Notes,
Color: r.Color,
CategoryID: r.CategoryID,
CategoryName: r.CategoryName,
CategoryColor: r.CategoryColor,
Metadata: r.Metadata,
CreatorID: r.CreatorID,
CreatorName: r.CreatorName,
IsPublic: r.IsPublic,
CreatedAt: domain.UUIDCreatedAt(r.ID),
}
}
// ---------------------------------------------------------------------------
// Cursor
// ---------------------------------------------------------------------------
type fileCursor struct {
Sort string `json:"s"` // canonical sort name
Order string `json:"o"` // "ASC" or "DESC"
ID string `json:"id"` // UUID of the boundary file
Val string `json:"v"` // sort column value; empty for "created" (id IS the key)
}
func encodeCursor(c fileCursor) string {
b, _ := json.Marshal(c)
return base64.RawURLEncoding.EncodeToString(b)
}
func decodeCursor(s string) (fileCursor, error) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return fileCursor{}, fmt.Errorf("cursor: invalid encoding")
}
var c fileCursor
if err := json.Unmarshal(b, &c); err != nil {
return fileCursor{}, fmt.Errorf("cursor: invalid format")
}
return c, nil
}
// makeCursor builds a fileCursor from a boundary row and the current sort/order.
func makeCursor(r fileRow, sort, order string) fileCursor {
var val string
switch sort {
case "content_datetime":
val = r.ContentDatetime.UTC().Format(time.RFC3339Nano)
case "original_name":
if r.OriginalName != nil {
val = *r.OriginalName
}
case "mime":
val = r.MIMEType
// "created": val is empty; f.id is the sort key.
}
return fileCursor{Sort: sort, Order: order, ID: r.ID.String(), Val: val}
}
// ---------------------------------------------------------------------------
// Sort helpers
// ---------------------------------------------------------------------------
func normSort(s string) string {
switch s {
case "content_datetime", "original_name", "mime":
return s
default:
return "created"
}
}
func normOrder(o string) string {
if strings.EqualFold(o, "asc") {
return "ASC"
}
return "DESC"
}
// buildKeysetCond returns a keyset WHERE fragment and an ORDER BY fragment.
//
// - forward=true: items after the cursor in the sort order (standard next-page)
// - forward=false: items before the cursor (previous-page); ORDER BY is reversed,
// caller must reverse the result slice after fetching
// - incl=true: include the cursor file itself (anchor case; uses ≤ / ≥)
//
// All user values are bound as parameters — no SQL injection possible.
func buildKeysetCond(
sort, order string,
forward, incl bool,
cursorID uuid.UUID, cursorVal string,
n int, args []any,
) (where, orderBy string, nextN int, outArgs []any) {
// goDown=true → want smaller values → primary comparison is "<".
// Applies for DESC+forward and ASC+backward.
goDown := (order == "DESC") == forward
var op, idOp string
if goDown {
op = "<"
if incl {
idOp = "<="
} else {
idOp = "<"
}
} else {
op = ">"
if incl {
idOp = ">="
} else {
idOp = ">"
}
}
// Effective ORDER BY direction: reversed for backward so the DB returns
// the closest items first (the ones we keep after trimming the extra).
dir := order
if !forward {
if order == "DESC" {
dir = "ASC"
} else {
dir = "DESC"
}
}
switch sort {
case "created":
// Single-column keyset: f.id (UUID v7, so ordering = chronological).
where = fmt.Sprintf("f.id %s $%d", idOp, n)
orderBy = fmt.Sprintf("f.id %s", dir)
outArgs = append(args, cursorID)
n++
case "content_datetime":
// Two-column keyset: (content_datetime, id).
// $n is referenced twice in the SQL (< and =) but passed once in args —
// PostgreSQL extended protocol allows multiple references to $N.
t, _ := time.Parse(time.RFC3339Nano, cursorVal)
where = fmt.Sprintf(
"(f.content_datetime %s $%d OR (f.content_datetime = $%d AND f.id %s $%d))",
op, n, n, idOp, n+1)
orderBy = fmt.Sprintf("f.content_datetime %s, f.id %s", dir, dir)
outArgs = append(args, t, cursorID)
n += 2
case "original_name":
// COALESCE treats NULL names as '' for stable pagination.
where = fmt.Sprintf(
"(COALESCE(f.original_name,'') %s $%d OR (COALESCE(f.original_name,'') = $%d AND f.id %s $%d))",
op, n, n, idOp, n+1)
orderBy = fmt.Sprintf("COALESCE(f.original_name,'') %s, f.id %s", dir, dir)
outArgs = append(args, cursorVal, cursorID)
n += 2
default: // "mime"
where = fmt.Sprintf(
"(mt.name %s $%d OR (mt.name = $%d AND f.id %s $%d))",
op, n, n, idOp, n+1)
orderBy = fmt.Sprintf("mt.name %s, f.id %s", dir, dir)
outArgs = append(args, cursorVal, cursorID)
n += 2
}
nextN = n
return
}
// defaultOrderBy returns the natural ORDER BY for the first page (no cursor).
func defaultOrderBy(sort, order string) string {
switch sort {
case "created":
return fmt.Sprintf("f.id %s", order)
case "content_datetime":
return fmt.Sprintf("f.content_datetime %s, f.id %s", order, order)
case "original_name":
return fmt.Sprintf("COALESCE(f.original_name,'') %s, f.id %s", order, order)
default: // "mime"
return fmt.Sprintf("mt.name %s, f.id %s", order, order)
}
}
// ---------------------------------------------------------------------------
// FileRepo
// ---------------------------------------------------------------------------
// FileRepo implements port.FileRepo using PostgreSQL.
type FileRepo struct {
pool *pgxpool.Pool
}
// NewFileRepo creates a FileRepo backed by pool.
func NewFileRepo(pool *pgxpool.Pool) *FileRepo {
return &FileRepo{pool: pool}
}
var _ port.FileRepo = (*FileRepo)(nil)
// fileSelectCTE is the SELECT appended after a CTE named "r" that exposes
// all file columns (including mime_id). Used by Create, Update, and Restore
// to get the full denormalized record in a single round-trip.
const fileSelectCTE = `
SELECT r.id, r.original_name,
mt.name AS mime_type, mt.extension AS mime_extension,
r.content_datetime, r.notes, r.metadata, r.exif, r.phash,
r.creator_id, u.name AS creator_name,
r.is_public, r.is_deleted, r.needs_review
FROM r
JOIN core.mime_types mt ON mt.id = r.mime_id
JOIN core.users u ON u.id = r.creator_id`
// ---------------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------------
// Create inserts a new file record using the ID already set on f.
// The MIME type is resolved from f.MIMEType (name string) via a subquery.
func (r *FileRepo) Create(ctx context.Context, f *domain.File) (*domain.File, error) {
const sqlStr = `
WITH r AS (
INSERT INTO data.files
(id, original_name, mime_id, content_datetime, notes, metadata, exif, phash, creator_id, is_public)
VALUES (
$1,
$2,
(SELECT id FROM core.mime_types WHERE name = $3),
$4, $5, $6, $7, $8, $9, $10
)
RETURNING id, original_name, mime_id, content_datetime, notes,
metadata, exif, phash, creator_id, is_public, is_deleted,
needs_review
)` + fileSelectCTE
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr,
f.ID, f.OriginalName, f.MIMEType, f.ContentDatetime,
f.Notes, f.Metadata, f.EXIF, f.PHash,
f.CreatorID, f.IsPublic,
)
if err != nil {
return nil, fmt.Errorf("FileRepo.Create: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
if err != nil {
return nil, fmt.Errorf("FileRepo.Create scan: %w", err)
}
created := toFile(row)
return &created, nil
}
// ---------------------------------------------------------------------------
// GetByID
// ---------------------------------------------------------------------------
func (r *FileRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.File, error) {
const sqlStr = `
SELECT f.id, f.original_name,
mt.name AS mime_type, mt.extension AS mime_extension,
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
f.creator_id, u.name AS creator_name,
f.is_public, f.is_deleted, f.needs_review
FROM data.files f
JOIN core.mime_types mt ON mt.id = f.mime_id
JOIN core.users u ON u.id = f.creator_id
WHERE f.id = $1`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr, id)
if err != nil {
return nil, fmt.Errorf("FileRepo.GetByID: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("FileRepo.GetByID scan: %w", err)
}
f := toFile(row)
tags, err := r.ListTags(ctx, id)
if err != nil {
return nil, err
}
f.Tags = tags
return &f, nil
}
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
// Update applies editable metadata fields. MIME type and EXIF are immutable.
func (r *FileRepo) Update(ctx context.Context, id uuid.UUID, f *domain.File) (*domain.File, error) {
const sqlStr = `
WITH r AS (
UPDATE data.files
SET original_name = $2,
content_datetime = $3,
notes = $4,
metadata = $5,
is_public = $6
WHERE id = $1
RETURNING id, original_name, mime_id, content_datetime, notes,
metadata, exif, phash, creator_id, is_public, is_deleted,
needs_review
)` + fileSelectCTE
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr,
id, f.OriginalName, f.ContentDatetime,
f.Notes, f.Metadata, f.IsPublic,
)
if err != nil {
return nil, fmt.Errorf("FileRepo.Update: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("FileRepo.Update scan: %w", err)
}
updated := toFile(row)
tags, err := r.ListTags(ctx, id)
if err != nil {
return nil, err
}
updated.Tags = tags
return &updated, nil
}
// SetNeedsReview sets the review status on the given files in one statement.
// Trashed files are left untouched. No-op for an empty id list.
func (r *FileRepo) SetNeedsReview(ctx context.Context, ids []uuid.UUID, value bool) error {
if len(ids) == 0 {
return nil
}
const sqlStr = `UPDATE data.files SET needs_review = $2 WHERE id = ANY($1) AND is_deleted = false`
q := connOrTx(ctx, r.pool)
if _, err := q.Exec(ctx, sqlStr, ids, value); err != nil {
return fmt.Errorf("FileRepo.SetNeedsReview: %w", err)
}
return nil
}
// SetPHash sets (or clears, when phash is nil) the perceptual hash of a file.
// Used by the dedup backfill and on content replacement; phash is non-critical,
// recomputable metadata, so callers may treat failures as best-effort.
func (r *FileRepo) SetPHash(ctx context.Context, id uuid.UUID, phash *int64) error {
const sqlStr = `UPDATE data.files SET phash = $2 WHERE id = $1`
q := connOrTx(ctx, r.pool)
if _, err := q.Exec(ctx, sqlStr, id, phash); err != nil {
return fmt.Errorf("FileRepo.SetPHash: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// Perceptual-hash / duplicate support
// ---------------------------------------------------------------------------
// ListMissingPHash returns live image/video files that have no perceptual hash
// yet — the work list for the dedup backfill. Tags are not loaded (the backfill
// only needs the id and MIME type to choose image vs video hashing).
func (r *FileRepo) ListMissingPHash(ctx context.Context) ([]domain.File, error) {
const sqlStr = `
SELECT f.id, f.original_name,
mt.name AS mime_type, mt.extension AS mime_extension,
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
f.creator_id, u.name AS creator_name,
f.is_public, f.is_deleted, f.needs_review
FROM data.files f
JOIN core.mime_types mt ON mt.id = f.mime_id
JOIN core.users u ON u.id = f.creator_id
WHERE f.phash IS NULL AND f.is_deleted = false
AND (mt.name LIKE 'image/%' OR mt.name LIKE 'video/%')
ORDER BY f.id`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr)
if err != nil {
return nil, fmt.Errorf("FileRepo.ListMissingPHash: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileRow])
if err != nil {
return nil, fmt.Errorf("FileRepo.ListMissingPHash scan: %w", err)
}
files := make([]domain.File, len(collected))
for i, row := range collected {
files[i] = toFile(row)
}
return files, nil
}
// phashRow is the minimal projection used to build duplicate clusters.
type phashRow struct {
ID uuid.UUID `db:"id"`
PHash int64 `db:"phash"`
}
// ListAllPHashes returns the id and perceptual hash of every live, hashed file.
// It is the global input to the dedup rescan, so it deliberately ignores ACL —
// the rescan builds the shared pairs table; visibility is enforced on read.
func (r *FileRepo) ListAllPHashes(ctx context.Context) ([]domain.PHashEntry, error) {
const sqlStr = `SELECT id, phash FROM data.files WHERE is_deleted = false AND phash IS NOT NULL`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr)
if err != nil {
return nil, fmt.Errorf("FileRepo.ListAllPHashes: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[phashRow])
if err != nil {
return nil, fmt.Errorf("FileRepo.ListAllPHashes scan: %w", err)
}
out := make([]domain.PHashEntry, len(collected))
for i, row := range collected {
out[i] = domain.PHashEntry{ID: row.ID, PHash: row.PHash}
}
return out, nil
}
// CopyPoolMemberships adds targetID to every pool sourceID belongs to (copying
// the source's position), skipping pools the target is already in. Used by the
// duplicate merge to preserve the discarded file's pool memberships on the
// survivor. The merge is authorised at the file level, so pool ACL is not
// re-checked here.
func (r *FileRepo) CopyPoolMemberships(ctx context.Context, targetID, sourceID uuid.UUID) error {
const sqlStr = `
INSERT INTO data.file_pool (file_id, pool_id, position)
SELECT $1, fp.pool_id, fp.position
FROM data.file_pool fp
WHERE fp.file_id = $2
ON CONFLICT (file_id, pool_id) DO NOTHING`
q := connOrTx(ctx, r.pool)
if _, err := q.Exec(ctx, sqlStr, targetID, sourceID); err != nil {
return fmt.Errorf("FileRepo.CopyPoolMemberships: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// SoftDelete / Restore / DeletePermanent
// ---------------------------------------------------------------------------
// SoftDelete moves a file to trash (is_deleted = true). Returns ErrNotFound
// if the file does not exist or is already in trash.
func (r *FileRepo) SoftDelete(ctx context.Context, id uuid.UUID) error {
const sqlStr = `UPDATE data.files SET is_deleted = true WHERE id = $1 AND is_deleted = false`
q := connOrTx(ctx, r.pool)
tag, err := q.Exec(ctx, sqlStr, id)
if err != nil {
return fmt.Errorf("FileRepo.SoftDelete: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
// Restore moves a file out of trash (is_deleted = false). Returns ErrNotFound
// if the file does not exist or is not in trash.
func (r *FileRepo) Restore(ctx context.Context, id uuid.UUID) (*domain.File, error) {
const sqlStr = `
WITH r AS (
UPDATE data.files
SET is_deleted = false
WHERE id = $1 AND is_deleted = true
RETURNING id, original_name, mime_id, content_datetime, notes,
metadata, exif, phash, creator_id, is_public, is_deleted,
needs_review
)` + fileSelectCTE
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr, id)
if err != nil {
return nil, fmt.Errorf("FileRepo.Restore: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("FileRepo.Restore scan: %w", err)
}
restored := toFile(row)
tags, err := r.ListTags(ctx, id)
if err != nil {
return nil, err
}
restored.Tags = tags
return &restored, nil
}
// DeletePermanent removes a file record permanently. Only allowed when the
// file is already in trash (is_deleted = true).
func (r *FileRepo) DeletePermanent(ctx context.Context, id uuid.UUID) error {
const sqlStr = `DELETE FROM data.files WHERE id = $1 AND is_deleted = true`
q := connOrTx(ctx, r.pool)
tag, err := q.Exec(ctx, sqlStr, id)
if err != nil {
return fmt.Errorf("FileRepo.DeletePermanent: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
// ---------------------------------------------------------------------------
// ListTags / SetTags
// ---------------------------------------------------------------------------
// ListTags returns all tags assigned to a file, ordered by tag name.
func (r *FileRepo) ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
m, err := r.loadTagsBatch(ctx, []uuid.UUID{fileID})
if err != nil {
return nil, err
}
return m[fileID], nil
}
// SetTags replaces all tags on a file (full replace semantics).
func (r *FileRepo) SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error {
q := connOrTx(ctx, r.pool)
const del = `DELETE FROM data.file_tag WHERE file_id = $1`
if _, err := q.Exec(ctx, del, fileID); err != nil {
return fmt.Errorf("FileRepo.SetTags delete: %w", err)
}
if len(tagIDs) == 0 {
return nil
}
const ins = `INSERT INTO data.file_tag (file_id, tag_id) VALUES ($1, $2)`
for _, tagID := range tagIDs {
if _, err := q.Exec(ctx, ins, fileID, tagID); err != nil {
return fmt.Errorf("FileRepo.SetTags insert: %w", err)
}
}
return nil
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
// List returns a cursor-paginated page of files.
//
// Pagination is keyset-based for stable performance on large tables.
// Cursor encodes the sort position; the caller provides direction.
// Anchor mode centres the result around a specific file UUID.
func (r *FileRepo) List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error) {
sort := normSort(params.Sort)
order := normOrder(params.Order)
forward := params.Direction != "backward"
limit := db.ClampLimit(params.Limit, 50, 200)
// --- resolve cursor / anchor ---
var (
cursorID uuid.UUID
cursorVal string
hasCursor bool
isAnchor bool
)
switch {
case params.Cursor != "":
cur, err := decodeCursor(params.Cursor)
if err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
}
id, err := uuid.Parse(cur.ID)
if err != nil {
return nil, domain.ErrValidation
}
// Lock in the sort/order encoded in the cursor so changing query
// parameters mid-session doesn't corrupt pagination.
sort = normSort(cur.Sort)
order = normOrder(cur.Order)
cursorID = id
cursorVal = cur.Val
hasCursor = true
case params.Anchor != nil:
av, err := r.fetchAnchorVals(ctx, *params.Anchor)
if err != nil {
return nil, err
}
cursorID = *params.Anchor
switch sort {
case "content_datetime":
cursorVal = av.ContentDatetime.UTC().Format(time.RFC3339Nano)
case "original_name":
cursorVal = av.OriginalName
case "mime":
cursorVal = av.MIMEType
// "created": cursorVal stays ""; cursorID is the sort key.
}
hasCursor = true
isAnchor = true
}
// Without a cursor there is no meaningful "backward" direction.
if !hasCursor {
forward = true
}
// --- build WHERE and ORDER BY ---
var conds []string
args := make([]any, 0, 8)
n := 1
conds = append(conds, fmt.Sprintf("f.is_deleted = $%d", n))
args = append(args, params.Trash)
n++
if params.Search != "" {
conds = append(conds, fmt.Sprintf("f.original_name ILIKE $%d", n))
args = append(args, "%"+params.Search+"%")
n++
}
if params.Filter != "" {
filterSQL, nextN, filterArgs, err := ParseFilter(params.Filter, n)
if err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
}
if filterSQL != "" {
conds = append(conds, filterSQL)
n = nextN
args = append(args, filterArgs...)
}
}
// Restrict to files the viewer may see (private-by-default), unless admin.
if !params.ViewerIsAdmin {
var aclCond string
aclCond, n, args = aclVisibilityCond("f", objTypeFile, params.ViewerID, n, args)
conds = append(conds, aclCond)
}
var orderBy string
if hasCursor {
ksWhere, ksOrder, nextN, ksArgs := buildKeysetCond(
sort, order, forward, isAnchor, cursorID, cursorVal, n, args)
conds = append(conds, ksWhere)
n = nextN
args = ksArgs
orderBy = ksOrder
} else {
orderBy = defaultOrderBy(sort, order)
}
where := ""
if len(conds) > 0 {
where = "WHERE " + strings.Join(conds, " AND ")
}
// Fetch one extra row to detect whether more items exist beyond this page.
args = append(args, limit+1)
sqlStr := fmt.Sprintf(`
SELECT f.id, f.original_name,
mt.name AS mime_type, mt.extension AS mime_extension,
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
f.creator_id, u.name AS creator_name,
f.is_public, f.is_deleted, f.needs_review
FROM data.files f
JOIN core.mime_types mt ON mt.id = f.mime_id
JOIN core.users u ON u.id = f.creator_id
%s
ORDER BY %s
LIMIT $%d`, where, orderBy, n)
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr, args...)
if err != nil {
return nil, fmt.Errorf("FileRepo.List: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileRow])
if err != nil {
return nil, fmt.Errorf("FileRepo.List scan: %w", err)
}
// --- trim extra row and reverse for backward ---
hasMore := len(collected) > limit
if hasMore {
collected = collected[:limit]
}
if !forward {
// Results were fetched in reversed ORDER BY; invert to restore the
// natural sort order expected by the caller.
for i, j := 0, len(collected)-1; i < j; i, j = i+1, j-1 {
collected[i], collected[j] = collected[j], collected[i]
}
}
// --- assemble page ---
page := &domain.FilePage{
Items: make([]domain.File, len(collected)),
}
for i, row := range collected {
page.Items[i] = toFile(row)
}
// --- set next/prev cursors ---
// next_cursor: navigate further in the forward direction.
// prev_cursor: navigate further in the backward direction.
if len(collected) > 0 {
firstCur := encodeCursor(makeCursor(collected[0], sort, order))
lastCur := encodeCursor(makeCursor(collected[len(collected)-1], sort, order))
if forward {
// We only know a prev page exists if we arrived via cursor.
if hasCursor {
page.PrevCursor = &firstCur
}
if hasMore {
page.NextCursor = &lastCur
}
} else {
// Backward: last item (after reversal) is closest to original cursor.
if hasCursor {
page.NextCursor = &lastCur
}
if hasMore {
page.PrevCursor = &firstCur
}
}
}
// --- batch-load tags ---
if len(page.Items) > 0 {
fileIDs := make([]uuid.UUID, len(page.Items))
for i, f := range page.Items {
fileIDs[i] = f.ID
}
tagMap, err := r.loadTagsBatch(ctx, fileIDs)
if err != nil {
return nil, err
}
for i, f := range page.Items {
page.Items[i].Tags = tagMap[f.ID] // nil becomes []domain.Tag{} via loadTagsBatch
}
}
return page, nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// fetchAnchorVals returns the sort-column values for the given file.
// Used to set up a cursor when the caller provides an anchor UUID.
func (r *FileRepo) fetchAnchorVals(ctx context.Context, fileID uuid.UUID) (*anchorValRow, error) {
const sqlStr = `
SELECT f.content_datetime,
COALESCE(f.original_name, '') AS original_name,
mt.name AS mime_type
FROM data.files f
JOIN core.mime_types mt ON mt.id = f.mime_id
WHERE f.id = $1`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr, fileID)
if err != nil {
return nil, fmt.Errorf("FileRepo.fetchAnchorVals: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[anchorValRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("FileRepo.fetchAnchorVals scan: %w", err)
}
return &row, nil
}
// loadTagsBatch fetches tags for multiple files in a single query and returns
// them as a map keyed by file ID. Every requested file ID appears as a key
// (with an empty slice if the file has no tags).
func (r *FileRepo) loadTagsBatch(ctx context.Context, fileIDs []uuid.UUID) (map[uuid.UUID][]domain.Tag, error) {
if len(fileIDs) == 0 {
return nil, nil
}
// Build a parameterised IN list. The max page size is 200, so at most 200
// placeholders — well within PostgreSQL's limits.
placeholders := make([]string, len(fileIDs))
args := make([]any, len(fileIDs))
for i, id := range fileIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
sqlStr := fmt.Sprintf(`
SELECT ft.file_id,
t.id, t.name, t.notes, t.color,
t.category_id,
c.name AS category_name,
c.color AS category_color,
t.metadata, t.creator_id, u.name AS creator_name, t.is_public
FROM data.file_tag ft
JOIN data.tags t ON t.id = ft.tag_id
JOIN core.users u ON u.id = t.creator_id
LEFT JOIN data.categories c ON c.id = t.category_id
WHERE ft.file_id IN (%s)
ORDER BY ft.file_id, t.name`, strings.Join(placeholders, ","))
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr, args...)
if err != nil {
return nil, fmt.Errorf("FileRepo.loadTagsBatch: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileTagRow])
if err != nil {
return nil, fmt.Errorf("FileRepo.loadTagsBatch scan: %w", err)
}
result := make(map[uuid.UUID][]domain.Tag, len(fileIDs))
for _, fid := range fileIDs {
result[fid] = []domain.Tag{} // guarantee every key has a non-nil slice
}
for _, row := range collected {
result[row.FileID] = append(result[row.FileID], toTagFromFileTag(row))
}
return result, nil
}
// RecordView appends a row to activity.file_views. viewed_at defaults to
// statement_timestamp(), so each call records a distinct view in the history.
func (r *FileRepo) RecordView(ctx context.Context, fileID uuid.UUID, userID int16) error {
const query = `INSERT INTO activity.file_views (file_id, user_id) VALUES ($1, $2)`
if _, err := connOrTx(ctx, r.pool).Exec(ctx, query, fileID, userID); err != nil {
return fmt.Errorf("FileRepo.RecordView: %w", err)
}
return nil
}
// RecordTagUses appends a row to activity.tag_uses for each tag referenced in a
// filter DSL, flagging it included (positive) or excluded (negated). Tags are
// deduplicated per call, so one statement_timestamp() never collides on the
// (tag_id, used_at, user_id) PK; ON CONFLICT DO NOTHING guards the rest. A
// filter with no tag terms is a no-op.
func (r *FileRepo) RecordTagUses(ctx context.Context, userID int16, filterDSL string) error {
uses := filterTagUses(filterDSL)
if len(uses) == 0 {
return nil
}
var sb strings.Builder
sb.WriteString("INSERT INTO activity.tag_uses (tag_id, user_id, is_included) VALUES ")
args := make([]any, 0, len(uses)*3)
for i, u := range uses {
if i > 0 {
sb.WriteString(", ")
}
base := i * 3
fmt.Fprintf(&sb, "($%d, $%d, $%d)", base+1, base+2, base+3)
args = append(args, u.tagID, userID, u.included)
}
sb.WriteString(" ON CONFLICT DO NOTHING")
if _, err := connOrTx(ctx, r.pool).Exec(ctx, sb.String(), args...); err != nil {
return fmt.Errorf("FileRepo.RecordTagUses: %w", err)
}
return nil
}
@@ -0,0 +1,361 @@
package postgres
import (
"fmt"
"strconv"
"strings"
"github.com/google/uuid"
)
// ---------------------------------------------------------------------------
// Token types
// ---------------------------------------------------------------------------
type filterTokenKind int
const (
ftkAnd filterTokenKind = iota
ftkOr
ftkNot
ftkLParen
ftkRParen
ftkTag // t=<uuid>
ftkMimeExact // m=<int>
ftkMimeLike // m~<pattern>
ftkReview // r=<0|1>
)
type filterToken struct {
kind filterTokenKind
tagID uuid.UUID // ftkTag
untagged bool // ftkTag with zero UUID → "file has no tags"
mimeID int16 // ftkMimeExact
pattern string // ftkMimeLike
review bool // ftkReview → needs_review value
}
// ---------------------------------------------------------------------------
// AST nodes
// ---------------------------------------------------------------------------
// filterNode produces a parameterized SQL fragment.
// n is the index of the next available positional parameter ($n).
// Returns the fragment, the updated n, and the extended args slice.
type filterNode interface {
toSQL(n int, args []any) (string, int, []any)
}
type andNode struct{ left, right filterNode }
type orNode struct{ left, right filterNode }
type notNode struct{ child filterNode }
type leafNode struct{ tok filterToken }
func (a *andNode) toSQL(n int, args []any) (string, int, []any) {
ls, n, args := a.left.toSQL(n, args)
rs, n, args := a.right.toSQL(n, args)
return "(" + ls + " AND " + rs + ")", n, args
}
func (o *orNode) toSQL(n int, args []any) (string, int, []any) {
ls, n, args := o.left.toSQL(n, args)
rs, n, args := o.right.toSQL(n, args)
return "(" + ls + " OR " + rs + ")", n, args
}
func (no *notNode) toSQL(n int, args []any) (string, int, []any) {
cs, n, args := no.child.toSQL(n, args)
return "(NOT " + cs + ")", n, args
}
func (l *leafNode) toSQL(n int, args []any) (string, int, []any) {
switch l.tok.kind {
case ftkTag:
if l.tok.untagged {
return "NOT EXISTS (SELECT 1 FROM data.file_tag ft WHERE ft.file_id = f.id)", n, args
}
s := fmt.Sprintf(
"EXISTS (SELECT 1 FROM data.file_tag ft WHERE ft.file_id = f.id AND ft.tag_id = $%d)", n)
return s, n + 1, append(args, l.tok.tagID)
case ftkMimeExact:
return fmt.Sprintf("f.mime_id = $%d", n), n + 1, append(args, l.tok.mimeID)
case ftkMimeLike:
// mt alias comes from the JOIN in the main file query (always present).
return fmt.Sprintf("mt.name LIKE $%d", n), n + 1, append(args, l.tok.pattern)
case ftkReview:
return fmt.Sprintf("f.needs_review = $%d", n), n + 1, append(args, l.tok.review)
}
panic("filterNode.toSQL: unknown leaf kind")
}
// ---------------------------------------------------------------------------
// Lexer
// ---------------------------------------------------------------------------
// lexFilter tokenises the DSL string {a,b,c,...} into filterTokens.
func lexFilter(dsl string) ([]filterToken, error) {
dsl = strings.TrimSpace(dsl)
if !strings.HasPrefix(dsl, "{") || !strings.HasSuffix(dsl, "}") {
return nil, fmt.Errorf("filter DSL must be wrapped in braces: {…}")
}
inner := strings.TrimSpace(dsl[1 : len(dsl)-1])
if inner == "" {
return nil, nil
}
parts := strings.Split(inner, ",")
tokens := make([]filterToken, 0, len(parts))
for _, raw := range parts {
p := strings.TrimSpace(raw)
switch {
case p == "&":
tokens = append(tokens, filterToken{kind: ftkAnd})
case p == "|":
tokens = append(tokens, filterToken{kind: ftkOr})
case p == "!":
tokens = append(tokens, filterToken{kind: ftkNot})
case p == "(":
tokens = append(tokens, filterToken{kind: ftkLParen})
case p == ")":
tokens = append(tokens, filterToken{kind: ftkRParen})
case strings.HasPrefix(p, "t="):
id, err := uuid.Parse(p[2:])
if err != nil {
return nil, fmt.Errorf("filter: invalid tag UUID %q", p[2:])
}
tokens = append(tokens, filterToken{kind: ftkTag, tagID: id, untagged: id == uuid.Nil})
case strings.HasPrefix(p, "m="):
v, err := strconv.ParseInt(p[2:], 10, 16)
if err != nil {
return nil, fmt.Errorf("filter: invalid MIME ID %q", p[2:])
}
tokens = append(tokens, filterToken{kind: ftkMimeExact, mimeID: int16(v)})
case strings.HasPrefix(p, "m~"):
// The pattern value is passed as a query parameter, so no SQL injection risk.
tokens = append(tokens, filterToken{kind: ftkMimeLike, pattern: p[2:]})
case strings.HasPrefix(p, "r="):
switch p[2:] {
case "1":
tokens = append(tokens, filterToken{kind: ftkReview, review: true})
case "0":
tokens = append(tokens, filterToken{kind: ftkReview, review: false})
default:
return nil, fmt.Errorf("filter: invalid review flag %q (want r=0 or r=1)", p[2:])
}
default:
return nil, fmt.Errorf("filter: unknown token %q", p)
}
}
return tokens, nil
}
// ---------------------------------------------------------------------------
// Recursive-descent parser
// ---------------------------------------------------------------------------
type filterParser struct {
tokens []filterToken
pos int
}
func (p *filterParser) peek() (filterToken, bool) {
if p.pos >= len(p.tokens) {
return filterToken{}, false
}
return p.tokens[p.pos], true
}
func (p *filterParser) next() filterToken {
t := p.tokens[p.pos]
p.pos++
return t
}
// Grammar (standard NOT > AND > OR precedence):
//
// expr := or_expr
// or_expr := and_expr ('|' and_expr)*
// and_expr := not_expr ('&' not_expr)*
// not_expr := '!' not_expr | atom
// atom := '(' expr ')' | leaf
func (p *filterParser) parseExpr() (filterNode, error) { return p.parseOr() }
func (p *filterParser) parseOr() (filterNode, error) {
left, err := p.parseAnd()
if err != nil {
return nil, err
}
for {
t, ok := p.peek()
if !ok || t.kind != ftkOr {
break
}
p.next()
right, err := p.parseAnd()
if err != nil {
return nil, err
}
left = &orNode{left, right}
}
return left, nil
}
func (p *filterParser) parseAnd() (filterNode, error) {
left, err := p.parseNot()
if err != nil {
return nil, err
}
for {
t, ok := p.peek()
if !ok || t.kind != ftkAnd {
break
}
p.next()
right, err := p.parseNot()
if err != nil {
return nil, err
}
left = &andNode{left, right}
}
return left, nil
}
func (p *filterParser) parseNot() (filterNode, error) {
t, ok := p.peek()
if ok && t.kind == ftkNot {
p.next()
child, err := p.parseNot() // right-recursive to allow !!x
if err != nil {
return nil, err
}
return &notNode{child}, nil
}
return p.parseAtom()
}
func (p *filterParser) parseAtom() (filterNode, error) {
t, ok := p.peek()
if !ok {
return nil, fmt.Errorf("filter: unexpected end of expression")
}
if t.kind == ftkLParen {
p.next()
expr, err := p.parseExpr()
if err != nil {
return nil, err
}
rp, ok := p.peek()
if !ok || rp.kind != ftkRParen {
return nil, fmt.Errorf("filter: expected ')'")
}
p.next()
return expr, nil
}
switch t.kind {
case ftkTag, ftkMimeExact, ftkMimeLike, ftkReview:
p.next()
return &leafNode{t}, nil
default:
return nil, fmt.Errorf("filter: unexpected token at position %d", p.pos)
}
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
// parseFilterAST lexes and parses a filter DSL into an AST. Returns (nil, nil)
// for an empty or trivial DSL.
func parseFilterAST(dsl string) (filterNode, error) {
dsl = strings.TrimSpace(dsl)
if dsl == "" || dsl == "{}" {
return nil, nil
}
toks, err := lexFilter(dsl)
if err != nil {
return nil, err
}
if len(toks) == 0 {
return nil, nil
}
p := &filterParser{tokens: toks}
node, err := p.parseExpr()
if err != nil {
return nil, err
}
if p.pos != len(p.tokens) {
return nil, fmt.Errorf("filter: trailing tokens at position %d", p.pos)
}
return node, nil
}
// ParseFilter parses a filter DSL string into a parameterized SQL fragment.
//
// argStart is the 1-based index for the first $N placeholder; this lets the
// caller interleave filter parameters with other query parameters.
//
// Returns ("", argStart, nil, nil) for an empty or trivial DSL.
// SQL injection is structurally impossible: every user-supplied value is
// bound as a query parameter ($N), never interpolated into the SQL string.
func ParseFilter(dsl string, argStart int) (sql string, nextN int, args []any, err error) {
node, err := parseFilterAST(dsl)
if err != nil {
return "", argStart, nil, err
}
if node == nil {
return "", argStart, nil, nil
}
sql, nextN, args = node.toSQL(argStart, nil)
return sql, nextN, args, nil
}
// tagUse is a tag referenced by a filter, with whether it was included
// (positive) or excluded (negated under an odd number of NOTs).
type tagUse struct {
tagID uuid.UUID
included bool
}
// filterTagUses extracts the distinct tag references in a filter DSL, marking
// each as included or excluded. The "untagged" pseudo-token (zero UUID) is
// skipped. Returns nil for a filter with no tag terms; an unparseable filter
// also yields nil (extraction is best-effort analytics, not validation).
func filterTagUses(dsl string) []tagUse {
node, err := parseFilterAST(dsl)
if err != nil || node == nil {
return nil
}
seen := make(map[uuid.UUID]bool)
collectTagUses(node, true, seen)
if len(seen) == 0 {
return nil
}
uses := make([]tagUse, 0, len(seen))
for id, inc := range seen {
uses = append(uses, tagUse{tagID: id, included: inc})
}
return uses
}
// collectTagUses walks the AST, recording each real tag leaf into out keyed by
// id. included flips under every NOT, so a tag is "excluded" only when nested
// under an odd number of NOTs. A tag appearing under both polarities keeps the
// last seen — pathological, but it avoids a duplicate-key insert.
func collectTagUses(node filterNode, included bool, out map[uuid.UUID]bool) {
switch nd := node.(type) {
case *andNode:
collectTagUses(nd.left, included, out)
collectTagUses(nd.right, included, out)
case *orNode:
collectTagUses(nd.left, included, out)
collectTagUses(nd.right, included, out)
case *notNode:
collectTagUses(nd.child, !included, out)
case *leafNode:
if nd.tok.kind == ftkTag && !nd.tok.untagged {
out[nd.tok.tagID] = included
}
}
}
@@ -0,0 +1,97 @@
package postgres
import (
"testing"
"github.com/google/uuid"
)
func TestParseFilterReview(t *testing.T) {
t.Run("r=1 needs review", func(t *testing.T) {
sql, n, args, err := ParseFilter("{r=1}", 1)
if err != nil {
t.Fatalf("ParseFilter: %v", err)
}
if sql != "f.needs_review = $1" {
t.Fatalf("sql = %q", sql)
}
if n != 2 || len(args) != 1 || args[0] != true {
t.Fatalf("n=%d args=%v", n, args)
}
})
t.Run("r=0 reviewed", func(t *testing.T) {
sql, _, args, err := ParseFilter("{r=0}", 1)
if err != nil {
t.Fatalf("ParseFilter: %v", err)
}
if sql != "f.needs_review = $1" || len(args) != 1 || args[0] != false {
t.Fatalf("sql=%q args=%v", sql, args)
}
})
t.Run("combined with mime", func(t *testing.T) {
sql, n, args, err := ParseFilter("{r=1,&,m~image/%}", 1)
if err != nil {
t.Fatalf("ParseFilter: %v", err)
}
if sql != "(f.needs_review = $1 AND mt.name LIKE $2)" {
t.Fatalf("sql = %q", sql)
}
if n != 3 || len(args) != 2 || args[0] != true || args[1] != "image/%" {
t.Fatalf("n=%d args=%v", n, args)
}
})
t.Run("invalid flag rejected", func(t *testing.T) {
if _, _, _, err := ParseFilter("{r=2}", 1); err == nil {
t.Fatal("expected error for r=2")
}
})
}
func TestFilterTagUses(t *testing.T) {
a := uuid.MustParse("11111111-1111-1111-1111-111111111111")
b := uuid.MustParse("22222222-2222-2222-2222-222222222222")
tests := []struct {
name string
dsl string
want map[uuid.UUID]bool // tag → included; absence means "not recorded"
}{
{"single included", "{t=" + a.String() + "}", map[uuid.UUID]bool{a: true}},
{"single excluded", "{!,t=" + a.String() + "}", map[uuid.UUID]bool{a: false}},
{"double negation is included", "{!,!,t=" + a.String() + "}", map[uuid.UUID]bool{a: true}},
{
"and of two included",
"{t=" + a.String() + ",&,t=" + b.String() + "}",
map[uuid.UUID]bool{a: true, b: true},
},
{
"not over a group excludes both",
"{!,(,t=" + a.String() + ",|,t=" + b.String() + ",)}",
map[uuid.UUID]bool{a: false, b: false},
},
{"untagged pseudo-token skipped", "{t=" + uuid.Nil.String() + "}", map[uuid.UUID]bool{}},
{"mime-only filter records nothing", "{m=3}", map[uuid.UUID]bool{}},
{"empty filter", "{}", map[uuid.UUID]bool{}},
{"unparseable filter is best-effort nil", "{t=not-a-uuid}", map[uuid.UUID]bool{}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := make(map[uuid.UUID]bool)
for _, u := range filterTagUses(tc.dsl) {
got[u.tagID] = u.included
}
if len(got) != len(tc.want) {
t.Fatalf("got %d uses %v, want %d %v", len(got), got, len(tc.want), tc.want)
}
for id, inc := range tc.want {
if g, ok := got[id]; !ok || g != inc {
t.Errorf("tag %s: got (included=%v, present=%v), want included=%v", id, g, ok, inc)
}
}
})
}
}
+97
View File
@@ -0,0 +1,97 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
type mimeRow struct {
ID int16 `db:"id"`
Name string `db:"name"`
Extension string `db:"extension"`
}
func toMIMEType(r mimeRow) domain.MIMEType {
return domain.MIMEType{
ID: r.ID,
Name: r.Name,
Extension: r.Extension,
}
}
// MimeRepo implements port.MimeRepo using PostgreSQL.
type MimeRepo struct {
pool *pgxpool.Pool
}
// NewMimeRepo creates a MimeRepo backed by pool.
func NewMimeRepo(pool *pgxpool.Pool) *MimeRepo {
return &MimeRepo{pool: pool}
}
var _ port.MimeRepo = (*MimeRepo)(nil)
func (r *MimeRepo) List(ctx context.Context) ([]domain.MIMEType, error) {
const sql = `SELECT id, name, extension FROM core.mime_types ORDER BY name`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql)
if err != nil {
return nil, fmt.Errorf("MimeRepo.List: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[mimeRow])
if err != nil {
return nil, fmt.Errorf("MimeRepo.List scan: %w", err)
}
result := make([]domain.MIMEType, len(collected))
for i, row := range collected {
result[i] = toMIMEType(row)
}
return result, nil
}
func (r *MimeRepo) GetByID(ctx context.Context, id int16) (*domain.MIMEType, error) {
const sql = `SELECT id, name, extension FROM core.mime_types WHERE id = $1`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, id)
if err != nil {
return nil, fmt.Errorf("MimeRepo.GetByID: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[mimeRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("MimeRepo.GetByID scan: %w", err)
}
m := toMIMEType(row)
return &m, nil
}
func (r *MimeRepo) GetByName(ctx context.Context, name string) (*domain.MIMEType, error) {
const sql = `SELECT id, name, extension FROM core.mime_types WHERE name = $1`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, name)
if err != nil {
return nil, fmt.Errorf("MimeRepo.GetByName: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[mimeRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrUnsupportedMIME
}
return nil, fmt.Errorf("MimeRepo.GetByName scan: %w", err)
}
m := toMIMEType(row)
return &m, nil
}
+725
View File
@@ -0,0 +1,725 @@
package postgres
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/backend/internal/db"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// ---------------------------------------------------------------------------
// Row structs
// ---------------------------------------------------------------------------
type poolRow struct {
ID uuid.UUID `db:"id"`
Name string `db:"name"`
Notes *string `db:"notes"`
Metadata []byte `db:"metadata"`
CreatorID int16 `db:"creator_id"`
CreatorName string `db:"creator_name"`
IsPublic bool `db:"is_public"`
FileCount int `db:"file_count"`
}
type poolRowWithTotal struct {
poolRow
Total int `db:"total"`
}
// poolFileRow is a flat struct combining all file columns plus pool position.
type poolFileRow struct {
ID uuid.UUID `db:"id"`
OriginalName *string `db:"original_name"`
MIMEType string `db:"mime_type"`
MIMEExtension string `db:"mime_extension"`
ContentDatetime time.Time `db:"content_datetime"`
Notes *string `db:"notes"`
Metadata json.RawMessage `db:"metadata"`
EXIF json.RawMessage `db:"exif"`
PHash *int64 `db:"phash"`
CreatorID int16 `db:"creator_id"`
CreatorName string `db:"creator_name"`
IsPublic bool `db:"is_public"`
IsDeleted bool `db:"is_deleted"`
Position int `db:"position"`
}
// ---------------------------------------------------------------------------
// Converters
// ---------------------------------------------------------------------------
func toPool(r poolRow) domain.Pool {
p := domain.Pool{
ID: r.ID,
Name: r.Name,
Notes: r.Notes,
CreatorID: r.CreatorID,
CreatorName: r.CreatorName,
IsPublic: r.IsPublic,
FileCount: r.FileCount,
CreatedAt: domain.UUIDCreatedAt(r.ID),
}
if len(r.Metadata) > 0 && string(r.Metadata) != "null" {
p.Metadata = json.RawMessage(r.Metadata)
}
return p
}
func toPoolFile(r poolFileRow) domain.PoolFile {
return domain.PoolFile{
File: domain.File{
ID: r.ID,
OriginalName: r.OriginalName,
MIMEType: r.MIMEType,
MIMEExtension: r.MIMEExtension,
ContentDatetime: r.ContentDatetime,
Notes: r.Notes,
Metadata: r.Metadata,
EXIF: r.EXIF,
PHash: r.PHash,
CreatorID: r.CreatorID,
CreatorName: r.CreatorName,
IsPublic: r.IsPublic,
IsDeleted: r.IsDeleted,
CreatedAt: domain.UUIDCreatedAt(r.ID),
},
Position: r.Position,
}
}
// ---------------------------------------------------------------------------
// Cursor
// ---------------------------------------------------------------------------
type poolFileCursor struct {
Position int `json:"p"`
FileID string `json:"id"`
}
func encodePoolCursor(c poolFileCursor) string {
b, _ := json.Marshal(c)
return base64.RawURLEncoding.EncodeToString(b)
}
func decodePoolCursor(s string) (poolFileCursor, error) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return poolFileCursor{}, fmt.Errorf("cursor: invalid encoding")
}
var c poolFileCursor
if err := json.Unmarshal(b, &c); err != nil {
return poolFileCursor{}, fmt.Errorf("cursor: invalid format")
}
return c, nil
}
// ---------------------------------------------------------------------------
// Shared SQL
// ---------------------------------------------------------------------------
// poolCountSubquery computes per-pool file counts.
const poolCountSubquery = `(SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool GROUP BY pool_id)`
const poolSelectFrom = `
SELECT p.id, p.name, p.notes, p.metadata,
p.creator_id, u.name AS creator_name, p.is_public,
COALESCE(fc.cnt, 0) AS file_count
FROM data.pools p
JOIN core.users u ON u.id = p.creator_id
LEFT JOIN ` + poolCountSubquery + ` fc ON fc.pool_id = p.id`
func poolSortColumn(s string) string {
if s == "name" {
return "p.name"
}
return "p.id" // "created"
}
// ---------------------------------------------------------------------------
// PoolRepo
// ---------------------------------------------------------------------------
// PoolRepo implements port.PoolRepo using PostgreSQL.
type PoolRepo struct {
pool *pgxpool.Pool
}
var _ port.PoolRepo = (*PoolRepo)(nil)
// NewPoolRepo creates a PoolRepo backed by pool.
func NewPoolRepo(pool *pgxpool.Pool) *PoolRepo {
return &PoolRepo{pool: pool}
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
func (r *PoolRepo) List(ctx context.Context, params port.OffsetParams) (*domain.PoolOffsetPage, error) {
order := "ASC"
if strings.ToLower(params.Order) == "desc" {
order = "DESC"
}
sortCol := poolSortColumn(params.Sort)
args := []any{}
n := 1
var conditions []string
if params.Search != "" {
conditions = append(conditions, fmt.Sprintf("lower(p.name) LIKE lower($%d)", n))
args = append(args, "%"+params.Search+"%")
n++
}
// Restrict to pools the viewer may see (private-by-default), unless admin.
if !params.ViewerIsAdmin {
var aclCond string
aclCond, n, args = aclVisibilityCond("p", objTypePool, params.ViewerID, n, args)
conditions = append(conditions, aclCond)
}
where := ""
if len(conditions) > 0 {
where = "WHERE " + strings.Join(conditions, " AND ")
}
limit := params.Limit
if limit <= 0 {
limit = 50
}
offset := params.Offset
if offset < 0 {
offset = 0
}
query := fmt.Sprintf(`
SELECT p.id, p.name, p.notes, p.metadata,
p.creator_id, u.name AS creator_name, p.is_public,
COALESCE(fc.cnt, 0) AS file_count,
COUNT(*) OVER() AS total
FROM data.pools p
JOIN core.users u ON u.id = p.creator_id
LEFT JOIN %s fc ON fc.pool_id = p.id
%s
ORDER BY %s %s NULLS LAST, p.id ASC
LIMIT $%d OFFSET $%d`, poolCountSubquery, where, sortCol, order, n, n+1)
args = append(args, limit, offset)
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("PoolRepo.List query: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[poolRowWithTotal])
if err != nil {
return nil, fmt.Errorf("PoolRepo.List scan: %w", err)
}
items := make([]domain.Pool, len(collected))
total := 0
for i, row := range collected {
items[i] = toPool(row.poolRow)
total = row.Total
}
return &domain.PoolOffsetPage{
Items: items,
Total: total,
Offset: offset,
Limit: limit,
}, nil
}
// ---------------------------------------------------------------------------
// GetByID
// ---------------------------------------------------------------------------
func (r *PoolRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Pool, error) {
query := poolSelectFrom + `
WHERE p.id = $1`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, id)
if err != nil {
return nil, fmt.Errorf("PoolRepo.GetByID: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[poolRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("PoolRepo.GetByID scan: %w", err)
}
p := toPool(row)
return &p, nil
}
// RecordView appends a row to activity.pool_views. viewed_at defaults to
// statement_timestamp(), so each call records a distinct view in the history.
func (r *PoolRepo) RecordView(ctx context.Context, poolID uuid.UUID, userID int16) error {
const query = `INSERT INTO activity.pool_views (pool_id, user_id) VALUES ($1, $2)`
if _, err := connOrTx(ctx, r.pool).Exec(ctx, query, poolID, userID); err != nil {
return fmt.Errorf("PoolRepo.RecordView: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------------
func (r *PoolRepo) Create(ctx context.Context, p *domain.Pool) (*domain.Pool, error) {
const query = `
WITH ins AS (
INSERT INTO data.pools (name, notes, metadata, creator_id, is_public)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
)
SELECT ins.id, ins.name, ins.notes, ins.metadata,
ins.creator_id, u.name AS creator_name, ins.is_public,
0 AS file_count
FROM ins
JOIN core.users u ON u.id = ins.creator_id`
var meta any
if len(p.Metadata) > 0 {
meta = p.Metadata
}
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, p.Name, p.Notes, meta, p.CreatorID, p.IsPublic)
if err != nil {
return nil, fmt.Errorf("PoolRepo.Create: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[poolRow])
if err != nil {
if isPgUniqueViolation(err) {
return nil, domain.ErrConflict
}
return nil, fmt.Errorf("PoolRepo.Create scan: %w", err)
}
created := toPool(row)
return &created, nil
}
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
func (r *PoolRepo) Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*domain.Pool, error) {
const query = `
WITH upd AS (
UPDATE data.pools SET
name = $2,
notes = $3,
metadata = COALESCE($4, metadata),
is_public = $5
WHERE id = $1
RETURNING *
)
SELECT upd.id, upd.name, upd.notes, upd.metadata,
upd.creator_id, u.name AS creator_name, upd.is_public,
COALESCE(fc.cnt, 0) AS file_count
FROM upd
JOIN core.users u ON u.id = upd.creator_id
LEFT JOIN (SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool WHERE pool_id = $1 GROUP BY pool_id) fc
ON fc.pool_id = upd.id`
var meta any
if len(p.Metadata) > 0 {
meta = p.Metadata
}
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic)
if err != nil {
return nil, fmt.Errorf("PoolRepo.Update: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[poolRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
if isPgUniqueViolation(err) {
return nil, domain.ErrConflict
}
return nil, fmt.Errorf("PoolRepo.Update scan: %w", err)
}
updated := toPool(row)
return &updated, nil
}
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
func (r *PoolRepo) Delete(ctx context.Context, id uuid.UUID) error {
const query = `DELETE FROM data.pools WHERE id = $1`
q := connOrTx(ctx, r.pool)
ct, err := q.Exec(ctx, query, id)
if err != nil {
return fmt.Errorf("PoolRepo.Delete: %w", err)
}
if ct.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
// ---------------------------------------------------------------------------
// ListFiles
// ---------------------------------------------------------------------------
// fileSelectForPool is the column list for pool file queries (without position).
const fileSelectForPool = `
f.id, f.original_name,
mt.name AS mime_type, mt.extension AS mime_extension,
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
f.creator_id, u.name AS creator_name,
f.is_public, f.is_deleted`
func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) {
limit := db.ClampLimit(params.Limit, 50, 200)
args := []any{poolID}
n := 2
var conds []string
conds = append(conds, "fp.pool_id = $1")
conds = append(conds, "f.is_deleted = false")
if params.Filter != "" {
filterSQL, nextN, filterArgs, err := ParseFilter(params.Filter, n)
if err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
}
if filterSQL != "" {
conds = append(conds, filterSQL)
n = nextN
args = append(args, filterArgs...)
}
}
// Cursor condition.
var orderBy string
if params.Cursor != "" {
cur, err := decodePoolCursor(params.Cursor)
if err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
}
fileID, err := uuid.Parse(cur.FileID)
if err != nil {
return nil, domain.ErrValidation
}
conds = append(conds, fmt.Sprintf(
"(fp.position > $%d OR (fp.position = $%d AND fp.file_id > $%d))",
n, n, n+1))
args = append(args, cur.Position, fileID)
n += 2
}
orderBy = "fp.position ASC, fp.file_id ASC"
where := "WHERE " + strings.Join(conds, " AND ")
args = append(args, limit+1)
sqlStr := fmt.Sprintf(`
SELECT %s, fp.position
FROM data.file_pool fp
JOIN data.files f ON f.id = fp.file_id
JOIN core.mime_types mt ON mt.id = f.mime_id
JOIN core.users u ON u.id = f.creator_id
%s
ORDER BY %s
LIMIT $%d`, fileSelectForPool, where, orderBy, n)
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr, args...)
if err != nil {
return nil, fmt.Errorf("PoolRepo.ListFiles query: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[poolFileRow])
if err != nil {
return nil, fmt.Errorf("PoolRepo.ListFiles scan: %w", err)
}
hasMore := len(collected) > limit
if hasMore {
collected = collected[:limit]
}
items := make([]domain.PoolFile, len(collected))
for i, row := range collected {
items[i] = toPoolFile(row)
}
page := &domain.PoolFilePage{Items: items}
if hasMore && len(collected) > 0 {
last := collected[len(collected)-1]
cur := encodePoolCursor(poolFileCursor{
Position: last.Position,
FileID: last.ID.String(),
})
page.NextCursor = &cur
}
// Batch-load tags.
if len(items) > 0 {
fileIDs := make([]uuid.UUID, len(items))
for i, pf := range items {
fileIDs[i] = pf.File.ID
}
tagMap, err := r.loadPoolTagsBatch(ctx, fileIDs)
if err != nil {
return nil, err
}
for i, pf := range items {
page.Items[i].File.Tags = tagMap[pf.File.ID]
}
}
return page, nil
}
// loadPoolTagsBatch re-uses the same pattern as FileRepo.loadTagsBatch.
func (r *PoolRepo) loadPoolTagsBatch(ctx context.Context, fileIDs []uuid.UUID) (map[uuid.UUID][]domain.Tag, error) {
if len(fileIDs) == 0 {
return nil, nil
}
placeholders := make([]string, len(fileIDs))
args := make([]any, len(fileIDs))
for i, id := range fileIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
sqlStr := fmt.Sprintf(`
SELECT ft.file_id,
t.id, t.name, t.notes, t.color,
t.category_id,
c.name AS category_name,
c.color AS category_color,
t.metadata, t.creator_id, u.name AS creator_name, t.is_public
FROM data.file_tag ft
JOIN data.tags t ON t.id = ft.tag_id
JOIN core.users u ON u.id = t.creator_id
LEFT JOIN data.categories c ON c.id = t.category_id
WHERE ft.file_id IN (%s)
ORDER BY ft.file_id, t.name`, strings.Join(placeholders, ","))
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr, args...)
if err != nil {
return nil, fmt.Errorf("PoolRepo.loadPoolTagsBatch: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileTagRow])
if err != nil {
return nil, fmt.Errorf("PoolRepo.loadPoolTagsBatch scan: %w", err)
}
result := make(map[uuid.UUID][]domain.Tag, len(fileIDs))
for _, fid := range fileIDs {
result[fid] = []domain.Tag{}
}
for _, row := range collected {
result[row.FileID] = append(result[row.FileID], toTagFromFileTag(row))
}
return result, nil
}
// ---------------------------------------------------------------------------
// AddFiles
// ---------------------------------------------------------------------------
// AddFiles inserts files into the pool. When position is nil, files are
// appended after the last existing file (MAX(position) + 1000 * i).
// When position is provided (0-indexed), files are inserted at that index
// and all pool positions are reassigned in one shot.
func (r *PoolRepo) AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error {
if len(fileIDs) == 0 {
return nil
}
q := connOrTx(ctx, r.pool)
if position == nil {
// Append: get current max position, then bulk-insert.
var maxPos int
row := q.QueryRow(ctx, `SELECT COALESCE(MAX(position), 0) FROM data.file_pool WHERE pool_id = $1`, poolID)
if err := row.Scan(&maxPos); err != nil {
return fmt.Errorf("PoolRepo.AddFiles maxPos: %w", err)
}
const ins = `INSERT INTO data.file_pool (file_id, pool_id, position) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`
for i, fid := range fileIDs {
if _, err := q.Exec(ctx, ins, fid, poolID, maxPos+1000*(i+1)); err != nil {
return fmt.Errorf("PoolRepo.AddFiles insert: %w", err)
}
}
return nil
}
// Positional insert: rebuild the full ordered list and reassign.
return r.insertAtPosition(ctx, q, poolID, fileIDs, *position)
}
// insertAtPosition fetches the current ordered file list, splices in the new
// IDs at index pos (0-indexed, clamped), then does a full position reassign.
func (r *PoolRepo) insertAtPosition(ctx context.Context, q db.Querier, poolID uuid.UUID, newIDs []uuid.UUID, pos int) error {
// 1. Fetch current order.
rows, err := q.Query(ctx, `SELECT file_id FROM data.file_pool WHERE pool_id = $1 ORDER BY position ASC, file_id ASC`, poolID)
if err != nil {
return fmt.Errorf("PoolRepo.insertAtPosition fetch: %w", err)
}
var current []uuid.UUID
for rows.Next() {
var fid uuid.UUID
if err := rows.Scan(&fid); err != nil {
rows.Close()
return fmt.Errorf("PoolRepo.insertAtPosition scan: %w", err)
}
current = append(current, fid)
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("PoolRepo.insertAtPosition rows: %w", err)
}
// 2. Build new ordered list, skipping already-present IDs from newIDs.
present := make(map[uuid.UUID]bool, len(current))
for _, fid := range current {
present[fid] = true
}
toAdd := make([]uuid.UUID, 0, len(newIDs))
for _, fid := range newIDs {
if !present[fid] {
toAdd = append(toAdd, fid)
}
}
if len(toAdd) == 0 {
return nil // all already present
}
if pos < 0 {
pos = 0
}
if pos > len(current) {
pos = len(current)
}
ordered := make([]uuid.UUID, 0, len(current)+len(toAdd))
ordered = append(ordered, current[:pos]...)
ordered = append(ordered, toAdd...)
ordered = append(ordered, current[pos:]...)
// 3. Full replace.
return r.reassignPositions(ctx, q, poolID, ordered)
}
// reassignPositions does a DELETE + bulk INSERT for the pool with positions
// 1000, 2000, 3000, ...
func (r *PoolRepo) reassignPositions(ctx context.Context, q db.Querier, poolID uuid.UUID, ordered []uuid.UUID) error {
if _, err := q.Exec(ctx, `DELETE FROM data.file_pool WHERE pool_id = $1`, poolID); err != nil {
return fmt.Errorf("PoolRepo.reassignPositions delete: %w", err)
}
if len(ordered) == 0 {
return nil
}
const ins = `INSERT INTO data.file_pool (file_id, pool_id, position) VALUES ($1, $2, $3)`
for i, fid := range ordered {
if _, err := q.Exec(ctx, ins, fid, poolID, 1000*(i+1)); err != nil {
return fmt.Errorf("PoolRepo.reassignPositions insert: %w", err)
}
}
return nil
}
// ---------------------------------------------------------------------------
// RemoveFiles
// ---------------------------------------------------------------------------
func (r *PoolRepo) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
if len(fileIDs) == 0 {
return nil
}
placeholders := make([]string, len(fileIDs))
args := make([]any, len(fileIDs)+1)
args[0] = poolID
for i, fid := range fileIDs {
placeholders[i] = fmt.Sprintf("$%d", i+2)
args[i+1] = fid
}
query := fmt.Sprintf(
`DELETE FROM data.file_pool WHERE pool_id = $1 AND file_id IN (%s)`,
strings.Join(placeholders, ","))
q := connOrTx(ctx, r.pool)
if _, err := q.Exec(ctx, query, args...); err != nil {
return fmt.Errorf("PoolRepo.RemoveFiles: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// Reorder
// ---------------------------------------------------------------------------
// Reorder applies the requested order to the pool. Files actually in the pool
// are placed in the given order; any pool members the request omitted are kept
// and appended in their current order. This makes a partial request (e.g. a
// paginated client that only loaded the first pages) reorder the visible prefix
// without deleting the rest. Unknown IDs are ignored.
func (r *PoolRepo) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
q := connOrTx(ctx, r.pool)
// Current membership, in position order.
rows, err := q.Query(ctx,
`SELECT file_id FROM data.file_pool WHERE pool_id = $1 ORDER BY position ASC, file_id ASC`, poolID)
if err != nil {
return fmt.Errorf("PoolRepo.Reorder fetch: %w", err)
}
var current []uuid.UUID
for rows.Next() {
var fid uuid.UUID
if err := rows.Scan(&fid); err != nil {
rows.Close()
return fmt.Errorf("PoolRepo.Reorder scan: %w", err)
}
current = append(current, fid)
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("PoolRepo.Reorder rows: %w", err)
}
inPool := make(map[uuid.UUID]bool, len(current))
for _, fid := range current {
inPool[fid] = true
}
ordered := make([]uuid.UUID, 0, len(current))
placed := make(map[uuid.UUID]bool, len(current))
for _, fid := range fileIDs {
if inPool[fid] && !placed[fid] {
ordered = append(ordered, fid)
placed[fid] = true
}
}
// Preserve any members the request did not mention.
for _, fid := range current {
if !placed[fid] {
ordered = append(ordered, fid)
placed[fid] = true
}
}
return r.reassignPositions(ctx, q, poolID, ordered)
}
+111
View File
@@ -0,0 +1,111 @@
// Package postgres provides the PostgreSQL implementations of the port interfaces.
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/backend/internal/db"
)
// appName tags every connection as application_name, so the backend's sessions
// are identifiable in pg_stat_activity and server logs (and distinguishable from
// e.g. goose migrations or a psql shell).
const appName = "tanabata-backend"
// NewPool creates and validates a *pgxpool.Pool from the given connection URL.
// The pool is ready to use; the caller is responsible for closing it.
func NewPool(ctx context.Context, url string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(url)
if err != nil {
return nil, fmt.Errorf("pgxpool.ParseConfig: %w", err)
}
// Set application_name unless the operator already specified one in the DSN
// (or via PGAPPNAME), so an explicit override still wins.
if cfg.ConnConfig.RuntimeParams == nil {
cfg.ConnConfig.RuntimeParams = map[string]string{}
}
if cfg.ConnConfig.RuntimeParams["application_name"] == "" {
cfg.ConnConfig.RuntimeParams["application_name"] = appName
}
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("pgxpool.NewWithConfig: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("postgres ping: %w", err)
}
return pool, nil
}
// Transactor implements port.Transactor using a pgxpool.Pool.
type Transactor struct {
pool *pgxpool.Pool
}
// NewTransactor creates a Transactor backed by pool.
func NewTransactor(pool *pgxpool.Pool) *Transactor {
return &Transactor{pool: pool}
}
// WithTx begins a transaction, stores it in ctx, and calls fn. If fn returns
// an error the transaction is rolled back; otherwise it is committed.
func (t *Transactor) WithTx(ctx context.Context, fn func(ctx context.Context) error) error {
tx, err := t.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
txCtx := db.ContextWithTx(ctx, tx)
if err := fn(txCtx); err != nil {
_ = tx.Rollback(ctx)
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
// connOrTx returns the pgx.Tx stored in ctx by WithTx, or the pool itself when
// no transaction is active. The returned value satisfies db.Querier and can be
// used directly for queries and commands.
func connOrTx(ctx context.Context, pool *pgxpool.Pool) db.Querier {
if tx, ok := db.TxFromContext(ctx); ok {
return tx
}
return pool
}
// Object type IDs as seeded in core.object_types (007_seed_data.sql).
const (
objTypeFile int16 = 1
objTypeTag int16 = 2
objTypeCategory int16 = 3
objTypePool int16 = 4
)
// aclVisibilityCond returns a SQL boolean fragment that is true when the viewer
// may see the row at <alias>.id of the given object type under the
// private-by-default model: the row is public, the viewer created it, or the
// viewer holds an explicit can_view grant. objectTypeID is a trusted constant
// and is inlined; viewerID is bound as $n (referenced twice). Returns the
// fragment, the next free parameter index, and the extended args.
//
// Callers skip this entirely for admins (who bypass ACL).
func aclVisibilityCond(alias string, objectTypeID int16, viewerID int16, n int, args []any) (string, int, []any) {
cond := fmt.Sprintf(
"(%[1]s.is_public OR %[1]s.creator_id = $%[2]d OR EXISTS ("+
"SELECT 1 FROM acl.permissions p "+
"WHERE p.object_type_id = %[3]d AND p.object_id = %[1]s.id "+
"AND p.user_id = $%[2]d AND p.can_view))",
alias, n, objectTypeID)
return cond, n + 1, append(args, viewerID)
}
@@ -0,0 +1,184 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// sessionRow matches the columns stored in activity.sessions.
// IsCurrent is a service-layer concern and is not stored in the database.
type sessionRow struct {
ID int `db:"id"`
TokenHash string `db:"token_hash"`
UserID int16 `db:"user_id"`
UserAgent string `db:"user_agent"`
StartedAt time.Time `db:"started_at"`
ExpiresAt *time.Time `db:"expires_at"`
LastActivity time.Time `db:"last_activity"`
}
// sessionRowWithTotal extends sessionRow with a window-function count for ListByUser.
type sessionRowWithTotal struct {
sessionRow
Total int `db:"total"`
}
func toSession(r sessionRow) domain.Session {
return domain.Session{
ID: r.ID,
TokenHash: r.TokenHash,
UserID: r.UserID,
UserAgent: r.UserAgent,
StartedAt: r.StartedAt,
ExpiresAt: r.ExpiresAt,
LastActivity: r.LastActivity,
}
}
// SessionRepo implements port.SessionRepo using PostgreSQL.
type SessionRepo struct {
pool *pgxpool.Pool
}
// NewSessionRepo creates a SessionRepo backed by pool.
func NewSessionRepo(pool *pgxpool.Pool) *SessionRepo {
return &SessionRepo{pool: pool}
}
var _ port.SessionRepo = (*SessionRepo)(nil)
func (r *SessionRepo) Create(ctx context.Context, s *domain.Session) (*domain.Session, error) {
const sql = `
INSERT INTO activity.sessions (token_hash, user_id, user_agent, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, token_hash, user_id, user_agent, started_at, expires_at, last_activity`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, s.TokenHash, s.UserID, s.UserAgent, s.ExpiresAt)
if err != nil {
return nil, fmt.Errorf("SessionRepo.Create: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
if err != nil {
return nil, fmt.Errorf("SessionRepo.Create scan: %w", err)
}
created := toSession(row)
return &created, nil
}
func (r *SessionRepo) GetByID(ctx context.Context, id int) (*domain.Session, error) {
const sql = `
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity
FROM activity.sessions
WHERE id = $1 AND is_active = true`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, id)
if err != nil {
return nil, fmt.Errorf("SessionRepo.GetByID: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("SessionRepo.GetByID scan: %w", err)
}
s := toSession(row)
return &s, nil
}
func (r *SessionRepo) GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error) {
const sql = `
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity
FROM activity.sessions
WHERE token_hash = $1 AND is_active = true`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, hash)
if err != nil {
return nil, fmt.Errorf("SessionRepo.GetByTokenHash: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("SessionRepo.GetByTokenHash scan: %w", err)
}
s := toSession(row)
return &s, nil
}
func (r *SessionRepo) ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error) {
const sql = `
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity,
COUNT(*) OVER() AS total
FROM activity.sessions
WHERE user_id = $1 AND is_active = true
ORDER BY started_at DESC`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, userID)
if err != nil {
return nil, fmt.Errorf("SessionRepo.ListByUser: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[sessionRowWithTotal])
if err != nil {
return nil, fmt.Errorf("SessionRepo.ListByUser scan: %w", err)
}
list := &domain.SessionList{}
if len(collected) > 0 {
list.Total = collected[0].Total
}
list.Items = make([]domain.Session, len(collected))
for i, row := range collected {
list.Items[i] = toSession(row.sessionRow)
}
return list, nil
}
func (r *SessionRepo) UpdateLastActivity(ctx context.Context, id int, t time.Time) error {
const sql = `UPDATE activity.sessions SET last_activity = $2 WHERE id = $1`
q := connOrTx(ctx, r.pool)
tag, err := q.Exec(ctx, sql, id, t)
if err != nil {
return fmt.Errorf("SessionRepo.UpdateLastActivity: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
func (r *SessionRepo) Delete(ctx context.Context, id int) error {
const sql = `UPDATE activity.sessions SET is_active = false WHERE id = $1 AND is_active = true`
q := connOrTx(ctx, r.pool)
tag, err := q.Exec(ctx, sql, id)
if err != nil {
return fmt.Errorf("SessionRepo.Delete: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
func (r *SessionRepo) DeleteByUserID(ctx context.Context, userID int16) error {
const sql = `UPDATE activity.sessions SET is_active = false WHERE user_id = $1 AND is_active = true`
q := connOrTx(ctx, r.pool)
_, err := q.Exec(ctx, sql, userID)
if err != nil {
return fmt.Errorf("SessionRepo.DeleteByUserID: %w", err)
}
return nil
}
+652
View File
@@ -0,0 +1,652 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// ---------------------------------------------------------------------------
// Row structs — use pgx-scannable types
// ---------------------------------------------------------------------------
type tagRow struct {
ID uuid.UUID `db:"id"`
Name string `db:"name"`
Notes *string `db:"notes"`
Color *string `db:"color"`
CategoryID *uuid.UUID `db:"category_id"`
CategoryName *string `db:"category_name"`
CategoryColor *string `db:"category_color"`
Metadata []byte `db:"metadata"`
CreatorID int16 `db:"creator_id"`
CreatorName string `db:"creator_name"`
IsPublic bool `db:"is_public"`
}
type tagRowWithTotal struct {
tagRow
Total int `db:"total"`
}
type tagRuleRow struct {
WhenTagID uuid.UUID `db:"when_tag_id"`
ThenTagID uuid.UUID `db:"then_tag_id"`
ThenTagName string `db:"then_tag_name"`
IsActive bool `db:"is_active"`
}
// ---------------------------------------------------------------------------
// Converters
// ---------------------------------------------------------------------------
func toTag(r tagRow) domain.Tag {
t := domain.Tag{
ID: r.ID,
Name: r.Name,
Notes: r.Notes,
Color: r.Color,
CategoryID: r.CategoryID,
CategoryName: r.CategoryName,
CategoryColor: r.CategoryColor,
CreatorID: r.CreatorID,
CreatorName: r.CreatorName,
IsPublic: r.IsPublic,
CreatedAt: domain.UUIDCreatedAt(r.ID),
}
if len(r.Metadata) > 0 && string(r.Metadata) != "null" {
t.Metadata = json.RawMessage(r.Metadata)
}
return t
}
func toTagRule(r tagRuleRow) domain.TagRule {
return domain.TagRule{
WhenTagID: r.WhenTagID,
ThenTagID: r.ThenTagID,
ThenTagName: r.ThenTagName,
IsActive: r.IsActive,
}
}
// ---------------------------------------------------------------------------
// Shared SQL fragments
// ---------------------------------------------------------------------------
const tagSelectFrom = `
SELECT
t.id,
t.name,
t.notes,
t.color,
t.category_id,
c.name AS category_name,
c.color AS category_color,
t.metadata,
t.creator_id,
u.name AS creator_name,
t.is_public
FROM data.tags t
LEFT JOIN data.categories c ON c.id = t.category_id
JOIN core.users u ON u.id = t.creator_id`
func tagSortColumn(s string) string {
switch s {
case "name":
return "t.name"
case "color":
return "t.color"
case "category_name":
return "c.name"
default: // "created"
return "t.id"
}
}
// isPgUniqueViolation reports whether err is a PostgreSQL unique-constraint error.
func isPgUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
// ---------------------------------------------------------------------------
// TagRepo — implements port.TagRepo
// ---------------------------------------------------------------------------
// TagRepo handles tag CRUD and filetag relations.
type TagRepo struct {
pool *pgxpool.Pool
}
var _ port.TagRepo = (*TagRepo)(nil)
// NewTagRepo creates a TagRepo backed by pool.
func NewTagRepo(pool *pgxpool.Pool) *TagRepo {
return &TagRepo{pool: pool}
}
// ---------------------------------------------------------------------------
// List / ListByCategory
// ---------------------------------------------------------------------------
func (r *TagRepo) List(ctx context.Context, params port.OffsetParams) (*domain.TagOffsetPage, error) {
return r.listTags(ctx, params, nil)
}
func (r *TagRepo) ListByCategory(ctx context.Context, categoryID uuid.UUID, params port.OffsetParams) (*domain.TagOffsetPage, error) {
return r.listTags(ctx, params, &categoryID)
}
func (r *TagRepo) listTags(ctx context.Context, params port.OffsetParams, categoryID *uuid.UUID) (*domain.TagOffsetPage, error) {
order := "ASC"
if strings.ToLower(params.Order) == "desc" {
order = "DESC"
}
sortCol := tagSortColumn(params.Sort)
// When sorting by category, break ties within a category by the tag's own
// name (same direction), so tags are grouped by category then alphabetical.
secondarySort := ""
if params.Sort == "category_name" {
secondarySort = fmt.Sprintf("t.name %s, ", order)
}
args := []any{}
n := 1
var conditions []string
if params.Search != "" {
conditions = append(conditions, fmt.Sprintf("lower(t.name) LIKE lower($%d)", n))
args = append(args, "%"+params.Search+"%")
n++
}
if categoryID != nil {
conditions = append(conditions, fmt.Sprintf("t.category_id = $%d", n))
args = append(args, *categoryID)
n++
}
// Restrict to tags the viewer may see (private-by-default), unless admin.
if !params.ViewerIsAdmin {
var aclCond string
aclCond, n, args = aclVisibilityCond("t", objTypeTag, params.ViewerID, n, args)
conditions = append(conditions, aclCond)
}
where := ""
if len(conditions) > 0 {
where = "WHERE " + strings.Join(conditions, " AND ")
}
limit := params.Limit
if limit <= 0 {
limit = 50
}
offset := params.Offset
if offset < 0 {
offset = 0
}
query := fmt.Sprintf(`
SELECT
t.id, t.name, t.notes, t.color,
t.category_id,
c.name AS category_name,
c.color AS category_color,
t.metadata, t.creator_id,
u.name AS creator_name,
t.is_public,
COUNT(*) OVER() AS total
FROM data.tags t
LEFT JOIN data.categories c ON c.id = t.category_id
JOIN core.users u ON u.id = t.creator_id
%s
ORDER BY %s %s NULLS LAST, %st.id ASC
LIMIT $%d OFFSET $%d`, where, sortCol, order, secondarySort, n, n+1)
args = append(args, limit, offset)
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("TagRepo.List query: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[tagRowWithTotal])
if err != nil {
return nil, fmt.Errorf("TagRepo.List scan: %w", err)
}
items := make([]domain.Tag, len(collected))
total := 0
for i, row := range collected {
items[i] = toTag(row.tagRow)
total = row.Total
}
return &domain.TagOffsetPage{
Items: items,
Total: total,
Offset: offset,
Limit: limit,
}, nil
}
// ---------------------------------------------------------------------------
// GetByID
// ---------------------------------------------------------------------------
func (r *TagRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Tag, error) {
const query = tagSelectFrom + `
WHERE t.id = $1`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, id)
if err != nil {
return nil, fmt.Errorf("TagRepo.GetByID: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[tagRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("TagRepo.GetByID scan: %w", err)
}
t := toTag(row)
return &t, nil
}
// ---------------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------------
func (r *TagRepo) Create(ctx context.Context, t *domain.Tag) (*domain.Tag, error) {
const query = `
WITH ins AS (
INSERT INTO data.tags (name, notes, color, category_id, metadata, creator_id, is_public)
VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7)
RETURNING *
)
SELECT
ins.id, ins.name, ins.notes, ins.color,
ins.category_id,
c.name AS category_name,
c.color AS category_color,
ins.metadata, ins.creator_id,
u.name AS creator_name,
ins.is_public
FROM ins
LEFT JOIN data.categories c ON c.id = ins.category_id
JOIN core.users u ON u.id = ins.creator_id`
var meta any
if len(t.Metadata) > 0 {
meta = t.Metadata
}
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query,
t.Name, t.Notes, t.Color, t.CategoryID, meta, t.CreatorID, t.IsPublic)
if err != nil {
return nil, fmt.Errorf("TagRepo.Create: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[tagRow])
if err != nil {
if isPgUniqueViolation(err) {
return nil, domain.ErrConflict
}
return nil, fmt.Errorf("TagRepo.Create scan: %w", err)
}
created := toTag(row)
return &created, nil
}
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
// Update replaces all mutable fields. The caller must merge current values with
// the patch (read-then-write) before calling this.
func (r *TagRepo) Update(ctx context.Context, id uuid.UUID, t *domain.Tag) (*domain.Tag, error) {
const query = `
WITH upd AS (
UPDATE data.tags SET
name = $2,
notes = $3,
color = NULLIF($4, ''),
category_id = $5,
metadata = COALESCE($6, metadata),
is_public = $7
WHERE id = $1
RETURNING *
)
SELECT
upd.id, upd.name, upd.notes, upd.color,
upd.category_id,
c.name AS category_name,
c.color AS category_color,
upd.metadata, upd.creator_id,
u.name AS creator_name,
upd.is_public
FROM upd
LEFT JOIN data.categories c ON c.id = upd.category_id
JOIN core.users u ON u.id = upd.creator_id`
var meta any
if len(t.Metadata) > 0 {
meta = t.Metadata
}
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query,
id, t.Name, t.Notes, t.Color, t.CategoryID, meta, t.IsPublic)
if err != nil {
return nil, fmt.Errorf("TagRepo.Update: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[tagRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
if isPgUniqueViolation(err) {
return nil, domain.ErrConflict
}
return nil, fmt.Errorf("TagRepo.Update scan: %w", err)
}
updated := toTag(row)
return &updated, nil
}
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
func (r *TagRepo) Delete(ctx context.Context, id uuid.UUID) error {
const query = `DELETE FROM data.tags WHERE id = $1`
q := connOrTx(ctx, r.pool)
ct, err := q.Exec(ctx, query, id)
if err != nil {
return fmt.Errorf("TagRepo.Delete: %w", err)
}
if ct.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
// ---------------------------------------------------------------------------
// Filetag operations
// ---------------------------------------------------------------------------
func (r *TagRepo) ListByFile(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
const query = tagSelectFrom + `
JOIN data.file_tag ft ON ft.tag_id = t.id
WHERE ft.file_id = $1
ORDER BY t.name`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, fileID)
if err != nil {
return nil, fmt.Errorf("TagRepo.ListByFile: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[tagRow])
if err != nil {
return nil, fmt.Errorf("TagRepo.ListByFile scan: %w", err)
}
tags := make([]domain.Tag, len(collected))
for i, row := range collected {
tags[i] = toTag(row)
}
return tags, nil
}
func (r *TagRepo) AddFileTag(ctx context.Context, fileID, tagID uuid.UUID) error {
const query = `
INSERT INTO data.file_tag (file_id, tag_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`
q := connOrTx(ctx, r.pool)
if _, err := q.Exec(ctx, query, fileID, tagID); err != nil {
return fmt.Errorf("TagRepo.AddFileTag: %w", err)
}
return nil
}
func (r *TagRepo) RemoveFileTag(ctx context.Context, fileID, tagID uuid.UUID) error {
const query = `DELETE FROM data.file_tag WHERE file_id = $1 AND tag_id = $2`
q := connOrTx(ctx, r.pool)
if _, err := q.Exec(ctx, query, fileID, tagID); err != nil {
return fmt.Errorf("TagRepo.RemoveFileTag: %w", err)
}
return nil
}
func (r *TagRepo) SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error {
q := connOrTx(ctx, r.pool)
if _, err := q.Exec(ctx,
`DELETE FROM data.file_tag WHERE file_id = $1`, fileID); err != nil {
return fmt.Errorf("TagRepo.SetFileTags delete: %w", err)
}
if len(tagIDs) == 0 {
return nil
}
placeholders := make([]string, len(tagIDs))
args := []any{fileID}
for i, tagID := range tagIDs {
placeholders[i] = fmt.Sprintf("($1, $%d)", i+2)
args = append(args, tagID)
}
ins := `INSERT INTO data.file_tag (file_id, tag_id) VALUES ` +
strings.Join(placeholders, ", ") + ` ON CONFLICT DO NOTHING`
if _, err := q.Exec(ctx, ins, args...); err != nil {
return fmt.Errorf("TagRepo.SetFileTags insert: %w", err)
}
return nil
}
func (r *TagRepo) CommonTagsForFiles(ctx context.Context, fileIDs []uuid.UUID) ([]domain.Tag, error) {
if len(fileIDs) == 0 {
return []domain.Tag{}, nil
}
return r.queryTagsByPresence(ctx, fileIDs, "=")
}
func (r *TagRepo) PartialTagsForFiles(ctx context.Context, fileIDs []uuid.UUID) ([]domain.Tag, error) {
if len(fileIDs) == 0 {
return []domain.Tag{}, nil
}
return r.queryTagsByPresence(ctx, fileIDs, "<")
}
func (r *TagRepo) queryTagsByPresence(ctx context.Context, fileIDs []uuid.UUID, op string) ([]domain.Tag, error) {
placeholders := make([]string, len(fileIDs))
args := make([]any, len(fileIDs)+1)
for i, id := range fileIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
args[len(fileIDs)] = len(fileIDs)
n := len(fileIDs) + 1
query := fmt.Sprintf(`
SELECT
t.id, t.name, t.notes, t.color,
t.category_id,
c.name AS category_name,
c.color AS category_color,
t.metadata, t.creator_id,
u.name AS creator_name,
t.is_public
FROM data.tags t
JOIN data.file_tag ft ON ft.tag_id = t.id
LEFT JOIN data.categories c ON c.id = t.category_id
JOIN core.users u ON u.id = t.creator_id
WHERE ft.file_id IN (%s)
GROUP BY t.id, c.id, u.id
HAVING COUNT(DISTINCT ft.file_id) %s $%d
ORDER BY t.name`,
strings.Join(placeholders, ", "), op, n)
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("TagRepo.queryTagsByPresence: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[tagRow])
if err != nil {
return nil, fmt.Errorf("TagRepo.queryTagsByPresence scan: %w", err)
}
tags := make([]domain.Tag, len(collected))
for i, row := range collected {
tags[i] = toTag(row)
}
return tags, nil
}
// ---------------------------------------------------------------------------
// TagRuleRepo — implements port.TagRuleRepo (separate type to avoid method collision)
// ---------------------------------------------------------------------------
// TagRuleRepo handles tag-rule CRUD.
type TagRuleRepo struct {
pool *pgxpool.Pool
}
var _ port.TagRuleRepo = (*TagRuleRepo)(nil)
// NewTagRuleRepo creates a TagRuleRepo backed by pool.
func NewTagRuleRepo(pool *pgxpool.Pool) *TagRuleRepo {
return &TagRuleRepo{pool: pool}
}
func (r *TagRuleRepo) ListByTag(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error) {
const query = `
SELECT
tr.when_tag_id,
tr.then_tag_id,
t.name AS then_tag_name,
tr.is_active
FROM data.tag_rules tr
JOIN data.tags t ON t.id = tr.then_tag_id
WHERE tr.when_tag_id = $1
ORDER BY t.name`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, tagID)
if err != nil {
return nil, fmt.Errorf("TagRuleRepo.ListByTag: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[tagRuleRow])
if err != nil {
return nil, fmt.Errorf("TagRuleRepo.ListByTag scan: %w", err)
}
rules := make([]domain.TagRule, len(collected))
for i, row := range collected {
rules[i] = toTagRule(row)
}
return rules, nil
}
func (r *TagRuleRepo) Create(ctx context.Context, rule domain.TagRule) (*domain.TagRule, error) {
const query = `
WITH ins AS (
INSERT INTO data.tag_rules (when_tag_id, then_tag_id, is_active)
VALUES ($1, $2, $3)
RETURNING *
)
SELECT ins.when_tag_id, ins.then_tag_id, t.name AS then_tag_name, ins.is_active
FROM ins
JOIN data.tags t ON t.id = ins.then_tag_id`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, rule.WhenTagID, rule.ThenTagID, rule.IsActive)
if err != nil {
return nil, fmt.Errorf("TagRuleRepo.Create: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[tagRuleRow])
if err != nil {
if isPgUniqueViolation(err) {
return nil, domain.ErrConflict
}
return nil, fmt.Errorf("TagRuleRepo.Create scan: %w", err)
}
result := toTagRule(row)
return &result, nil
}
func (r *TagRuleRepo) SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active, applyToExisting bool) error {
const updateQuery = `
UPDATE data.tag_rules SET is_active = $3
WHERE when_tag_id = $1 AND then_tag_id = $2`
q := connOrTx(ctx, r.pool)
ct, err := q.Exec(ctx, updateQuery, whenTagID, thenTagID, active)
if err != nil {
return fmt.Errorf("TagRuleRepo.SetActive: %w", err)
}
if ct.RowsAffected() == 0 {
return domain.ErrNotFound
}
if !active || !applyToExisting {
return nil
}
return r.ApplyToExisting(ctx, whenTagID, thenTagID)
}
// ApplyToExisting retroactively applies the full transitive expansion of
// thenTagID to all files that already carry whenTagID. The recursive CTE walks
// active rules starting from thenTagID (mirrors the Go expandTagSet BFS), so
// inactive downstream rules are not followed. Idempotent via ON CONFLICT.
func (r *TagRuleRepo) ApplyToExisting(ctx context.Context, whenTagID, thenTagID uuid.UUID) error {
const retroQuery = `
WITH RECURSIVE expansion(tag_id) AS (
SELECT $2::uuid
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 (file_id, tag_id)
SELECT ft.file_id, e.tag_id
FROM data.file_tag ft
CROSS JOIN expansion e
WHERE ft.tag_id = $1
ON CONFLICT DO NOTHING`
q := connOrTx(ctx, r.pool)
if _, err := q.Exec(ctx, retroQuery, whenTagID, thenTagID); err != nil {
return fmt.Errorf("TagRuleRepo.ApplyToExisting: %w", err)
}
return nil
}
func (r *TagRuleRepo) Delete(ctx context.Context, whenTagID, thenTagID uuid.UUID) error {
const query = `
DELETE FROM data.tag_rules
WHERE when_tag_id = $1 AND then_tag_id = $2`
q := connOrTx(ctx, r.pool)
ct, err := q.Exec(ctx, query, whenTagID, thenTagID)
if err != nil {
return fmt.Errorf("TagRuleRepo.Delete: %w", err)
}
if ct.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
+196
View File
@@ -0,0 +1,196 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/backend/internal/db"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// userRow matches the columns returned by every user SELECT.
type userRow struct {
ID int16 `db:"id"`
Name string `db:"name"`
Password string `db:"password"`
IsAdmin bool `db:"is_admin"`
CanCreate bool `db:"can_create"`
IsBlocked bool `db:"is_blocked"`
}
// userRowWithTotal extends userRow with a window-function total for List.
type userRowWithTotal struct {
userRow
Total int `db:"total"`
}
func toUser(r userRow) domain.User {
return domain.User{
ID: r.ID,
Name: r.Name,
Password: r.Password,
IsAdmin: r.IsAdmin,
CanCreate: r.CanCreate,
IsBlocked: r.IsBlocked,
}
}
// userSortColumn whitelists valid sort keys to prevent SQL injection.
var userSortColumn = map[string]string{
"name": "name",
"id": "id",
}
// UserRepo implements port.UserRepo using PostgreSQL.
type UserRepo struct {
pool *pgxpool.Pool
}
// NewUserRepo creates a UserRepo backed by pool.
func NewUserRepo(pool *pgxpool.Pool) *UserRepo {
return &UserRepo{pool: pool}
}
var _ port.UserRepo = (*UserRepo)(nil)
func (r *UserRepo) List(ctx context.Context, params port.OffsetParams) (*domain.UserPage, error) {
col, ok := userSortColumn[params.Sort]
if !ok {
col = "id"
}
ord := "ASC"
if params.Order == "desc" {
ord = "DESC"
}
limit := db.ClampLimit(params.Limit, 50, 200)
offset := db.ClampOffset(params.Offset)
sql := fmt.Sprintf(`
SELECT id, name, password, is_admin, can_create, is_blocked,
COUNT(*) OVER() AS total
FROM core.users
ORDER BY %s %s
LIMIT $1 OFFSET $2`, col, ord)
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, limit, offset)
if err != nil {
return nil, fmt.Errorf("UserRepo.List: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[userRowWithTotal])
if err != nil {
return nil, fmt.Errorf("UserRepo.List scan: %w", err)
}
page := &domain.UserPage{Offset: offset, Limit: limit}
if len(collected) > 0 {
page.Total = collected[0].Total
}
page.Items = make([]domain.User, len(collected))
for i, row := range collected {
page.Items[i] = toUser(row.userRow)
}
return page, nil
}
func (r *UserRepo) GetByID(ctx context.Context, id int16) (*domain.User, error) {
const sql = `
SELECT id, name, password, is_admin, can_create, is_blocked
FROM core.users WHERE id = $1`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, id)
if err != nil {
return nil, fmt.Errorf("UserRepo.GetByID: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("UserRepo.GetByID scan: %w", err)
}
u := toUser(row)
return &u, nil
}
func (r *UserRepo) GetByName(ctx context.Context, name string) (*domain.User, error) {
const sql = `
SELECT id, name, password, is_admin, can_create, is_blocked
FROM core.users WHERE name = $1`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, name)
if err != nil {
return nil, fmt.Errorf("UserRepo.GetByName: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("UserRepo.GetByName scan: %w", err)
}
u := toUser(row)
return &u, nil
}
func (r *UserRepo) Create(ctx context.Context, u *domain.User) (*domain.User, error) {
const sql = `
INSERT INTO core.users (name, password, is_admin, can_create)
VALUES ($1, $2, $3, $4)
RETURNING id, name, password, is_admin, can_create, is_blocked`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, u.Name, u.Password, u.IsAdmin, u.CanCreate)
if err != nil {
return nil, fmt.Errorf("UserRepo.Create: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
if err != nil {
return nil, fmt.Errorf("UserRepo.Create scan: %w", err)
}
created := toUser(row)
return &created, nil
}
func (r *UserRepo) Update(ctx context.Context, id int16, u *domain.User) (*domain.User, error) {
const sql = `
UPDATE core.users
SET name = $2, password = $3, is_admin = $4, can_create = $5, is_blocked = $6
WHERE id = $1
RETURNING id, name, password, is_admin, can_create, is_blocked`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sql, id, u.Name, u.Password, u.IsAdmin, u.CanCreate, u.IsBlocked)
if err != nil {
return nil, fmt.Errorf("UserRepo.Update: %w", err)
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("UserRepo.Update scan: %w", err)
}
updated := toUser(row)
return &updated, nil
}
func (r *UserRepo) Delete(ctx context.Context, id int16) error {
const sql = `DELETE FROM core.users WHERE id = $1`
q := connOrTx(ctx, r.pool)
tag, err := q.Exec(ctx, sql, id)
if err != nil {
return fmt.Errorf("UserRepo.Delete: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
package domain
import "github.com/google/uuid"
// ObjectType is a reference entity (file, tag, category, pool).
type ObjectType struct {
ID int16
Name string
}
// Permission represents a per-object access entry for a user.
type Permission struct {
UserID int16
UserName string // denormalized
ObjectTypeID int16
ObjectID uuid.UUID
CanView bool
CanEdit bool
}
+46
View File
@@ -0,0 +1,46 @@
package domain
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// ActionType is a reference entity for auditable user actions.
type ActionType struct {
ID int16
Name string
}
// AuditEntry is a single audit log record.
type AuditEntry struct {
ID int64
UserID int16
UserName string // denormalized
Action string // action type name, e.g. "file_create"
ObjectType *string
ObjectID *uuid.UUID
Details json.RawMessage
PerformedAt time.Time
}
// AuditPage is an offset-based page of audit log entries.
type AuditPage struct {
Items []AuditEntry
Total int
Offset int
Limit int
}
// AuditFilter holds filter parameters for querying the audit log.
type AuditFilter struct {
UserID *int16
Action string
ObjectType string
ObjectID *uuid.UUID
From *time.Time
To *time.Time
Offset int
Limit int
}
+29
View File
@@ -0,0 +1,29 @@
package domain
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// Category is a logical grouping of tags.
type Category struct {
ID uuid.UUID
Name string
Notes *string
Color *string // 6-char hex
Metadata json.RawMessage
CreatorID int16
CreatorName string // denormalized
IsPublic bool
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
}
// CategoryOffsetPage is an offset-based page of categories.
type CategoryOffsetPage struct {
Items []Category
Total int
Offset int
Limit int
}
+32
View File
@@ -0,0 +1,32 @@
package domain
import "context"
type ctxKey int
const userKey ctxKey = iota
type contextUser struct {
ID int16
IsAdmin bool
SessionID int
}
// WithUser stores user identity and current session ID in ctx.
func WithUser(ctx context.Context, userID int16, isAdmin bool, sessionID int) context.Context {
return context.WithValue(ctx, userKey, contextUser{
ID: userID,
IsAdmin: isAdmin,
SessionID: sessionID,
})
}
// UserFromContext retrieves user identity from ctx.
// Returns zero values if no user is stored.
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool, sessionID int) {
u, ok := ctx.Value(userKey).(contextUser)
if !ok {
return 0, false, 0
}
return u.ID, u.IsAdmin, u.SessionID
}
+18
View File
@@ -0,0 +1,18 @@
package domain
import "github.com/google/uuid"
// PHashEntry is a file's perceptual hash, the input to duplicate clustering.
type PHashEntry struct {
ID uuid.UUID
PHash int64
}
// DuplicatePair is an unordered pair of files whose perceptual hashes are within
// the configured Hamming threshold. FileA < FileB by UUID byte order (canonical),
// so a pair is represented exactly once.
type DuplicatePair struct {
FileA uuid.UUID
FileB uuid.UUID
Distance int
}
+21
View File
@@ -0,0 +1,21 @@
package domain
// DomainError is a typed domain error with a stable machine-readable code.
// Handlers map these codes to HTTP status codes.
type DomainError struct {
code string
message string
}
func (e *DomainError) Error() string { return e.message }
func (e *DomainError) Code() string { return e.code }
// Sentinel domain errors. Use errors.Is(err, domain.ErrNotFound) for matching.
var (
ErrNotFound = &DomainError{"not_found", "not found"}
ErrForbidden = &DomainError{"forbidden", "forbidden"}
ErrUnauthorized = &DomainError{"unauthorized", "unauthorized"}
ErrConflict = &DomainError{"conflict", "conflict"}
ErrValidation = &DomainError{"validation_error", "validation error"}
ErrUnsupportedMIME = &DomainError{"unsupported_mime", "unsupported MIME type"}
)
+74
View File
@@ -0,0 +1,74 @@
package domain
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// MIMEType holds MIME whitelist data.
type MIMEType struct {
ID int16
Name string
Extension string
}
// File represents a managed file record.
type File struct {
ID uuid.UUID
OriginalName *string
MIMEType string // denormalized from core.mime_types
MIMEExtension string // denormalized from core.mime_types
ContentDatetime time.Time
Notes *string
Metadata json.RawMessage
EXIF json.RawMessage
PHash *int64
CreatorID int16
CreatorName string // denormalized from core.users
IsPublic bool
IsDeleted bool
NeedsReview bool // tagging not yet marked done; cleared by an explicit review action
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
Tags []Tag // loaded with the file
}
// FileListParams holds all parameters for listing/filtering files.
type FileListParams struct {
// Pagination
Cursor string
Direction string // "forward" or "backward"
Anchor *uuid.UUID
Limit int
// Sorting
Sort string // "content_datetime" | "created" | "original_name" | "mime"
Order string // "asc" | "desc"
// Filtering
Filter string // filter DSL expression
Search string // substring match on original_name
Trash bool // if true, return only soft-deleted files
// Visibility — populated by the service from the request context. When
// ViewerIsAdmin is false the repository restricts results to files the
// viewer may see (public, owned, or explicitly granted).
ViewerID int16
ViewerIsAdmin bool
}
// FilePage is the result of a cursor-based file listing.
type FilePage struct {
Items []File
NextCursor *string
PrevCursor *string
}
// UUIDCreatedAt extracts the creation timestamp embedded in a UUID v7.
// UUID v7 stores Unix milliseconds in the most-significant 48 bits.
func UUIDCreatedAt(id uuid.UUID) time.Time {
ms := int64(id[0])<<40 | int64(id[1])<<32 | int64(id[2])<<24 |
int64(id[3])<<16 | int64(id[4])<<8 | int64(id[5])
return time.Unix(ms/1000, (ms%1000)*int64(time.Millisecond)).UTC()
}
+41
View File
@@ -0,0 +1,41 @@
package domain
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// Pool is an ordered collection of files.
type Pool struct {
ID uuid.UUID
Name string
Notes *string
Metadata json.RawMessage
CreatorID int16
CreatorName string // denormalized
IsPublic bool
FileCount int
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
}
// PoolFile is a File with its ordering position within a pool.
type PoolFile struct {
File
Position int
}
// PoolFilePage is the result of a cursor-based pool file listing.
type PoolFilePage struct {
Items []PoolFile
NextCursor *string
}
// PoolOffsetPage is an offset-based page of pools.
type PoolOffsetPage struct {
Items []Pool
Total int
Offset int
Limit int
}
+41
View File
@@ -0,0 +1,41 @@
package domain
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// Tag represents a file label.
type Tag struct {
ID uuid.UUID
Name string
Notes *string
Color *string // 6-char hex, e.g. "5DCAA5"
CategoryID *uuid.UUID
CategoryName *string // denormalized
CategoryColor *string // denormalized
Metadata json.RawMessage
CreatorID int16
CreatorName string // denormalized
IsPublic bool
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
}
// TagRule defines an auto-tagging rule: when WhenTagID is applied,
// ThenTagID is automatically applied as well.
type TagRule struct {
WhenTagID uuid.UUID
ThenTagID uuid.UUID
ThenTagName string // denormalized
IsActive bool
}
// TagOffsetPage is an offset-based page of tags.
type TagOffsetPage struct {
Items []Tag
Total int
Offset int
Limit int
}
+39
View File
@@ -0,0 +1,39 @@
package domain
import "time"
// User is an application user.
type User struct {
ID int16
Name string
Password string // bcrypt hash; only populated when needed for auth
IsAdmin bool
CanCreate bool
IsBlocked bool
}
// Session is an active user session.
type Session struct {
ID int
TokenHash string
UserID int16
UserAgent string
StartedAt time.Time
ExpiresAt *time.Time
LastActivity time.Time
IsCurrent bool // true when this session matches the caller's token
}
// UserPage is an offset-based page of users.
type UserPage struct {
Items []User
Total int
Offset int
Limit int
}
// SessionList is a list of sessions with a total count.
type SessionList struct {
Items []Session
Total int
}
+146
View File
@@ -0,0 +1,146 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/service"
)
// objectTypeIDs maps the URL segment to the object_type PK in core.object_types.
// Row order matches 007_seed_data.sql: file=1, tag=2, category=3, pool=4.
var objectTypeIDs = map[string]int16{
"file": 1,
"tag": 2,
"category": 3,
"pool": 4,
}
// ACLHandler handles GET/PUT /acl/:object_type/:object_id.
type ACLHandler struct {
aclSvc *service.ACLService
}
// NewACLHandler creates an ACLHandler.
func NewACLHandler(aclSvc *service.ACLService) *ACLHandler {
return &ACLHandler{aclSvc: aclSvc}
}
// ---------------------------------------------------------------------------
// Response type
// ---------------------------------------------------------------------------
type permissionJSON struct {
UserID int16 `json:"user_id"`
UserName string `json:"user_name"`
CanView bool `json:"can_view"`
CanEdit bool `json:"can_edit"`
}
func toPermissionJSON(p domain.Permission) permissionJSON {
return permissionJSON{
UserID: p.UserID,
UserName: p.UserName,
CanView: p.CanView,
CanEdit: p.CanEdit,
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func parseACLPath(c *gin.Context) (objectTypeID int16, objectID uuid.UUID, ok bool) {
typeStr := c.Param("object_type")
id, exists := objectTypeIDs[typeStr]
if !exists {
respondError(c, domain.ErrValidation)
return 0, uuid.UUID{}, false
}
objectID, err := uuid.Parse(c.Param("object_id"))
if err != nil {
respondError(c, domain.ErrValidation)
return 0, uuid.UUID{}, false
}
return id, objectID, true
}
// ---------------------------------------------------------------------------
// GET /acl/:object_type/:object_id
// ---------------------------------------------------------------------------
func (h *ACLHandler) GetPermissions(c *gin.Context) {
objectTypeID, objectID, ok := parseACLPath(c)
if !ok {
return
}
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
perms, err := h.aclSvc.GetPermissions(c.Request.Context(), userID, isAdmin, objectTypeID, objectID)
if err != nil {
respondError(c, err)
return
}
out := make([]permissionJSON, len(perms))
for i, p := range perms {
out[i] = toPermissionJSON(p)
}
respondJSON(c, http.StatusOK, out)
}
// ---------------------------------------------------------------------------
// PUT /acl/:object_type/:object_id
// ---------------------------------------------------------------------------
func (h *ACLHandler) SetPermissions(c *gin.Context) {
objectTypeID, objectID, ok := parseACLPath(c)
if !ok {
return
}
var body struct {
Permissions []struct {
UserID int16 `json:"user_id" binding:"required"`
CanView bool `json:"can_view"`
CanEdit bool `json:"can_edit"`
} `json:"permissions" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
perms := make([]domain.Permission, len(body.Permissions))
for i, p := range body.Permissions {
perms[i] = domain.Permission{
UserID: p.UserID,
CanView: p.CanView,
CanEdit: p.CanEdit,
}
}
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
if err := h.aclSvc.SetPermissions(c.Request.Context(), userID, isAdmin, objectTypeID, objectID, perms); err != nil {
respondError(c, err)
return
}
// Re-read to return the stored permissions (with UserName denormalized).
stored, err := h.aclSvc.GetPermissions(c.Request.Context(), userID, isAdmin, objectTypeID, objectID)
if err != nil {
respondError(c, err)
return
}
out := make([]permissionJSON, len(stored))
for i, p := range stored {
out[i] = toPermissionJSON(p)
}
respondJSON(c, http.StatusOK, out)
}
+120
View File
@@ -0,0 +1,120 @@
package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/service"
)
// AuditHandler handles GET /audit.
type AuditHandler struct {
auditSvc *service.AuditService
}
// NewAuditHandler creates an AuditHandler.
func NewAuditHandler(auditSvc *service.AuditService) *AuditHandler {
return &AuditHandler{auditSvc: auditSvc}
}
// ---------------------------------------------------------------------------
// Response type
// ---------------------------------------------------------------------------
type auditEntryJSON struct {
ID int64 `json:"id"`
UserID int16 `json:"user_id"`
UserName string `json:"user_name"`
Action string `json:"action"`
ObjectType *string `json:"object_type"`
ObjectID *string `json:"object_id"`
PerformedAt string `json:"performed_at"`
}
func toAuditEntryJSON(e domain.AuditEntry) auditEntryJSON {
j := auditEntryJSON{
ID: e.ID,
UserID: e.UserID,
UserName: e.UserName,
Action: e.Action,
ObjectType: e.ObjectType,
PerformedAt: e.PerformedAt.UTC().Format(time.RFC3339),
}
if e.ObjectID != nil {
s := e.ObjectID.String()
j.ObjectID = &s
}
return j
}
// ---------------------------------------------------------------------------
// GET /audit (admin)
// ---------------------------------------------------------------------------
func (h *AuditHandler) List(c *gin.Context) {
if !requireAdmin(c) {
return
}
filter := domain.AuditFilter{}
if s := c.Query("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
filter.Limit = n
}
}
if s := c.Query("offset"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
filter.Offset = n
}
}
if s := c.Query("user_id"); s != "" {
if n, err := strconv.ParseInt(s, 10, 16); err == nil {
id := int16(n)
filter.UserID = &id
}
}
if s := c.Query("action"); s != "" {
filter.Action = s
}
if s := c.Query("object_type"); s != "" {
filter.ObjectType = s
}
if s := c.Query("object_id"); s != "" {
if id, err := uuid.Parse(s); err == nil {
filter.ObjectID = &id
}
}
if s := c.Query("from"); s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil {
filter.From = &t
}
}
if s := c.Query("to"); s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil {
filter.To = &t
}
}
page, err := h.auditSvc.Query(c.Request.Context(), filter)
if err != nil {
respondError(c, err)
return
}
items := make([]auditEntryJSON, len(page.Items))
for i, e := range page.Items {
items[i] = toAuditEntryJSON(e)
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"total": page.Total,
"offset": page.Offset,
"limit": page.Limit,
})
}
+140
View File
@@ -0,0 +1,140 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/service"
)
// AuthHandler handles all /auth endpoints.
type AuthHandler struct {
authSvc *service.AuthService
}
// NewAuthHandler creates an AuthHandler backed by authSvc.
func NewAuthHandler(authSvc *service.AuthService) *AuthHandler {
return &AuthHandler{authSvc: authSvc}
}
// Login handles POST /auth/login.
func (h *AuthHandler) Login(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, domain.ErrValidation)
return
}
pair, err := h.authSvc.Login(c.Request.Context(), req.Name, req.Password, c.GetHeader("User-Agent"))
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, gin.H{
"access_token": pair.AccessToken,
"refresh_token": pair.RefreshToken,
"expires_in": pair.ExpiresIn,
})
}
// Refresh handles POST /auth/refresh.
func (h *AuthHandler) Refresh(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, domain.ErrValidation)
return
}
pair, err := h.authSvc.Refresh(c.Request.Context(), req.RefreshToken, c.GetHeader("User-Agent"))
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, gin.H{
"access_token": pair.AccessToken,
"refresh_token": pair.RefreshToken,
"expires_in": pair.ExpiresIn,
})
}
// Logout handles POST /auth/logout. Requires authentication.
func (h *AuthHandler) Logout(c *gin.Context) {
_, _, sessionID := domain.UserFromContext(c.Request.Context())
if err := h.authSvc.Logout(c.Request.Context(), sessionID); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ListSessions handles GET /auth/sessions. Requires authentication.
func (h *AuthHandler) ListSessions(c *gin.Context) {
userID, _, sessionID := domain.UserFromContext(c.Request.Context())
list, err := h.authSvc.ListSessions(c.Request.Context(), userID, sessionID)
if err != nil {
respondError(c, err)
return
}
type sessionItem struct {
ID int `json:"id"`
UserAgent string `json:"user_agent"`
StartedAt string `json:"started_at"`
ExpiresAt any `json:"expires_at"`
LastActivity string `json:"last_activity"`
IsCurrent bool `json:"is_current"`
}
items := make([]sessionItem, len(list.Items))
for i, s := range list.Items {
var expiresAt any
if s.ExpiresAt != nil {
expiresAt = s.ExpiresAt.Format("2006-01-02T15:04:05Z07:00")
}
items[i] = sessionItem{
ID: s.ID,
UserAgent: s.UserAgent,
StartedAt: s.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
ExpiresAt: expiresAt,
LastActivity: s.LastActivity.Format("2006-01-02T15:04:05Z07:00"),
IsCurrent: s.IsCurrent,
}
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"total": list.Total,
})
}
// TerminateSession handles DELETE /auth/sessions/:id. Requires authentication.
func (h *AuthHandler) TerminateSession(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
if err := h.authSvc.TerminateSession(c.Request.Context(), userID, isAdmin, id); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
@@ -0,0 +1,235 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/service"
)
// CategoryHandler handles all /categories endpoints.
type CategoryHandler struct {
categorySvc *service.CategoryService
}
// NewCategoryHandler creates a CategoryHandler.
func NewCategoryHandler(categorySvc *service.CategoryService) *CategoryHandler {
return &CategoryHandler{categorySvc: categorySvc}
}
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
type categoryJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
Color *string `json:"color"`
CreatorID int16 `json:"creator_id"`
CreatorName string `json:"creator_name"`
IsPublic bool `json:"is_public"`
CreatedAt string `json:"created_at"`
}
func toCategoryJSON(c domain.Category) categoryJSON {
return categoryJSON{
ID: c.ID.String(),
Name: c.Name,
Notes: c.Notes,
Color: c.Color,
CreatorID: c.CreatorID,
CreatorName: c.CreatorName,
IsPublic: c.IsPublic,
CreatedAt: c.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func parseCategoryID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("category_id"))
if err != nil {
respondError(c, domain.ErrValidation)
return uuid.UUID{}, false
}
return id, true
}
// ---------------------------------------------------------------------------
// GET /categories
// ---------------------------------------------------------------------------
func (h *CategoryHandler) List(c *gin.Context) {
params := parseOffsetParams(c, "created")
page, err := h.categorySvc.List(c.Request.Context(), params)
if err != nil {
respondError(c, err)
return
}
items := make([]categoryJSON, len(page.Items))
for i, cat := range page.Items {
items[i] = toCategoryJSON(cat)
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"total": page.Total,
"offset": page.Offset,
"limit": page.Limit,
})
}
// ---------------------------------------------------------------------------
// POST /categories
// ---------------------------------------------------------------------------
func (h *CategoryHandler) Create(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Notes *string `json:"notes"`
Color *string `json:"color"`
IsPublic *bool `json:"is_public"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
created, err := h.categorySvc.Create(c.Request.Context(), service.CategoryParams{
Name: body.Name,
Notes: body.Notes,
Color: body.Color,
IsPublic: body.IsPublic,
})
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusCreated, toCategoryJSON(*created))
}
// ---------------------------------------------------------------------------
// GET /categories/:category_id
// ---------------------------------------------------------------------------
func (h *CategoryHandler) Get(c *gin.Context) {
id, ok := parseCategoryID(c)
if !ok {
return
}
cat, err := h.categorySvc.Get(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toCategoryJSON(*cat))
}
// ---------------------------------------------------------------------------
// PATCH /categories/:category_id
// ---------------------------------------------------------------------------
func (h *CategoryHandler) Update(c *gin.Context) {
id, ok := parseCategoryID(c)
if !ok {
return
}
// Use a raw map to detect explicitly-null fields.
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
respondError(c, domain.ErrValidation)
return
}
params := service.CategoryParams{}
if v, ok := raw["name"]; ok {
if s, ok := v.(string); ok {
params.Name = s
}
}
if _, ok := raw["notes"]; ok {
if raw["notes"] == nil {
empty := ""
params.Notes = &empty
} else if s, ok := raw["notes"].(string); ok {
params.Notes = &s
}
}
if _, ok := raw["color"]; ok {
if raw["color"] == nil {
empty := ""
params.Color = &empty
} else if s, ok := raw["color"].(string); ok {
params.Color = &s
}
}
if v, ok := raw["is_public"]; ok {
if b, ok := v.(bool); ok {
params.IsPublic = &b
}
}
updated, err := h.categorySvc.Update(c.Request.Context(), id, params)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toCategoryJSON(*updated))
}
// ---------------------------------------------------------------------------
// DELETE /categories/:category_id
// ---------------------------------------------------------------------------
func (h *CategoryHandler) Delete(c *gin.Context) {
id, ok := parseCategoryID(c)
if !ok {
return
}
if err := h.categorySvc.Delete(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// GET /categories/:category_id/tags
// ---------------------------------------------------------------------------
func (h *CategoryHandler) ListTags(c *gin.Context) {
id, ok := parseCategoryID(c)
if !ok {
return
}
params := parseOffsetParams(c, "created")
page, err := h.categorySvc.ListTags(c.Request.Context(), id, params)
if err != nil {
respondError(c, err)
return
}
items := make([]tagJSON, len(page.Items))
for i, t := range page.Items {
items[i] = toTagJSON(t)
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"total": page.Total,
"offset": page.Offset,
"limit": page.Limit,
})
}
@@ -0,0 +1,122 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/service"
)
// DuplicateHandler handles the /files/duplicates endpoints.
type DuplicateHandler struct {
dupSvc *service.DuplicateService
}
// NewDuplicateHandler creates a DuplicateHandler.
func NewDuplicateHandler(dupSvc *service.DuplicateService) *DuplicateHandler {
return &DuplicateHandler{dupSvc: dupSvc}
}
// List handles GET /files/duplicates — an offset-paginated list of duplicate
// clusters, each a group of files within the perceptual-hash threshold.
func (h *DuplicateHandler) List(c *gin.Context) {
limit, offset := 20, 0
if n, err := strconv.Atoi(c.Query("limit")); err == nil {
limit = n
}
if n, err := strconv.Atoi(c.Query("offset")); err == nil {
offset = n
}
if limit < 1 {
limit = 1
}
if limit > 50 {
limit = 50
}
if offset < 0 {
offset = 0
}
clusters, total, err := h.dupSvc.Clusters(c.Request.Context(), limit, offset)
if err != nil {
respondError(c, err)
return
}
items := make([]gin.H, len(clusters))
for i, files := range clusters {
fs := make([]fileJSON, len(files))
for j, f := range files {
fs[j] = toFileJSON(f)
}
items[i] = gin.H{"files": fs}
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"total": total,
"limit": limit,
"offset": offset,
})
}
// Dismiss handles POST /files/duplicates/dismiss — mark a pair "not a duplicate".
func (h *DuplicateHandler) Dismiss(c *gin.Context) {
var body struct {
FileIDA string `json:"file_id_a" binding:"required"`
FileIDB string `json:"file_id_b" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
ids, err := parseUUIDs([]string{body.FileIDA, body.FileIDB})
if err != nil {
respondError(c, domain.ErrValidation)
return
}
if err := h.dupSvc.Dismiss(c.Request.Context(), ids[0], ids[1]); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// Resolve handles POST /files/duplicates/resolve — merge a duplicate pair,
// keeping one file and folding the chosen fields in from the other. Returns the
// updated survivor. delete_discarded defaults to true.
func (h *DuplicateHandler) Resolve(c *gin.Context) {
var body struct {
Keep string `json:"keep" binding:"required"`
Discard string `json:"discard" binding:"required"`
Fields service.MergeFields `json:"fields"`
DeleteDiscarded *bool `json:"delete_discarded"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
ids, err := parseUUIDs([]string{body.Keep, body.Discard})
if err != nil {
respondError(c, domain.ErrValidation)
return
}
del := true
if body.DeleteDiscarded != nil {
del = *body.DeleteDiscarded
}
f, err := h.dupSvc.Resolve(c.Request.Context(), service.MergeSpec{
Keep: ids[0],
Discard: ids[1],
Fields: body.Fields,
DeleteDiscarded: del,
})
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toFileJSON(*f))
}
+795
View File
@@ -0,0 +1,795 @@
package handler
import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/service"
)
// FileHandler handles all /files endpoints.
type FileHandler struct {
fileSvc *service.FileService
tagSvc *service.TagService
authSvc *service.AuthService
maxUploadBytes int64
}
// NewFileHandler creates a FileHandler. maxUploadBytes caps the size of an
// uploaded or replacement file. authSvc mints content tokens for media URLs.
func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService, authSvc *service.AuthService, maxUploadBytes int64) *FileHandler {
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc, authSvc: authSvc, maxUploadBytes: maxUploadBytes}
}
// formFileLimited reads the "file" multipart field while bounding how many bytes
// are read from the request body, then rejects files larger than the configured
// cap. The body limit guards against a dishonest Content-Length; the Size check
// gives a clear rejection for an honestly-declared oversized file.
func (h *FileHandler) formFileLimited(c *gin.Context) (*multipart.FileHeader, bool) {
// Allow a little slack above the file cap for multipart framing overhead.
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxUploadBytes+(1<<20))
fh, err := c.FormFile("file")
if err != nil {
respondError(c, domain.ErrValidation)
return nil, false
}
if fh.Size > h.maxUploadBytes {
respondError(c, domain.ErrValidation)
return nil, false
}
return fh, true
}
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
type tagJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
Color *string `json:"color"`
CategoryID *string `json:"category_id"`
CategoryName *string `json:"category_name"`
CategoryColor *string `json:"category_color"`
CreatorID int16 `json:"creator_id"`
CreatorName string `json:"creator_name"`
IsPublic bool `json:"is_public"`
CreatedAt string `json:"created_at"`
}
type fileJSON struct {
ID string `json:"id"`
OriginalName *string `json:"original_name"`
MIMEType string `json:"mime_type"`
MIMEExtension string `json:"mime_extension"`
ContentDatetime string `json:"content_datetime"`
Notes *string `json:"notes"`
Metadata json.RawMessage `json:"metadata"`
EXIF json.RawMessage `json:"exif"`
PHash *int64 `json:"phash"`
CreatorID int16 `json:"creator_id"`
CreatorName string `json:"creator_name"`
IsPublic bool `json:"is_public"`
IsDeleted bool `json:"is_deleted"`
NeedsReview bool `json:"needs_review"`
CreatedAt string `json:"created_at"`
Tags []tagJSON `json:"tags"`
}
func toTagJSON(t domain.Tag) tagJSON {
j := tagJSON{
ID: t.ID.String(),
Name: t.Name,
Notes: t.Notes,
Color: t.Color,
CategoryName: t.CategoryName,
CategoryColor: t.CategoryColor,
CreatorID: t.CreatorID,
CreatorName: t.CreatorName,
IsPublic: t.IsPublic,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
}
if t.CategoryID != nil {
s := t.CategoryID.String()
j.CategoryID = &s
}
return j
}
func toFileJSON(f domain.File) fileJSON {
tags := make([]tagJSON, len(f.Tags))
for i, t := range f.Tags {
tags[i] = toTagJSON(t)
}
exif := f.EXIF
if exif == nil {
exif = json.RawMessage("{}")
}
return fileJSON{
ID: f.ID.String(),
OriginalName: f.OriginalName,
MIMEType: f.MIMEType,
MIMEExtension: f.MIMEExtension,
ContentDatetime: f.ContentDatetime.Format(time.RFC3339),
Notes: f.Notes,
Metadata: f.Metadata,
EXIF: exif,
PHash: f.PHash,
CreatorID: f.CreatorID,
CreatorName: f.CreatorName,
IsPublic: f.IsPublic,
IsDeleted: f.IsDeleted,
NeedsReview: f.NeedsReview,
CreatedAt: f.CreatedAt.Format(time.RFC3339),
Tags: tags,
}
}
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
func parseFileID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, domain.ErrValidation)
return uuid.UUID{}, false
}
return id, true
}
// ---------------------------------------------------------------------------
// GET /files
// ---------------------------------------------------------------------------
func (h *FileHandler) List(c *gin.Context) {
params := domain.FileListParams{
Cursor: c.Query("cursor"),
Direction: c.DefaultQuery("direction", "forward"),
Sort: c.DefaultQuery("sort", "created"),
Order: c.DefaultQuery("order", "desc"),
Filter: c.Query("filter"),
Search: c.Query("search"),
}
if limitStr := c.Query("limit"); limitStr != "" {
n, err := strconv.Atoi(limitStr)
if err != nil || n < 1 || n > 200 {
respondError(c, domain.ErrValidation)
return
}
params.Limit = n
} else {
params.Limit = 50
}
if anchorStr := c.Query("anchor"); anchorStr != "" {
id, err := uuid.Parse(anchorStr)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
params.Anchor = &id
}
if trashStr := c.Query("trash"); trashStr == "true" || trashStr == "1" {
params.Trash = true
}
page, err := h.fileSvc.List(c.Request.Context(), params)
if err != nil {
respondError(c, err)
return
}
items := make([]fileJSON, len(page.Items))
for i, f := range page.Items {
items[i] = toFileJSON(f)
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"next_cursor": page.NextCursor,
"prev_cursor": page.PrevCursor,
})
}
// ---------------------------------------------------------------------------
// POST /files (multipart upload)
// ---------------------------------------------------------------------------
func (h *FileHandler) Upload(c *gin.Context) {
fh, ok := h.formFileLimited(c)
if !ok {
return
}
src, err := fh.Open()
if err != nil {
respondError(c, err)
return
}
defer src.Close()
// Detect MIME from actual bytes (ignore client-supplied Content-Type).
mt, err := mimetype.DetectReader(src)
if err != nil {
respondError(c, err)
return
}
// Rewind by reopening — FormFile gives a multipart.File which supports Seek.
if _, err := src.Seek(0, io.SeekStart); err != nil {
respondError(c, err)
return
}
mimeStr := strings.SplitN(mt.String(), ";", 2)[0]
params := service.UploadParams{
Reader: src,
MIMEType: mimeStr,
IsPublic: c.PostForm("is_public") == "true",
}
if name := fh.Filename; name != "" {
params.OriginalName = &name
}
if notes := c.PostForm("notes"); notes != "" {
params.Notes = &notes
}
if metaStr := c.PostForm("metadata"); metaStr != "" {
params.Metadata = json.RawMessage(metaStr)
}
if dtStr := c.PostForm("content_datetime"); dtStr != "" {
t, err := time.Parse(time.RFC3339, dtStr)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
params.ContentDatetime = &t
}
if tagIDsStr := c.PostForm("tag_ids"); tagIDsStr != "" {
for _, raw := range strings.Split(tagIDsStr, ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
id, err := uuid.Parse(raw)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
params.TagIDs = append(params.TagIDs, id)
}
}
f, err := h.fileSvc.Upload(c.Request.Context(), params)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusCreated, toFileJSON(*f))
}
// ---------------------------------------------------------------------------
// GET /files/:id
// ---------------------------------------------------------------------------
func (h *FileHandler) GetMeta(c *gin.Context) {
id, ok := parseFileID(c)
if !ok {
return
}
f, err := h.fileSvc.Get(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toFileJSON(*f))
}
// ---------------------------------------------------------------------------
// POST /files/:id/views
// ---------------------------------------------------------------------------
// RecordView logs that the current user viewed the file (activity.file_views).
func (h *FileHandler) RecordView(c *gin.Context) {
id, ok := parseFileID(c)
if !ok {
return
}
if err := h.fileSvc.RecordView(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// PATCH /files/:id
// ---------------------------------------------------------------------------
func (h *FileHandler) UpdateMeta(c *gin.Context) {
id, ok := parseFileID(c)
if !ok {
return
}
var body struct {
OriginalName *string `json:"original_name"`
ContentDatetime *string `json:"content_datetime"`
Notes *string `json:"notes"`
Metadata json.RawMessage `json:"metadata"`
IsPublic *bool `json:"is_public"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
params := service.UpdateParams{
OriginalName: body.OriginalName,
Notes: body.Notes,
Metadata: body.Metadata,
IsPublic: body.IsPublic,
}
if body.ContentDatetime != nil {
t, err := time.Parse(time.RFC3339, *body.ContentDatetime)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
params.ContentDatetime = &t
}
f, err := h.fileSvc.Update(c.Request.Context(), id, params)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toFileJSON(*f))
}
// ---------------------------------------------------------------------------
// DELETE /files/:id (soft-delete)
// ---------------------------------------------------------------------------
func (h *FileHandler) SoftDelete(c *gin.Context) {
id, ok := parseFileID(c)
if !ok {
return
}
if err := h.fileSvc.Delete(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// POST /files/:id/content-token
// ---------------------------------------------------------------------------
// CreateContentToken mints a short-lived, single-file capability token the
// client can put in a content URL's access_token query parameter to open or
// stream the original by link (e.g. a long video in a new tab) without the URL
// dying when the 15-minute access token expires. It first enforces view
// permission via fileSvc.Get, so a token is only issued for a file the caller
// may actually read.
func (h *FileHandler) CreateContentToken(c *gin.Context) {
id, ok := parseFileID(c)
if !ok {
return
}
// Authorize (and confirm existence) the same way content serving does.
if _, err := h.fileSvc.Get(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
token, expiresIn, err := h.authSvc.GenerateContentToken(id.String(), userID, isAdmin)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "expires_in": expiresIn})
}
// ---------------------------------------------------------------------------
// GET /files/:id/content
// ---------------------------------------------------------------------------
func (h *FileHandler) GetContent(c *gin.Context) {
id, ok := parseFileID(c)
if !ok {
return
}
res, err := h.fileSvc.GetContent(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
defer res.Body.Close()
c.Header("Content-Type", res.MIMEType)
c.Header("Cache-Control", "private, max-age=3600")
// Default to attachment (download); ?inline=1 serves it for in-tab viewing.
disposition := "attachment"
if c.Query("inline") == "1" {
disposition = "inline"
}
name := ""
if res.OriginalName != nil {
name = *res.OriginalName
c.Header("Content-Disposition",
fmt.Sprintf("%s; filename=%q", disposition, name))
}
// Serve with byte-range support when the body is seekable (it is for the
// disk store): http.ServeContent advertises Accept-Ranges and answers Range
// requests with 206 Partial Content, which is what lets the browser scrub and
// seek within audio/video. Fall back to a plain stream otherwise.
if seeker, ok := res.Body.(io.ReadSeeker); ok {
http.ServeContent(c.Writer, c.Request, name, time.Time{}, seeker)
return
}
c.Status(http.StatusOK)
io.Copy(c.Writer, res.Body) //nolint:errcheck
}
// ---------------------------------------------------------------------------
// PUT /files/:id/content (replace)
// ---------------------------------------------------------------------------
func (h *FileHandler) ReplaceContent(c *gin.Context) {
id, ok := parseFileID(c)
if !ok {
return
}
fh, ok := h.formFileLimited(c)
if !ok {
return
}
src, err := fh.Open()
if err != nil {
respondError(c, err)
return
}
defer src.Close()
mt, err := mimetype.DetectReader(src)
if err != nil {
respondError(c, err)
return
}
if _, err := src.Seek(0, io.SeekStart); err != nil {
respondError(c, err)
return
}
mimeStr := strings.SplitN(mt.String(), ";", 2)[0]
name := fh.Filename
params := service.UploadParams{
Reader: src,
MIMEType: mimeStr,
OriginalName: &name,
}
f, err := h.fileSvc.Replace(c.Request.Context(), id, params)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toFileJSON(*f))
}
// ---------------------------------------------------------------------------
// GET /files/:id/thumbnail
// ---------------------------------------------------------------------------
func (h *FileHandler) GetThumbnail(c *gin.Context) {
id, ok := parseFileID(c)
if !ok {
return
}
rc, err := h.fileSvc.GetThumbnail(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
defer rc.Close()
c.Header("Content-Type", "image/jpeg")
c.Header("Cache-Control", "private, max-age=3600")
c.Status(http.StatusOK)
io.Copy(c.Writer, rc) //nolint:errcheck
}
// ---------------------------------------------------------------------------
// GET /files/:id/preview
// ---------------------------------------------------------------------------
func (h *FileHandler) GetPreview(c *gin.Context) {
id, ok := parseFileID(c)
if !ok {
return
}
rc, err := h.fileSvc.GetPreview(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
defer rc.Close()
c.Header("Content-Type", "image/jpeg")
c.Header("Cache-Control", "private, max-age=3600")
c.Status(http.StatusOK)
io.Copy(c.Writer, rc) //nolint:errcheck
}
// ---------------------------------------------------------------------------
// POST /files/:id/restore
// ---------------------------------------------------------------------------
func (h *FileHandler) Restore(c *gin.Context) {
id, ok := parseFileID(c)
if !ok {
return
}
f, err := h.fileSvc.Restore(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toFileJSON(*f))
}
// ---------------------------------------------------------------------------
// DELETE /files/:id/permanent
// ---------------------------------------------------------------------------
func (h *FileHandler) PermanentDelete(c *gin.Context) {
id, ok := parseFileID(c)
if !ok {
return
}
if err := h.fileSvc.PermanentDelete(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// POST /files/bulk/tags
// ---------------------------------------------------------------------------
func (h *FileHandler) BulkSetTags(c *gin.Context) {
var body struct {
FileIDs []string `json:"file_ids" binding:"required"`
Action string `json:"action" binding:"required"`
TagIDs []string `json:"tag_ids" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
if body.Action != "add" && body.Action != "remove" {
respondError(c, domain.ErrValidation)
return
}
fileIDs, err := parseUUIDs(body.FileIDs)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
tagIDs, err := parseUUIDs(body.TagIDs)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
applied, err := h.tagSvc.BulkSetTags(c.Request.Context(), fileIDs, body.Action, tagIDs)
if err != nil {
respondError(c, err)
return
}
strs := make([]string, len(applied))
for i, id := range applied {
strs[i] = id.String()
}
respondJSON(c, http.StatusOK, gin.H{"applied_tag_ids": strs})
}
// ---------------------------------------------------------------------------
// POST /files/bulk/delete
// ---------------------------------------------------------------------------
func (h *FileHandler) BulkDelete(c *gin.Context) {
var body struct {
FileIDs []string `json:"file_ids" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
fileIDs, err := parseUUIDs(body.FileIDs)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
if err := h.fileSvc.BulkDelete(c.Request.Context(), fileIDs); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// BulkReview sets the review status on one or more files. A single-file toggle
// is just a one-element file_ids array. Files the caller cannot edit are
// silently skipped (handled in the service).
func (h *FileHandler) BulkReview(c *gin.Context) {
var body struct {
FileIDs []string `json:"file_ids" binding:"required"`
NeedsReview *bool `json:"needs_review" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.NeedsReview == nil {
respondError(c, domain.ErrValidation)
return
}
fileIDs, err := parseUUIDs(body.FileIDs)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
if err := h.fileSvc.SetNeedsReview(c.Request.Context(), fileIDs, *body.NeedsReview); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// POST /files/bulk/common-tags
// ---------------------------------------------------------------------------
func (h *FileHandler) CommonTags(c *gin.Context) {
var body struct {
FileIDs []string `json:"file_ids" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
fileIDs, err := parseUUIDs(body.FileIDs)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
common, partial, err := h.tagSvc.CommonTags(c.Request.Context(), fileIDs)
if err != nil {
respondError(c, err)
return
}
toStrs := func(tags []domain.Tag) []string {
s := make([]string, len(tags))
for i, t := range tags {
s[i] = t.ID.String()
}
return s
}
respondJSON(c, http.StatusOK, gin.H{
"common_tag_ids": toStrs(common),
"partial_tag_ids": toStrs(partial),
})
}
// ---------------------------------------------------------------------------
// POST /files/import
// ---------------------------------------------------------------------------
func (h *FileHandler) Import(c *gin.Context) {
// Server-side directory import reads arbitrary paths on the host; restrict
// it to administrators.
if !requireAdmin(c) {
return
}
var body struct {
Path string `json:"path"`
}
// Body is optional; ignore bind errors.
_ = c.ShouldBindJSON(&body)
// Stream progress as newline-delimited JSON so the client can render a live
// progress bar and per-file status. Headers are deferred until the first
// event, so a validation error (bad path, import disabled) raised before any
// file is touched can still be returned as a normal JSON error response.
flusher, canFlush := c.Writer.(http.Flusher)
started := false
enc := json.NewEncoder(c.Writer)
emit := func(ev service.ImportEvent) {
if !started {
c.Header("Content-Type", "application/x-ndjson")
c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no") // don't let a proxy buffer the stream
c.Writer.WriteHeader(http.StatusOK)
started = true
}
_ = enc.Encode(ev) // appends a newline
if canFlush {
flusher.Flush()
}
}
if _, err := h.fileSvc.Import(c.Request.Context(), body.Path, emit); err != nil {
if !started {
respondError(c, err)
return
}
// Headers already sent; surface the failure as a terminal stream event.
emit(service.ImportEvent{Type: "error", Reason: err.Error()})
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func parseUUIDs(strs []string) ([]uuid.UUID, error) {
ids := make([]uuid.UUID, 0, len(strs))
for _, s := range strs {
id, err := uuid.Parse(s)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, nil
}
+116
View File
@@ -0,0 +1,116 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/service"
)
// AuthMiddleware validates Bearer JWTs and injects user identity into context.
type AuthMiddleware struct {
authSvc *service.AuthService
}
// NewAuthMiddleware creates an AuthMiddleware backed by authSvc.
func NewAuthMiddleware(authSvc *service.AuthService) *AuthMiddleware {
return &AuthMiddleware{authSvc: authSvc}
}
// Handle returns a Gin handler function that enforces authentication.
// On success it calls c.Next(); on failure it aborts with 401 JSON.
func (m *AuthMiddleware) Handle() gin.HandlerFunc {
return func(c *gin.Context) {
token := bearerToken(c)
if token == "" {
c.JSON(http.StatusUnauthorized, errorBody{
Code: domain.ErrUnauthorized.Code(),
Message: "authorization header missing or malformed",
})
c.Abort()
return
}
claims, err := m.authSvc.ValidateAccessToken(c.Request.Context(), token)
if err != nil {
c.JSON(http.StatusUnauthorized, errorBody{
Code: domain.ErrUnauthorized.Code(),
Message: "invalid or expired token",
})
c.Abort()
return
}
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
// HandleContent authenticates a file-content GET, accepting either a normal
// access token or a content token scoped (by its fid claim) to the :id in the
// path. The content token is what keeps a long media stream playing after the
// short access token would have expired. View permission is still enforced in
// the handler against the resolved user, so a content token only widens *when*
// a file may be read by URL, never *which* files.
func (m *AuthMiddleware) HandleContent() gin.HandlerFunc {
return func(c *gin.Context) {
token := bearerToken(c)
if token == "" {
contentUnauthorized(c)
return
}
// A regular access token grants access to everything as usual.
if claims, err := m.authSvc.ValidateAccessToken(c.Request.Context(), token); err == nil {
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
c.Request = c.Request.WithContext(ctx)
c.Next()
return
}
// Otherwise accept a content token minted for exactly this file. Normalise
// the path id to canonical form so it matches the minted fid claim.
id, err := uuid.Parse(c.Param("id"))
if err != nil {
contentUnauthorized(c)
return
}
claims, err := m.authSvc.ValidateContentToken(token, id.String())
if err != nil {
contentUnauthorized(c)
return
}
// A content token carries no session (sid 0); it is session-independent.
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func contentUnauthorized(c *gin.Context) {
c.JSON(http.StatusUnauthorized, errorBody{
Code: domain.ErrUnauthorized.Code(),
Message: "invalid or expired token",
})
c.Abort()
}
// bearerToken extracts the access token from the Authorization header. As a
// fallback it accepts an ?access_token= query parameter, but only for GET
// requests — this lets the browser open media (e.g. /files/{id}/content) via a
// plain link/new tab, where it can't send the header, without allowing a crafted
// link to drive a state-changing request.
func bearerToken(c *gin.Context) string {
if raw := c.GetHeader("Authorization"); strings.HasPrefix(raw, "Bearer ") {
return strings.TrimPrefix(raw, "Bearer ")
}
if c.Request.Method == http.MethodGet {
return c.Query("access_token")
}
return ""
}
+375
View File
@@ -0,0 +1,375 @@
package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
"tanabata/backend/internal/service"
)
// PoolHandler handles all /pools endpoints.
type PoolHandler struct {
poolSvc *service.PoolService
}
// NewPoolHandler creates a PoolHandler.
func NewPoolHandler(poolSvc *service.PoolService) *PoolHandler {
return &PoolHandler{poolSvc: poolSvc}
}
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
type poolJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
CreatorID int16 `json:"creator_id"`
CreatorName string `json:"creator_name"`
IsPublic bool `json:"is_public"`
FileCount int `json:"file_count"`
CreatedAt string `json:"created_at"`
}
type poolFileJSON struct {
fileJSON
Position int `json:"position"`
}
func toPoolJSON(p domain.Pool) poolJSON {
return poolJSON{
ID: p.ID.String(),
Name: p.Name,
Notes: p.Notes,
CreatorID: p.CreatorID,
CreatorName: p.CreatorName,
IsPublic: p.IsPublic,
FileCount: p.FileCount,
CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339),
}
}
func toPoolFileJSON(pf domain.PoolFile) poolFileJSON {
return poolFileJSON{
fileJSON: toFileJSON(pf.File),
Position: pf.Position,
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func parsePoolID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("pool_id"))
if err != nil {
respondError(c, domain.ErrValidation)
return uuid.UUID{}, false
}
return id, true
}
func parsePoolFileParams(c *gin.Context) port.PoolFileListParams {
limit := 50
if s := c.Query("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 {
limit = n
}
}
return port.PoolFileListParams{
Cursor: c.Query("cursor"),
Limit: limit,
Filter: c.Query("filter"),
}
}
// ---------------------------------------------------------------------------
// GET /pools
// ---------------------------------------------------------------------------
func (h *PoolHandler) List(c *gin.Context) {
params := parseOffsetParams(c, "created")
page, err := h.poolSvc.List(c.Request.Context(), params)
if err != nil {
respondError(c, err)
return
}
items := make([]poolJSON, len(page.Items))
for i, p := range page.Items {
items[i] = toPoolJSON(p)
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"total": page.Total,
"offset": page.Offset,
"limit": page.Limit,
})
}
// ---------------------------------------------------------------------------
// POST /pools
// ---------------------------------------------------------------------------
func (h *PoolHandler) Create(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Notes *string `json:"notes"`
IsPublic *bool `json:"is_public"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
created, err := h.poolSvc.Create(c.Request.Context(), service.PoolParams{
Name: body.Name,
Notes: body.Notes,
IsPublic: body.IsPublic,
})
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusCreated, toPoolJSON(*created))
}
// ---------------------------------------------------------------------------
// GET /pools/:pool_id
// ---------------------------------------------------------------------------
func (h *PoolHandler) Get(c *gin.Context) {
id, ok := parsePoolID(c)
if !ok {
return
}
p, err := h.poolSvc.Get(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toPoolJSON(*p))
}
// ---------------------------------------------------------------------------
// POST /pools/:pool_id/views
// ---------------------------------------------------------------------------
// RecordView logs that the current user viewed the pool (activity.pool_views).
func (h *PoolHandler) RecordView(c *gin.Context) {
id, ok := parsePoolID(c)
if !ok {
return
}
if err := h.poolSvc.RecordView(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// PATCH /pools/:pool_id
// ---------------------------------------------------------------------------
func (h *PoolHandler) Update(c *gin.Context) {
id, ok := parsePoolID(c)
if !ok {
return
}
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
respondError(c, domain.ErrValidation)
return
}
params := service.PoolParams{}
if v, ok := raw["name"]; ok {
if s, ok := v.(string); ok {
params.Name = s
}
}
if _, ok := raw["notes"]; ok {
if raw["notes"] == nil {
empty := ""
params.Notes = &empty
} else if s, ok := raw["notes"].(string); ok {
params.Notes = &s
}
}
if v, ok := raw["is_public"]; ok {
if b, ok := v.(bool); ok {
params.IsPublic = &b
}
}
updated, err := h.poolSvc.Update(c.Request.Context(), id, params)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toPoolJSON(*updated))
}
// ---------------------------------------------------------------------------
// DELETE /pools/:pool_id
// ---------------------------------------------------------------------------
func (h *PoolHandler) Delete(c *gin.Context) {
id, ok := parsePoolID(c)
if !ok {
return
}
if err := h.poolSvc.Delete(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// GET /pools/:pool_id/files
// ---------------------------------------------------------------------------
func (h *PoolHandler) ListFiles(c *gin.Context) {
poolID, ok := parsePoolID(c)
if !ok {
return
}
params := parsePoolFileParams(c)
page, err := h.poolSvc.ListFiles(c.Request.Context(), poolID, params)
if err != nil {
respondError(c, err)
return
}
items := make([]poolFileJSON, len(page.Items))
for i, pf := range page.Items {
items[i] = toPoolFileJSON(pf)
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"next_cursor": page.NextCursor,
})
}
// ---------------------------------------------------------------------------
// POST /pools/:pool_id/files
// ---------------------------------------------------------------------------
func (h *PoolHandler) AddFiles(c *gin.Context) {
poolID, ok := parsePoolID(c)
if !ok {
return
}
var body struct {
FileIDs []string `json:"file_ids" binding:"required"`
Position *int `json:"position"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
fileIDs := make([]uuid.UUID, 0, len(body.FileIDs))
for _, s := range body.FileIDs {
id, err := uuid.Parse(s)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
fileIDs = append(fileIDs, id)
}
if err := h.poolSvc.AddFiles(c.Request.Context(), poolID, fileIDs, body.Position); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusCreated)
}
// ---------------------------------------------------------------------------
// POST /pools/:pool_id/files/remove
// ---------------------------------------------------------------------------
func (h *PoolHandler) RemoveFiles(c *gin.Context) {
poolID, ok := parsePoolID(c)
if !ok {
return
}
var body struct {
FileIDs []string `json:"file_ids" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
fileIDs := make([]uuid.UUID, 0, len(body.FileIDs))
for _, s := range body.FileIDs {
id, err := uuid.Parse(s)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
fileIDs = append(fileIDs, id)
}
if err := h.poolSvc.RemoveFiles(c.Request.Context(), poolID, fileIDs); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// PUT /pools/:pool_id/files/reorder
// ---------------------------------------------------------------------------
func (h *PoolHandler) Reorder(c *gin.Context) {
poolID, ok := parsePoolID(c)
if !ok {
return
}
var body struct {
FileIDs []string `json:"file_ids" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
fileIDs := make([]uuid.UUID, 0, len(body.FileIDs))
for _, s := range body.FileIDs {
id, err := uuid.Parse(s)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
fileIDs = append(fileIDs, id)
}
if err := h.poolSvc.Reorder(c.Request.Context(), poolID, fileIDs); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+77
View File
@@ -0,0 +1,77 @@
package handler
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// rateLimiter is a process-local, fixed-window per-key request limiter used to
// throttle unauthenticated endpoints (login, refresh) against brute force. It
// is best-effort: counts live in memory and reset on restart.
type rateLimiter struct {
mu sync.Mutex
counts map[string]*rateWindow
limit int
window time.Duration
}
type rateWindow struct {
count int
reset time.Time
}
// newRateLimiter allows up to limit requests per key within each window.
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
return &rateLimiter{
counts: make(map[string]*rateWindow),
limit: limit,
window: window,
}
}
// allow records a request for key and reports whether it is within the limit.
func (rl *rateLimiter) allow(key string) bool {
now := time.Now()
rl.mu.Lock()
defer rl.mu.Unlock()
// Opportunistically prune expired entries so the map cannot grow without
// bound under a flood of distinct client IPs.
if len(rl.counts) > 10000 {
for k, w := range rl.counts {
if now.After(w.reset) {
delete(rl.counts, k)
}
}
}
w, ok := rl.counts[key]
if !ok || now.After(w.reset) {
rl.counts[key] = &rateWindow{count: 1, reset: now.Add(rl.window)}
return true
}
if w.count >= rl.limit {
return false
}
w.count++
return true
}
// Middleware throttles requests by client IP, returning 429 when over the limit.
func (rl *rateLimiter) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !rl.allow(c.ClientIP()) {
c.JSON(http.StatusTooManyRequests, errorBody{
Code: "rate_limited",
Message: "too many requests, please try again later",
})
c.Abort()
return
}
c.Next()
}
}
+55
View File
@@ -0,0 +1,55 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"tanabata/backend/internal/domain"
)
// errorBody is the JSON shape returned for all error responses.
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
func respondJSON(c *gin.Context, status int, data any) {
c.JSON(status, data)
}
// respondError maps a domain error to the appropriate HTTP status and writes
// a JSON error body. Unknown errors become 500.
func respondError(c *gin.Context, err error) {
var de *domain.DomainError
if errors.As(err, &de) {
c.JSON(domainStatus(de), errorBody{Code: de.Code(), Message: de.Error()})
return
}
c.JSON(http.StatusInternalServerError, errorBody{
Code: "internal_error",
Message: "internal server error",
})
}
// domainStatus maps a DomainError sentinel to its HTTP status code per the
// error mapping table in docs/GO_PROJECT_STRUCTURE.md.
func domainStatus(de *domain.DomainError) int {
switch de {
case domain.ErrNotFound:
return http.StatusNotFound
case domain.ErrForbidden:
return http.StatusForbidden
case domain.ErrUnauthorized:
return http.StatusUnauthorized
case domain.ErrConflict:
return http.StatusConflict
case domain.ErrValidation:
return http.StatusBadRequest
case domain.ErrUnsupportedMIME:
return http.StatusUnsupportedMediaType
default:
return http.StatusInternalServerError
}
}
+220
View File
@@ -0,0 +1,220 @@
package handler
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// securityHeaders sets conservative response headers on every response: prevent
// MIME sniffing of served file content, forbid framing, and suppress the
// Referer header on outbound navigations.
func securityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
h := c.Writer.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
c.Next()
}
}
// NewRouter builds and returns a configured Gin engine.
func NewRouter(
auth *AuthMiddleware,
authHandler *AuthHandler,
fileHandler *FileHandler,
duplicateHandler *DuplicateHandler,
tagHandler *TagHandler,
categoryHandler *CategoryHandler,
poolHandler *PoolHandler,
userHandler *UserHandler,
aclHandler *ACLHandler,
auditHandler *AuditHandler,
staticDir string,
trustedProxies []string,
) (*gin.Engine, error) {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery(), securityHeaders())
// Behind a reverse proxy the client's real IP arrives in X-Forwarded-For.
// Trust only the proxy hop(s) so c.ClientIP() — used by the auth rate
// limiter — reflects the real client and can't be spoofed by a forged
// header from a direct caller. An empty list trusts no proxy (ClientIP is
// the immediate peer).
if err := r.SetTrustedProxies(trustedProxies); err != nil {
return nil, fmt.Errorf("configure trusted proxies: %w", err)
}
// Health check — no auth required.
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
v1 := r.Group("/api/v1")
// -------------------------------------------------------------------------
// Auth
// -------------------------------------------------------------------------
authGroup := v1.Group("/auth")
{
// Throttle credential endpoints per client IP to slow brute force.
authLimiter := newRateLimiter(10, time.Minute).Middleware()
authGroup.POST("/login", authLimiter, authHandler.Login)
authGroup.POST("/refresh", authLimiter, authHandler.Refresh)
protected := authGroup.Group("", auth.Handle())
{
protected.POST("/logout", authHandler.Logout)
protected.GET("/sessions", authHandler.ListSessions)
protected.DELETE("/sessions/:id", authHandler.TerminateSession)
}
}
// -------------------------------------------------------------------------
// Files (all require auth)
// -------------------------------------------------------------------------
files := v1.Group("/files", auth.Handle())
{
files.GET("", fileHandler.List)
files.POST("", fileHandler.Upload)
// Bulk + import + duplicates routes registered before /:id to prevent
// param collision (e.g. "duplicates" being captured as :id).
files.GET("/duplicates", duplicateHandler.List)
files.POST("/duplicates/dismiss", duplicateHandler.Dismiss)
files.POST("/duplicates/resolve", duplicateHandler.Resolve)
files.POST("/bulk/tags", fileHandler.BulkSetTags)
files.POST("/bulk/delete", fileHandler.BulkDelete)
files.POST("/bulk/review", fileHandler.BulkReview)
files.POST("/bulk/common-tags", fileHandler.CommonTags)
files.POST("/import", fileHandler.Import)
// Per-file routes.
files.GET("/:id", fileHandler.GetMeta)
files.PATCH("/:id", fileHandler.UpdateMeta)
files.DELETE("/:id", fileHandler.SoftDelete)
files.PUT("/:id/content", fileHandler.ReplaceContent)
// Mints a content token (strict auth) for the GET /:id/content route below.
files.POST("/:id/content-token", fileHandler.CreateContentToken)
files.GET("/:id/thumbnail", fileHandler.GetThumbnail)
files.GET("/:id/preview", fileHandler.GetPreview)
files.POST("/:id/views", fileHandler.RecordView)
files.POST("/:id/restore", fileHandler.Restore)
files.DELETE("/:id/permanent", fileHandler.PermanentDelete)
// Filetag relations — served by TagHandler for auto-rule support.
files.GET("/:id/tags", tagHandler.FileListTags)
files.PUT("/:id/tags", tagHandler.FileSetTags)
files.PUT("/:id/tags/:tag_id", tagHandler.FileAddTag)
files.DELETE("/:id/tags/:tag_id", tagHandler.FileRemoveTag)
}
// Serving an original is the one read that can outlive a 15-minute access
// token — a long video streams via repeated Range requests over many minutes.
// So this route alone also accepts a file-scoped content token (see
// HandleContent), letting the media URL stay valid for the whole playback.
media := v1.Group("/files", auth.HandleContent())
{
media.GET("/:id/content", fileHandler.GetContent)
}
// -------------------------------------------------------------------------
// Tags (all require auth)
// -------------------------------------------------------------------------
tags := v1.Group("/tags", auth.Handle())
{
tags.GET("", tagHandler.List)
tags.POST("", tagHandler.Create)
tags.GET("/:tag_id", tagHandler.Get)
tags.PATCH("/:tag_id", tagHandler.Update)
tags.DELETE("/:tag_id", tagHandler.Delete)
tags.GET("/:tag_id/files", tagHandler.ListFiles)
tags.GET("/:tag_id/rules", tagHandler.ListRules)
tags.POST("/:tag_id/rules", tagHandler.CreateRule)
tags.PATCH("/:tag_id/rules/:then_tag_id", tagHandler.PatchRule)
tags.DELETE("/:tag_id/rules/:then_tag_id", tagHandler.DeleteRule)
}
// -------------------------------------------------------------------------
// Categories (all require auth)
// -------------------------------------------------------------------------
categories := v1.Group("/categories", auth.Handle())
{
categories.GET("", categoryHandler.List)
categories.POST("", categoryHandler.Create)
categories.GET("/:category_id", categoryHandler.Get)
categories.PATCH("/:category_id", categoryHandler.Update)
categories.DELETE("/:category_id", categoryHandler.Delete)
categories.GET("/:category_id/tags", categoryHandler.ListTags)
}
// -------------------------------------------------------------------------
// Pools (all require auth)
// -------------------------------------------------------------------------
pools := v1.Group("/pools", auth.Handle())
{
pools.GET("", poolHandler.List)
pools.POST("", poolHandler.Create)
pools.GET("/:pool_id", poolHandler.Get)
pools.PATCH("/:pool_id", poolHandler.Update)
pools.DELETE("/:pool_id", poolHandler.Delete)
pools.POST("/:pool_id/views", poolHandler.RecordView)
// Sub-routes registered before /:pool_id/files to avoid param conflicts.
pools.POST("/:pool_id/files/remove", poolHandler.RemoveFiles)
pools.PUT("/:pool_id/files/reorder", poolHandler.Reorder)
pools.GET("/:pool_id/files", poolHandler.ListFiles)
pools.POST("/:pool_id/files", poolHandler.AddFiles)
}
// -------------------------------------------------------------------------
// Users (auth required; admin checks enforced in handler)
// -------------------------------------------------------------------------
users := v1.Group("/users", auth.Handle())
{
// /users/me must be registered before /:user_id to avoid param capture.
users.GET("/me", userHandler.GetMe)
users.PATCH("/me", userHandler.UpdateMe)
users.GET("", userHandler.List)
users.POST("", userHandler.Create)
users.GET("/:user_id", userHandler.Get)
users.PATCH("/:user_id", userHandler.UpdateAdmin)
users.DELETE("/:user_id", userHandler.Delete)
}
// -------------------------------------------------------------------------
// ACL (auth required)
// -------------------------------------------------------------------------
acl := v1.Group("/acl", auth.Handle())
{
acl.GET("/:object_type/:object_id", aclHandler.GetPermissions)
acl.PUT("/:object_type/:object_id", aclHandler.SetPermissions)
}
// -------------------------------------------------------------------------
// Audit (auth required; admin check enforced in handler)
// -------------------------------------------------------------------------
v1.GET("/audit", auth.Handle(), auditHandler.List)
// Serve the built single-page app on the same port as the API. When
// staticDir is empty (local development) the Vite dev server serves the UI
// instead, so the API runs standalone and unknown routes 404 normally.
if staticDir != "" {
r.NoRoute(spaHandler(staticDir))
}
return r, nil
}
+23
View File
@@ -0,0 +1,23 @@
package handler
import "testing"
// TestNewRouterRegisters builds the router with typed-nil dependencies to assert
// route registration itself succeeds. Gin panics on a route conflict (e.g. a
// duplicated method+path or an inconsistent wildcard name) during registration,
// before any handler runs — so this catches such mistakes without a database.
// Handlers are never invoked here; method values on nil pointers are fine.
func TestNewRouterRegisters(t *testing.T) {
r, err := NewRouter(
(*AuthMiddleware)(nil), (*AuthHandler)(nil),
(*FileHandler)(nil), (*DuplicateHandler)(nil), (*TagHandler)(nil), (*CategoryHandler)(nil), (*PoolHandler)(nil),
(*UserHandler)(nil), (*ACLHandler)(nil), (*AuditHandler)(nil),
"", nil,
)
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
if r == nil {
t.Fatal("NewRouter returned nil engine")
}
}
+74
View File
@@ -0,0 +1,74 @@
package handler
import (
"mime"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
func init() {
// Go's mime table doesn't know .webmanifest; register it so the PWA manifest
// is served as JSON and isn't rejected by the X-Content-Type-Options header.
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
}
// spaHandler serves the built single-page app from dir. It is wired as the
// router's NoRoute handler, so it only sees requests that matched no API route.
//
// A request whose path maps to a real file on disk is served directly (with
// cache headers tuned to SvelteKit's adapter-static output). Anything else
// falls back to index.html so the client-side router can resolve deep links
// like /pools/123. Unknown /api/ paths return a JSON 404 instead of the HTML
// shell, keeping API error responses machine-readable.
func spaHandler(dir string) gin.HandlerFunc {
indexPath := filepath.Join(dir, "index.html")
return func(c *gin.Context) {
reqPath := c.Request.URL.Path
if strings.HasPrefix(reqPath, "/api/") {
c.JSON(http.StatusNotFound, errorBody{
Code: "not_found",
Message: "resource not found",
})
return
}
// Resolve the request to a path inside dir. Cleaning an absolute path
// collapses any "../" segments before the join, so the result can never
// escape dir — this is the traversal guard.
clean := path.Clean("/" + reqPath)
target := filepath.Join(dir, filepath.FromSlash(clean))
if info, err := os.Stat(target); err == nil && !info.IsDir() {
c.Header("Cache-Control", cacheControl(clean))
c.File(target)
return
}
// SPA fallback: serve the shell, never cached so a new deploy is picked
// up immediately on the next navigation.
c.Header("Cache-Control", "no-cache")
c.File(indexPath)
}
}
// cacheControl returns the Cache-Control value for a served static asset.
// SvelteKit emits content-hashed files under /_app/immutable — those are safe
// to cache forever. The service worker must never be cached, or clients pin to
// a stale shell. Everything else gets a short, revalidated TTL.
func cacheControl(p string) string {
switch {
case strings.HasPrefix(p, "/_app/immutable/"):
return "public, max-age=31536000, immutable"
case p == "/service-worker.js":
return "no-cache"
default:
return "public, max-age=3600"
}
}
+552
View File
@@ -0,0 +1,552 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
"tanabata/backend/internal/service"
)
// TagHandler handles all /tags endpoints.
type TagHandler struct {
tagSvc *service.TagService
fileSvc *service.FileService
}
// NewTagHandler creates a TagHandler.
func NewTagHandler(tagSvc *service.TagService, fileSvc *service.FileService) *TagHandler {
return &TagHandler{tagSvc: tagSvc, fileSvc: fileSvc}
}
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
type tagRuleJSON struct {
WhenTagID string `json:"when_tag_id"`
ThenTagID string `json:"then_tag_id"`
ThenTagName string `json:"then_tag_name"`
IsActive bool `json:"is_active"`
}
func toTagRuleJSON(r domain.TagRule) tagRuleJSON {
return tagRuleJSON{
WhenTagID: r.WhenTagID.String(),
ThenTagID: r.ThenTagID.String(),
ThenTagName: r.ThenTagName,
IsActive: r.IsActive,
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func parseTagID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("tag_id"))
if err != nil {
respondError(c, domain.ErrValidation)
return uuid.UUID{}, false
}
return id, true
}
func parseOffsetParams(c *gin.Context, defaultSort string) port.OffsetParams {
limit := 50
if s := c.Query("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 {
limit = n
}
}
offset := 0
if s := c.Query("offset"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
offset = n
}
}
sort := c.DefaultQuery("sort", defaultSort)
order := c.DefaultQuery("order", "desc")
search := c.Query("search")
return port.OffsetParams{Sort: sort, Order: order, Search: search, Limit: limit, Offset: offset}
}
// ---------------------------------------------------------------------------
// GET /tags
// ---------------------------------------------------------------------------
func (h *TagHandler) List(c *gin.Context) {
params := parseOffsetParams(c, "created")
page, err := h.tagSvc.List(c.Request.Context(), params)
if err != nil {
respondError(c, err)
return
}
items := make([]tagJSON, len(page.Items))
for i, t := range page.Items {
items[i] = toTagJSON(t)
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"total": page.Total,
"offset": page.Offset,
"limit": page.Limit,
})
}
// ---------------------------------------------------------------------------
// POST /tags
// ---------------------------------------------------------------------------
func (h *TagHandler) Create(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Notes *string `json:"notes"`
Color *string `json:"color"`
CategoryID *string `json:"category_id"`
IsPublic *bool `json:"is_public"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
params := service.TagParams{
Name: body.Name,
Notes: body.Notes,
Color: body.Color,
IsPublic: body.IsPublic,
}
if body.CategoryID != nil {
id, err := uuid.Parse(*body.CategoryID)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
params.CategoryID = &id
}
t, err := h.tagSvc.Create(c.Request.Context(), params)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusCreated, toTagJSON(*t))
}
// ---------------------------------------------------------------------------
// GET /tags/:tag_id
// ---------------------------------------------------------------------------
func (h *TagHandler) Get(c *gin.Context) {
id, ok := parseTagID(c)
if !ok {
return
}
t, err := h.tagSvc.Get(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toTagJSON(*t))
}
// ---------------------------------------------------------------------------
// PATCH /tags/:tag_id
// ---------------------------------------------------------------------------
func (h *TagHandler) Update(c *gin.Context) {
id, ok := parseTagID(c)
if !ok {
return
}
// Use a raw map to distinguish "field absent" from "field = null".
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
respondError(c, domain.ErrValidation)
return
}
params := service.TagParams{}
if v, ok := raw["name"]; ok {
if s, ok := v.(string); ok {
params.Name = s
}
}
if _, ok := raw["notes"]; ok {
if raw["notes"] == nil {
params.Notes = ptr("")
} else if s, ok := raw["notes"].(string); ok {
params.Notes = &s
}
}
if _, ok := raw["color"]; ok {
if raw["color"] == nil {
nilStr := ""
params.Color = &nilStr
} else if s, ok := raw["color"].(string); ok {
params.Color = &s
}
}
if _, ok := raw["category_id"]; ok {
if raw["category_id"] == nil {
nilID := uuid.Nil
params.CategoryID = &nilID // signals "unassign"
} else if s, ok := raw["category_id"].(string); ok {
cid, err := uuid.Parse(s)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
params.CategoryID = &cid
}
}
if v, ok := raw["is_public"]; ok {
if b, ok := v.(bool); ok {
params.IsPublic = &b
}
}
t, err := h.tagSvc.Update(c.Request.Context(), id, params)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toTagJSON(*t))
}
// ---------------------------------------------------------------------------
// DELETE /tags/:tag_id
// ---------------------------------------------------------------------------
func (h *TagHandler) Delete(c *gin.Context) {
id, ok := parseTagID(c)
if !ok {
return
}
if err := h.tagSvc.Delete(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// GET /tags/:tag_id/files
// ---------------------------------------------------------------------------
func (h *TagHandler) ListFiles(c *gin.Context) {
id, ok := parseTagID(c)
if !ok {
return
}
limit := 50
if s := c.Query("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 {
limit = n
}
}
// Delegate to file service with a tag filter.
page, err := h.fileSvc.List(c.Request.Context(), domain.FileListParams{
Cursor: c.Query("cursor"),
Direction: "forward",
Limit: limit,
Sort: "created",
Order: "desc",
Filter: "{t=" + id.String() + "}",
})
if err != nil {
respondError(c, err)
return
}
items := make([]fileJSON, len(page.Items))
for i, f := range page.Items {
items[i] = toFileJSON(f)
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"next_cursor": page.NextCursor,
"prev_cursor": page.PrevCursor,
})
}
// ---------------------------------------------------------------------------
// GET /tags/:tag_id/rules
// ---------------------------------------------------------------------------
func (h *TagHandler) ListRules(c *gin.Context) {
id, ok := parseTagID(c)
if !ok {
return
}
rules, err := h.tagSvc.ListRules(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
items := make([]tagRuleJSON, len(rules))
for i, r := range rules {
items[i] = toTagRuleJSON(r)
}
respondJSON(c, http.StatusOK, items)
}
// ---------------------------------------------------------------------------
// POST /tags/:tag_id/rules
// ---------------------------------------------------------------------------
func (h *TagHandler) CreateRule(c *gin.Context) {
whenTagID, ok := parseTagID(c)
if !ok {
return
}
var body struct {
ThenTagID string `json:"then_tag_id" binding:"required"`
IsActive *bool `json:"is_active"`
ApplyToExisting *bool `json:"apply_to_existing"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
thenTagID, err := uuid.Parse(body.ThenTagID)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
isActive := true
if body.IsActive != nil {
isActive = *body.IsActive
}
applyToExisting := true
if body.ApplyToExisting != nil {
applyToExisting = *body.ApplyToExisting
}
rule, err := h.tagSvc.CreateRule(c.Request.Context(), whenTagID, thenTagID, isActive, applyToExisting)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusCreated, toTagRuleJSON(*rule))
}
// ---------------------------------------------------------------------------
// PATCH /tags/:tag_id/rules/:then_tag_id
// ---------------------------------------------------------------------------
func (h *TagHandler) PatchRule(c *gin.Context) {
whenTagID, ok := parseTagID(c)
if !ok {
return
}
thenTagID, err := uuid.Parse(c.Param("then_tag_id"))
if err != nil {
respondError(c, domain.ErrValidation)
return
}
var body struct {
IsActive *bool `json:"is_active"`
ApplyToExisting *bool `json:"apply_to_existing"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.IsActive == nil {
respondError(c, domain.ErrValidation)
return
}
applyToExisting := false
if body.ApplyToExisting != nil {
applyToExisting = *body.ApplyToExisting
}
rule, err := h.tagSvc.SetRuleActive(c.Request.Context(), whenTagID, thenTagID, *body.IsActive, applyToExisting)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toTagRuleJSON(*rule))
}
// ---------------------------------------------------------------------------
// DELETE /tags/:tag_id/rules/:then_tag_id
// ---------------------------------------------------------------------------
func (h *TagHandler) DeleteRule(c *gin.Context) {
whenTagID, ok := parseTagID(c)
if !ok {
return
}
thenTagID, err := uuid.Parse(c.Param("then_tag_id"))
if err != nil {
respondError(c, domain.ErrValidation)
return
}
if err := h.tagSvc.DeleteRule(c.Request.Context(), whenTagID, thenTagID); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// File-tag endpoints wired through TagService
// (called from file routes, shared handler logic lives here)
// ---------------------------------------------------------------------------
// FileListTags handles GET /files/:id/tags.
func (h *TagHandler) FileListTags(c *gin.Context) {
fileID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, domain.ErrValidation)
return
}
if err := h.fileSvc.AuthorizeView(c.Request.Context(), fileID); err != nil {
respondError(c, err)
return
}
tags, err := h.tagSvc.ListFileTags(c.Request.Context(), fileID)
if err != nil {
respondError(c, err)
return
}
items := make([]tagJSON, len(tags))
for i, t := range tags {
items[i] = toTagJSON(t)
}
respondJSON(c, http.StatusOK, items)
}
// FileSetTags handles PUT /files/:id/tags.
func (h *TagHandler) FileSetTags(c *gin.Context) {
fileID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, domain.ErrValidation)
return
}
var body struct {
TagIDs []string `json:"tag_ids" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
tagIDs, err := parseUUIDs(body.TagIDs)
if err != nil {
respondError(c, domain.ErrValidation)
return
}
if err := h.fileSvc.AuthorizeEdit(c.Request.Context(), fileID); err != nil {
respondError(c, err)
return
}
tags, err := h.tagSvc.SetFileTags(c.Request.Context(), fileID, tagIDs)
if err != nil {
respondError(c, err)
return
}
items := make([]tagJSON, len(tags))
for i, t := range tags {
items[i] = toTagJSON(t)
}
respondJSON(c, http.StatusOK, items)
}
// FileAddTag handles PUT /files/:id/tags/:tag_id.
func (h *TagHandler) FileAddTag(c *gin.Context) {
fileID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, domain.ErrValidation)
return
}
tagID, err := uuid.Parse(c.Param("tag_id"))
if err != nil {
respondError(c, domain.ErrValidation)
return
}
if err := h.fileSvc.AuthorizeEdit(c.Request.Context(), fileID); err != nil {
respondError(c, err)
return
}
tags, err := h.tagSvc.AddFileTag(c.Request.Context(), fileID, tagID)
if err != nil {
respondError(c, err)
return
}
items := make([]tagJSON, len(tags))
for i, t := range tags {
items[i] = toTagJSON(t)
}
respondJSON(c, http.StatusOK, items)
}
// FileRemoveTag handles DELETE /files/:id/tags/:tag_id.
func (h *TagHandler) FileRemoveTag(c *gin.Context) {
fileID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, domain.ErrValidation)
return
}
tagID, err := uuid.Parse(c.Param("tag_id"))
if err != nil {
respondError(c, domain.ErrValidation)
return
}
if err := h.fileSvc.AuthorizeEdit(c.Request.Context(), fileID); err != nil {
respondError(c, err)
return
}
if err := h.tagSvc.RemoveFileTag(c.Request.Context(), fileID, tagID); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func ptr(s string) *string { return &s }
+258
View File
@@ -0,0 +1,258 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
"tanabata/backend/internal/service"
)
// UserHandler handles all /users endpoints.
type UserHandler struct {
userSvc *service.UserService
}
// NewUserHandler creates a UserHandler.
func NewUserHandler(userSvc *service.UserService) *UserHandler {
return &UserHandler{userSvc: userSvc}
}
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
type userJSON struct {
ID int16 `json:"id"`
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
CanCreate bool `json:"can_create"`
IsBlocked bool `json:"is_blocked"`
}
func toUserJSON(u domain.User) userJSON {
return userJSON{
ID: u.ID,
Name: u.Name,
IsAdmin: u.IsAdmin,
CanCreate: u.CanCreate,
IsBlocked: u.IsBlocked,
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func requireAdmin(c *gin.Context) bool {
_, isAdmin, _ := domain.UserFromContext(c.Request.Context())
if !isAdmin {
respondError(c, domain.ErrForbidden)
return false
}
return true
}
func parseUserID(c *gin.Context) (int16, bool) {
n, err := strconv.ParseInt(c.Param("user_id"), 10, 16)
if err != nil {
respondError(c, domain.ErrValidation)
return 0, false
}
return int16(n), true
}
// ---------------------------------------------------------------------------
// GET /users/me
// ---------------------------------------------------------------------------
func (h *UserHandler) GetMe(c *gin.Context) {
u, err := h.userSvc.GetMe(c.Request.Context())
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toUserJSON(*u))
}
// ---------------------------------------------------------------------------
// PATCH /users/me
// ---------------------------------------------------------------------------
func (h *UserHandler) UpdateMe(c *gin.Context) {
var body struct {
Name string `json:"name"`
Password *string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
updated, err := h.userSvc.UpdateMe(c.Request.Context(), service.UpdateMeParams{
Name: body.Name,
Password: body.Password,
})
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toUserJSON(*updated))
}
// ---------------------------------------------------------------------------
// GET /users (admin)
// ---------------------------------------------------------------------------
func (h *UserHandler) List(c *gin.Context) {
if !requireAdmin(c) {
return
}
params := port.OffsetParams{
Sort: c.DefaultQuery("sort", "id"),
Order: c.DefaultQuery("order", "asc"),
}
if s := c.Query("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
params.Limit = n
}
}
if s := c.Query("offset"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
params.Offset = n
}
}
page, err := h.userSvc.List(c.Request.Context(), params)
if err != nil {
respondError(c, err)
return
}
items := make([]userJSON, len(page.Items))
for i, u := range page.Items {
items[i] = toUserJSON(u)
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"total": page.Total,
"offset": page.Offset,
"limit": page.Limit,
})
}
// ---------------------------------------------------------------------------
// POST /users (admin)
// ---------------------------------------------------------------------------
func (h *UserHandler) Create(c *gin.Context) {
if !requireAdmin(c) {
return
}
var body struct {
Name string `json:"name" binding:"required"`
Password string `json:"password" binding:"required"`
IsAdmin bool `json:"is_admin"`
CanCreate bool `json:"can_create"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
created, err := h.userSvc.Create(c.Request.Context(), service.CreateUserParams{
Name: body.Name,
Password: body.Password,
IsAdmin: body.IsAdmin,
CanCreate: body.CanCreate,
})
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusCreated, toUserJSON(*created))
}
// ---------------------------------------------------------------------------
// GET /users/:user_id (admin)
// ---------------------------------------------------------------------------
func (h *UserHandler) Get(c *gin.Context) {
if !requireAdmin(c) {
return
}
id, ok := parseUserID(c)
if !ok {
return
}
u, err := h.userSvc.Get(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toUserJSON(*u))
}
// ---------------------------------------------------------------------------
// PATCH /users/:user_id (admin)
// ---------------------------------------------------------------------------
func (h *UserHandler) UpdateAdmin(c *gin.Context) {
if !requireAdmin(c) {
return
}
id, ok := parseUserID(c)
if !ok {
return
}
var body struct {
IsAdmin *bool `json:"is_admin"`
CanCreate *bool `json:"can_create"`
IsBlocked *bool `json:"is_blocked"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
updated, err := h.userSvc.UpdateAdmin(c.Request.Context(), id, service.UpdateAdminParams{
IsAdmin: body.IsAdmin,
CanCreate: body.CanCreate,
IsBlocked: body.IsBlocked,
})
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toUserJSON(*updated))
}
// ---------------------------------------------------------------------------
// DELETE /users/:user_id (admin)
// ---------------------------------------------------------------------------
func (h *UserHandler) Delete(c *gin.Context) {
if !requireAdmin(c) {
return
}
id, ok := parseUserID(c)
if !ok {
return
}
if err := h.userSvc.Delete(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+70
View File
@@ -0,0 +1,70 @@
// Package imagehash computes a 64-bit perceptual hash (dHash) of an image and
// compares two hashes by Hamming distance. It is used for near-duplicate
// detection: visually similar images (re-encoded, resized, recompressed) produce
// hashes a small distance apart, while unrelated images are far apart.
//
// dHash is chosen for its robustness and simplicity: the image is reduced to a
// 9×8 grayscale and each pixel is compared to its right-hand neighbour, yielding
// 64 gradient-direction bits. It tolerates scaling and brightness/contrast
// changes well, which is exactly what re-encoded duplicates exhibit.
package imagehash
import (
"bytes"
"image"
_ "image/gif" // register GIF decoder
_ "image/jpeg" // register JPEG decoder
_ "image/png" // register PNG decoder
"math/bits"
"github.com/disintegration/imaging"
_ "golang.org/x/image/webp" // register WebP decoder
)
// hashWidth/hashHeight define the reduced grayscale used for dHash. The extra
// column (width = height+1) provides the right-hand neighbour for the 64
// horizontal comparisons that make up the hash.
const (
hashHeight = 8
hashWidth = hashHeight + 1
)
// FromImage reduces img to a 9×8 grayscale and returns its 64-bit dHash. The
// uint64 of gradient bits is returned as int64 (a plain bit reinterpretation) so
// it fits PostgreSQL's bigint; equality and Distance are bitwise, so the signed
// interpretation never matters.
func FromImage(img image.Image) int64 {
small := imaging.Grayscale(imaging.Resize(img, hashWidth, hashHeight, imaging.Lanczos))
var hash uint64
bit := 0
for y := 0; y < hashHeight; y++ {
for x := 0; x < hashHeight; x++ {
// After Grayscale, R == G == B, so the red channel is the luminance.
left := small.Pix[small.PixOffset(x, y)]
right := small.Pix[small.PixOffset(x+1, y)]
if left < right {
hash |= 1 << uint(63-bit)
}
bit++
}
}
return int64(hash)
}
// FromBytes decodes data (JPEG/PNG/GIF/WebP) and returns its dHash. ok is false
// when the bytes are not a decodable image, so callers can simply skip hashing
// (e.g. leave phash NULL) rather than fail.
func FromBytes(data []byte) (hash int64, ok bool) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return 0, false
}
return FromImage(img), true
}
// Distance returns the Hamming distance (064) between two hashes: the number of
// differing bits. 0 means identical; small values mean near-duplicate.
func Distance(a, b int64) int {
return bits.OnesCount64(uint64(a) ^ uint64(b))
}
@@ -0,0 +1,99 @@
package imagehash
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"math"
"testing"
)
// radial renders a smooth grayscale image whose brightness falls off with
// distance from (cx, cy). Smooth gradients are the realistic case for perceptual
// hashing and survive JPEG re-encoding well, so they make stable test fixtures.
func radial(w, h int, cx, cy float64) image.Image {
img := image.NewRGBA(image.Rect(0, 0, w, h))
maxD := math.Hypot(float64(w), float64(h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
d := math.Hypot(float64(x)-cx, float64(y)-cy)
v := uint8(255 * (1 - d/maxD))
img.Set(x, y, color.RGBA{v, v, v, 255})
}
}
return img
}
func encodePNG(t *testing.T, img image.Image) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("png encode: %v", err)
}
return buf.Bytes()
}
func encodeJPEG(t *testing.T, img image.Image, quality int) []byte {
t.Helper()
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
t.Fatalf("jpeg encode: %v", err)
}
return buf.Bytes()
}
// The same image re-encoded as PNG (lossless) and JPEG (lossy) must hash to a
// small Hamming distance — that is the whole point of a perceptual hash.
func TestFromBytes_SameImageAcrossEncodings(t *testing.T) {
img := radial(64, 64, 32, 32)
pngHash, ok := FromBytes(encodePNG(t, img))
if !ok {
t.Fatal("FromBytes(PNG): ok=false")
}
jpgHash, ok := FromBytes(encodeJPEG(t, img, 90))
if !ok {
t.Fatal("FromBytes(JPEG): ok=false")
}
if d := Distance(pngHash, jpgHash); d > 8 {
t.Errorf("same image, different encodings: distance = %d, want <= 8", d)
}
}
// Visually different images must be far apart, and clearly farther than the same
// image across encodings.
func TestDistance_DifferentImagesAreFarApart(t *testing.T) {
a := FromImage(radial(64, 64, 32, 32)) // centred
b := FromImage(radial(64, 64, 0, 0)) // corner
same, _ := FromBytes(encodeJPEG(t, radial(64, 64, 32, 32), 90))
d := Distance(a, b)
if d < 12 {
t.Errorf("different images: distance = %d, want >= 12", d)
}
if d <= Distance(a, same) {
t.Errorf("different images (%d) not farther than re-encoded same image (%d)", d, Distance(a, same))
}
}
func TestDistance_SymmetricAndZeroForEqual(t *testing.T) {
a := FromImage(radial(64, 64, 20, 40))
b := FromImage(radial(64, 64, 40, 20))
if Distance(a, a) != 0 {
t.Errorf("Distance(a, a) = %d, want 0", Distance(a, a))
}
if Distance(a, b) != Distance(b, a) {
t.Errorf("Distance not symmetric: %d vs %d", Distance(a, b), Distance(b, a))
}
}
func TestFromBytes_RejectsNonImage(t *testing.T) {
if _, ok := FromBytes([]byte("definitely not an image")); ok {
t.Error("FromBytes on garbage: ok=true, want false")
}
}
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
package port
import (
"context"
"time"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
)
// Transactor executes fn inside a single database transaction.
// All repository calls made within fn receive the transaction via context.
type Transactor interface {
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
}
// OffsetParams holds common offset-pagination and sort parameters.
type OffsetParams struct {
Sort string
Order string // "asc" | "desc"
Search string
Offset int
Limit int
// Visibility — populated by the service from the request context. When
// ViewerIsAdmin is false the repository restricts results to rows the viewer
// may see (public, owned, or explicitly granted). Ignored by user listing,
// which is admin-only.
ViewerID int16
ViewerIsAdmin bool
}
// PoolFileListParams holds parameters for listing files inside a pool.
type PoolFileListParams struct {
Cursor string
Limit int
Filter string // filter DSL expression
}
// FileRepo is the persistence interface for file records.
type FileRepo interface {
// List returns a cursor-based page of files.
List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error)
// GetByID returns the file with its tags loaded.
GetByID(ctx context.Context, id uuid.UUID) (*domain.File, error)
// Create inserts a new file record and returns it.
Create(ctx context.Context, f *domain.File) (*domain.File, error)
// Update applies partial metadata changes and returns the updated record.
Update(ctx context.Context, id uuid.UUID, f *domain.File) (*domain.File, error)
// SetNeedsReview sets the review status on the given (non-trashed) files.
SetNeedsReview(ctx context.Context, ids []uuid.UUID, value bool) error
// SetPHash sets (or clears, when nil) the perceptual hash of a file.
SetPHash(ctx context.Context, id uuid.UUID, phash *int64) error
// ListMissingPHash returns live image/video files that have no perceptual
// hash yet (the dedup backfill work list).
ListMissingPHash(ctx context.Context) ([]domain.File, error)
// ListAllPHashes returns the id and perceptual hash of every live, hashed
// file (the global input to the dedup rescan; not ACL-filtered).
ListAllPHashes(ctx context.Context) ([]domain.PHashEntry, error)
// CopyPoolMemberships adds targetID to every pool sourceID belongs to,
// skipping pools target is already in (used by the duplicate merge).
CopyPoolMemberships(ctx context.Context, targetID, sourceID uuid.UUID) error
// SoftDelete moves a file to trash (sets is_deleted = true).
SoftDelete(ctx context.Context, id uuid.UUID) error
// Restore moves a file out of trash (sets is_deleted = false).
Restore(ctx context.Context, id uuid.UUID) (*domain.File, error)
// DeletePermanent removes a file record. Only allowed when is_deleted = true.
DeletePermanent(ctx context.Context, id uuid.UUID) error
// ListTags returns all tags assigned to a file.
ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error)
// SetTags replaces all tags on a file (full replace semantics).
SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error
// RecordView appends a view-history row (activity.file_views) for the user.
RecordView(ctx context.Context, fileID uuid.UUID, userID int16) error
// RecordTagUses logs the tags referenced in a filter DSL to
// activity.tag_uses, flagging each included or excluded. Best-effort
// analytics — callers may ignore the error.
RecordTagUses(ctx context.Context, userID int16, filterDSL string) error
}
// DuplicatePairRepo persists the precomputed near-duplicate candidate pairs.
type DuplicatePairRepo interface {
// ReplaceAll atomically replaces the whole pairs table (used by the rescan).
ReplaceAll(ctx context.Context, pairs []domain.DuplicatePair) error
// ListVisible returns pairs whose both files are live, not dismissed, and
// (for non-admins) visible to the viewer.
ListVisible(ctx context.Context, viewerID int16, isAdmin bool) ([]domain.DuplicatePair, error)
}
// DismissalRepo persists "not a duplicate" decisions.
type DismissalRepo interface {
// Add records a pair as dismissed (canonical order, idempotent).
Add(ctx context.Context, a, b uuid.UUID, userID int16) error
}
// TagRepo is the persistence interface for tags.
type TagRepo interface {
List(ctx context.Context, params OffsetParams) (*domain.TagOffsetPage, error)
// ListByCategory returns tags belonging to a specific category.
ListByCategory(ctx context.Context, categoryID uuid.UUID, params OffsetParams) (*domain.TagOffsetPage, error)
GetByID(ctx context.Context, id uuid.UUID) (*domain.Tag, error)
Create(ctx context.Context, t *domain.Tag) (*domain.Tag, error)
Update(ctx context.Context, id uuid.UUID, t *domain.Tag) (*domain.Tag, error)
Delete(ctx context.Context, id uuid.UUID) error
// ListByFile returns all tags assigned to a specific file, ordered by name.
ListByFile(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error)
// AddFileTag inserts a single file→tag relation. No-op if already present.
AddFileTag(ctx context.Context, fileID, tagID uuid.UUID) error
// RemoveFileTag deletes a single file→tag relation.
RemoveFileTag(ctx context.Context, fileID, tagID uuid.UUID) error
// SetFileTags replaces all tags on a file (full replace semantics).
SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error
// CommonTagsForFiles returns tags present on every one of the given files.
CommonTagsForFiles(ctx context.Context, fileIDs []uuid.UUID) ([]domain.Tag, error)
// PartialTagsForFiles returns tags present on some but not all of the given files.
PartialTagsForFiles(ctx context.Context, fileIDs []uuid.UUID) ([]domain.Tag, error)
}
// TagRuleRepo is the persistence interface for auto-tag rules.
type TagRuleRepo interface {
// ListByTag returns all rules where WhenTagID == tagID.
ListByTag(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error)
Create(ctx context.Context, r domain.TagRule) (*domain.TagRule, error)
// SetActive toggles a rule's is_active flag. When active and applyToExisting
// are both true, the full transitive expansion of thenTagID is retroactively
// applied to all files that already carry whenTagID.
SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active, applyToExisting bool) error
// ApplyToExisting retroactively applies the full transitive expansion of
// thenTagID (following active rules) to every file that already carries
// whenTagID. Used when a rule is created or activated with apply_to_existing.
ApplyToExisting(ctx context.Context, whenTagID, thenTagID uuid.UUID) error
Delete(ctx context.Context, whenTagID, thenTagID uuid.UUID) error
}
// CategoryRepo is the persistence interface for categories.
type CategoryRepo interface {
List(ctx context.Context, params OffsetParams) (*domain.CategoryOffsetPage, error)
GetByID(ctx context.Context, id uuid.UUID) (*domain.Category, error)
Create(ctx context.Context, c *domain.Category) (*domain.Category, error)
Update(ctx context.Context, id uuid.UUID, c *domain.Category) (*domain.Category, error)
Delete(ctx context.Context, id uuid.UUID) error
}
// PoolRepo is the persistence interface for pools and poolfile membership.
type PoolRepo interface {
List(ctx context.Context, params OffsetParams) (*domain.PoolOffsetPage, error)
GetByID(ctx context.Context, id uuid.UUID) (*domain.Pool, error)
Create(ctx context.Context, p *domain.Pool) (*domain.Pool, error)
Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*domain.Pool, error)
Delete(ctx context.Context, id uuid.UUID) error
// ListFiles returns pool files ordered by position (cursor-based).
ListFiles(ctx context.Context, poolID uuid.UUID, params PoolFileListParams) (*domain.PoolFilePage, error)
// AddFiles appends files starting at position; nil position means append at end.
AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error
// RemoveFiles removes files from the pool.
RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
// Reorder sets the full ordered sequence of file IDs in the pool.
Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
// RecordView appends a view-history row (activity.pool_views) for the user.
RecordView(ctx context.Context, poolID uuid.UUID, userID int16) error
}
// UserRepo is the persistence interface for users.
type UserRepo interface {
List(ctx context.Context, params OffsetParams) (*domain.UserPage, error)
GetByID(ctx context.Context, id int16) (*domain.User, error)
// GetByName is used during login to look up credentials.
GetByName(ctx context.Context, name string) (*domain.User, error)
Create(ctx context.Context, u *domain.User) (*domain.User, error)
Update(ctx context.Context, id int16, u *domain.User) (*domain.User, error)
Delete(ctx context.Context, id int16) error
}
// SessionRepo is the persistence interface for auth sessions.
type SessionRepo interface {
// ListByUser returns all active sessions for a user.
ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error)
// GetByID returns an active session by its ID, or ErrNotFound if it does not
// exist or has been deactivated.
GetByID(ctx context.Context, id int) (*domain.Session, error)
// GetByTokenHash looks up a session by the hashed refresh token.
GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error)
Create(ctx context.Context, s *domain.Session) (*domain.Session, error)
// UpdateLastActivity refreshes the last_activity timestamp.
UpdateLastActivity(ctx context.Context, id int, t time.Time) error
// Delete terminates a single session.
Delete(ctx context.Context, id int) error
// DeleteByUserID terminates all sessions for a user (logout everywhere).
DeleteByUserID(ctx context.Context, userID int16) error
}
// ACLRepo is the persistence interface for per-object permissions.
type ACLRepo interface {
// List returns all permission entries for a given object.
List(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error)
// Get returns the permission entry for a specific user and object; returns
// ErrNotFound if no entry exists.
Get(ctx context.Context, userID int16, objectTypeID int16, objectID uuid.UUID) (*domain.Permission, error)
// Set replaces all permissions for an object (full replace semantics).
Set(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error
}
// AuditRepo is the persistence interface for the audit log.
type AuditRepo interface {
Log(ctx context.Context, entry domain.AuditEntry) error
List(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error)
}
// MimeRepo is the persistence interface for the MIME type whitelist.
type MimeRepo interface {
// List returns all supported MIME types.
List(ctx context.Context) ([]domain.MIMEType, error)
// GetByName returns the MIME type record for a given MIME name (e.g. "image/jpeg").
// Returns ErrUnsupportedMIME if not in the whitelist.
GetByName(ctx context.Context, name string) (*domain.MIMEType, error)
}
+35
View File
@@ -0,0 +1,35 @@
package port
import (
"context"
"io"
"github.com/google/uuid"
)
// FileStorage abstracts disk (or object-store) operations for file content,
// thumbnails, and previews.
type FileStorage interface {
// Save writes the reader's content to storage and returns the number of
// bytes written.
Save(ctx context.Context, id uuid.UUID, r io.Reader) (int64, error)
// Read opens the file content for reading. The caller must close the returned
// ReadCloser.
Read(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
// Delete removes the file content from storage.
Delete(ctx context.Context, id uuid.UUID) error
// InvalidateCache removes any cached thumbnail/preview for the file so they
// are regenerated from the current content on next request.
InvalidateCache(ctx context.Context, id uuid.UUID) error
// Thumbnail opens the pre-generated thumbnail (JPEG). Returns ErrNotFound
// if the thumbnail has not been generated yet.
Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
// Preview opens the pre-generated preview image (JPEG). Returns ErrNotFound
// if the preview has not been generated yet.
Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
}
+176
View File
@@ -0,0 +1,176 @@
package service
import (
"context"
"errors"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// ACLService handles access control checks and permission management.
type ACLService struct {
aclRepo port.ACLRepo
files port.FileRepo
tags port.TagRepo
categories port.CategoryRepo
pools port.PoolRepo
tx port.Transactor
}
// NewACLService creates an ACLService. The object repositories are used to
// resolve an object's owner when authorizing permission management.
func NewACLService(
aclRepo port.ACLRepo,
files port.FileRepo,
tags port.TagRepo,
categories port.CategoryRepo,
pools port.PoolRepo,
tx port.Transactor,
) *ACLService {
return &ACLService{
aclRepo: aclRepo,
files: files,
tags: tags,
categories: categories,
pools: pools,
tx: tx,
}
}
// CanView returns true if the user may view the object.
// isAdmin, creatorID, isPublic must be populated from the object record by the caller.
func (s *ACLService) CanView(
ctx context.Context,
userID int16, isAdmin bool,
creatorID int16, isPublic bool,
objectTypeID int16, objectID uuid.UUID,
) (bool, error) {
if isAdmin {
return true, nil
}
if isPublic {
return true, nil
}
if userID == creatorID {
return true, nil
}
perm, err := s.aclRepo.Get(ctx, userID, objectTypeID, objectID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return false, nil
}
return false, err
}
return perm.CanView, nil
}
// CanEdit returns true if the user may edit the object.
// is_public does not grant edit access; only admins, creators, and explicit grants.
func (s *ACLService) CanEdit(
ctx context.Context,
userID int16, isAdmin bool,
creatorID int16,
objectTypeID int16, objectID uuid.UUID,
) (bool, error) {
if isAdmin {
return true, nil
}
if userID == creatorID {
return true, nil
}
perm, err := s.aclRepo.Get(ctx, userID, objectTypeID, objectID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return false, nil
}
return false, err
}
return perm.CanEdit, nil
}
// GetPermissions returns all explicit ACL entries for an object. Only the
// object's owner or an admin may inspect its permission list.
func (s *ACLService) GetPermissions(
ctx context.Context,
userID int16, isAdmin bool,
objectTypeID int16, objectID uuid.UUID,
) ([]domain.Permission, error) {
if err := s.authorizeManage(ctx, userID, isAdmin, objectTypeID, objectID); err != nil {
return nil, err
}
return s.aclRepo.List(ctx, objectTypeID, objectID)
}
// SetPermissions replaces all ACL entries for an object (full replace semantics).
// Only the object's owner or an admin may change its permissions. The replace is
// performed atomically inside a single transaction.
func (s *ACLService) SetPermissions(
ctx context.Context,
userID int16, isAdmin bool,
objectTypeID int16, objectID uuid.UUID,
perms []domain.Permission,
) error {
if err := s.authorizeManage(ctx, userID, isAdmin, objectTypeID, objectID); err != nil {
return err
}
return s.tx.WithTx(ctx, func(ctx context.Context) error {
return s.aclRepo.Set(ctx, objectTypeID, objectID, perms)
})
}
// authorizeManage returns nil if the user may manage the object's ACL
// (admin or owner), ErrForbidden otherwise, or a propagated lookup error
// (including ErrNotFound when the object does not exist).
func (s *ACLService) authorizeManage(
ctx context.Context,
userID int16, isAdmin bool,
objectTypeID int16, objectID uuid.UUID,
) error {
if isAdmin {
return nil
}
owner, err := s.objectOwner(ctx, objectTypeID, objectID)
if err != nil {
return err
}
if owner != userID {
return domain.ErrForbidden
}
return nil
}
// objectOwner resolves the creator ID of the object identified by
// (objectTypeID, objectID). Returns ErrNotFound if the object does not exist.
func (s *ACLService) objectOwner(ctx context.Context, objectTypeID int16, objectID uuid.UUID) (int16, error) {
switch objectTypeID {
case fileObjectTypeID:
obj, err := s.files.GetByID(ctx, objectID)
if err != nil {
return 0, err
}
return obj.CreatorID, nil
case tagObjectTypeID:
obj, err := s.tags.GetByID(ctx, objectID)
if err != nil {
return 0, err
}
return obj.CreatorID, nil
case categoryObjectTypeID:
obj, err := s.categories.GetByID(ctx, objectID)
if err != nil {
return 0, err
}
return obj.CreatorID, nil
case poolObjectTypeID:
obj, err := s.pools.GetByID(ctx, objectID)
if err != nil {
return 0, err
}
return obj.CreatorID, nil
default:
return 0, domain.ErrValidation
}
}
+57
View File
@@ -0,0 +1,57 @@
package service
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// AuditService records user actions to the audit trail.
type AuditService struct {
repo port.AuditRepo
}
func NewAuditService(repo port.AuditRepo) *AuditService {
return &AuditService{repo: repo}
}
// Log records an action performed by the user in ctx.
// objectType and objectID are optional — pass nil when the action has no target object.
// details can be any JSON-serializable value, or nil.
func (s *AuditService) Log(
ctx context.Context,
action string,
objectType *string,
objectID *uuid.UUID,
details any,
) error {
userID, _, _ := domain.UserFromContext(ctx)
var raw json.RawMessage
if details != nil {
b, err := json.Marshal(details)
if err != nil {
return fmt.Errorf("AuditService.Log marshal details: %w", err)
}
raw = b
}
entry := domain.AuditEntry{
UserID: userID,
Action: action,
ObjectType: objectType,
ObjectID: objectID,
Details: raw,
}
return s.repo.Log(ctx, entry)
}
// Query returns a filtered, paginated page of audit log entries.
func (s *AuditService) Query(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error) {
return s.repo.List(ctx, filter)
}
+357
View File
@@ -0,0 +1,357 @@
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// Token types distinguish short-lived access tokens from long-lived refresh
// tokens so the two cannot be substituted for one another.
const (
tokenTypeAccess = "access"
tokenTypeRefresh = "refresh"
// tokenTypeContent is a file-scoped capability for reading one file's
// content by URL (originals / media streaming). It is not tied to a session,
// so it outlives the short access TTL and refresh rotation — letting a long
// video keep playing past access-token expiry.
tokenTypeContent = "content"
)
// dummyPasswordHash is a valid bcrypt hash used to equalise the cost of a login
// attempt against a non-existent user, preventing username enumeration via
// response timing. It is the hash of a random string no one knows.
const dummyPasswordHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
// Claims is the JWT payload for both access and refresh tokens.
type Claims struct {
jwt.RegisteredClaims
UserID int16 `json:"uid"`
IsAdmin bool `json:"adm"`
SessionID int `json:"sid"`
TokenType string `json:"typ"`
// FileID scopes a content token to a single file; empty on access/refresh.
FileID string `json:"fid,omitempty"`
}
// TokenPair holds an issued access/refresh token pair with the access TTL.
type TokenPair struct {
AccessToken string
RefreshToken string
ExpiresIn int // access token TTL in seconds
}
// AuthService handles authentication and session lifecycle.
type AuthService struct {
users port.UserRepo
sessions port.SessionRepo
secret []byte
accessTTL time.Duration
refreshTTL time.Duration
contentTTL time.Duration
}
// NewAuthService creates an AuthService.
func NewAuthService(
users port.UserRepo,
sessions port.SessionRepo,
jwtSecret string,
accessTTL time.Duration,
refreshTTL time.Duration,
contentTTL time.Duration,
) *AuthService {
return &AuthService{
users: users,
sessions: sessions,
secret: []byte(jwtSecret),
accessTTL: accessTTL,
refreshTTL: refreshTTL,
contentTTL: contentTTL,
}
}
// Login validates credentials, creates a session, and returns a token pair.
func (s *AuthService) Login(ctx context.Context, name, password, userAgent string) (*TokenPair, error) {
user, err := s.users.GetByName(ctx, name)
if err != nil {
// Compare against a dummy hash so a missing user costs the same as a
// wrong password, and return ErrUnauthorized either way to avoid
// username enumeration.
_ = bcrypt.CompareHashAndPassword([]byte(dummyPasswordHash), []byte(password))
return nil, domain.ErrUnauthorized
}
// Verify the password before disclosing anything about account state, so a
// caller cannot distinguish "blocked" from "wrong password".
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return nil, domain.ErrUnauthorized
}
if user.IsBlocked {
return nil, domain.ErrForbidden
}
return s.issuePair(ctx, user, userAgent)
}
// Logout deactivates the session identified by sessionID.
func (s *AuthService) Logout(ctx context.Context, sessionID int) error {
if err := s.sessions.Delete(ctx, sessionID); err != nil {
return fmt.Errorf("logout: %w", err)
}
return nil
}
// Refresh validates a refresh token, issues a new token pair, and deactivates
// the old session.
func (s *AuthService) Refresh(ctx context.Context, refreshToken, userAgent string) (*TokenPair, error) {
claims, err := s.parseToken(refreshToken)
if err != nil || claims.TokenType != tokenTypeRefresh {
return nil, domain.ErrUnauthorized
}
session, err := s.sessions.GetByTokenHash(ctx, hashToken(refreshToken))
if err != nil {
return nil, domain.ErrUnauthorized
}
if session.ExpiresAt != nil && time.Now().After(*session.ExpiresAt) {
_ = s.sessions.Delete(ctx, session.ID)
return nil, domain.ErrUnauthorized
}
// Rotate: deactivate old session.
if err := s.sessions.Delete(ctx, session.ID); err != nil {
return nil, fmt.Errorf("deactivate old session: %w", err)
}
user, err := s.users.GetByID(ctx, claims.UserID)
if err != nil {
return nil, domain.ErrUnauthorized
}
if user.IsBlocked {
return nil, domain.ErrForbidden
}
return s.issuePair(ctx, user, userAgent)
}
// issuePair creates a session and the access/refresh token pair for user.
//
// The refresh token is issued first and its hash is stored as the session's
// identity; the refresh token is located on /refresh purely by that hash, so it
// carries no session ID. The access token then embeds the real session ID so it
// can be revoked on logout. Because the stored hash is the hash of the token
// actually returned, /refresh works (unlike the previous re-issue approach).
func (s *AuthService) issuePair(ctx context.Context, user *domain.User, userAgent string) (*TokenPair, error) {
var expiresAt *time.Time
if s.refreshTTL > 0 {
t := time.Now().Add(s.refreshTTL)
expiresAt = &t
}
refreshToken, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL, tokenTypeRefresh)
if err != nil {
return nil, fmt.Errorf("issue refresh token: %w", err)
}
session, err := s.sessions.Create(ctx, &domain.Session{
TokenHash: hashToken(refreshToken),
UserID: user.ID,
UserAgent: userAgent,
ExpiresAt: expiresAt,
})
if err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
accessToken, err := s.issueToken(user.ID, user.IsAdmin, session.ID, s.accessTTL, tokenTypeAccess)
if err != nil {
return nil, fmt.Errorf("issue access token: %w", err)
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(s.accessTTL.Seconds()),
}, nil
}
// ListSessions returns all active sessions for the given user.
func (s *AuthService) ListSessions(ctx context.Context, userID int16, currentSessionID int) (*domain.SessionList, error) {
list, err := s.sessions.ListByUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list sessions: %w", err)
}
for i := range list.Items {
list.Items[i].IsCurrent = list.Items[i].ID == currentSessionID
}
return list, nil
}
// TerminateSession deactivates a specific session, enforcing ownership.
func (s *AuthService) TerminateSession(ctx context.Context, callerID int16, isAdmin bool, sessionID int) error {
if !isAdmin {
list, err := s.sessions.ListByUser(ctx, callerID)
if err != nil {
return fmt.Errorf("terminate session: %w", err)
}
owned := false
for _, sess := range list.Items {
if sess.ID == sessionID {
owned = true
break
}
}
if !owned {
return domain.ErrForbidden
}
}
if err := s.sessions.Delete(ctx, sessionID); err != nil {
return fmt.Errorf("terminate session: %w", err)
}
return nil
}
// ValidateAccessToken parses and validates an access token, returning its
// claims. A refresh token is rejected (wrong type), and the token's session
// must still be active — so logout, session termination, an admin block, or a
// refresh rotation revoke any outstanding access tokens immediately rather than
// only at expiry.
func (s *AuthService) ValidateAccessToken(ctx context.Context, tokenStr string) (*Claims, error) {
claims, err := s.parseToken(tokenStr)
if err != nil {
return nil, domain.ErrUnauthorized
}
if claims.TokenType != tokenTypeAccess {
return nil, domain.ErrUnauthorized
}
if _, err := s.sessions.GetByID(ctx, claims.SessionID); err != nil {
return nil, domain.ErrUnauthorized
}
return claims, nil
}
// GenerateContentToken issues a file-scoped capability token authorizing reads of
// one file's content (originals / media streaming) by URL. Unlike the access
// token it carries no session and is not validated against one, so it survives
// refresh rotation and outlives the short access TTL — which is what lets a long
// video keep playing. It is a bearer credential for that single file until
// ContentTokenTTL elapses. Returns the signed token and its lifetime in seconds.
func (s *AuthService) GenerateContentToken(fileID string, userID int16, isAdmin bool) (string, int, error) {
jti, err := randomJTI()
if err != nil {
return "", 0, err
}
now := time.Now()
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
ID: jti,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(s.contentTTL)),
},
UserID: userID,
IsAdmin: isAdmin,
TokenType: tokenTypeContent,
FileID: fileID,
}
signed, err := s.signClaims(claims)
if err != nil {
return "", 0, err
}
return signed, int(s.contentTTL.Seconds()), nil
}
// ValidateContentToken parses a content token and checks it authorizes fileID.
// It verifies the signature and expiry (via parseToken), the content token type,
// and that the embedded file ID matches the requested file — so a token minted
// for one file cannot read another. It is intentionally session-independent (no
// session lookup), which is what lets it outlive access-token/session rotation.
// Per-file view permission is still enforced downstream against the token's user.
func (s *AuthService) ValidateContentToken(tokenStr, fileID string) (*Claims, error) {
claims, err := s.parseToken(tokenStr)
if err != nil {
return nil, domain.ErrUnauthorized
}
if claims.TokenType != tokenTypeContent || claims.FileID != fileID {
return nil, domain.ErrUnauthorized
}
return claims, nil
}
// issueToken signs a JWT with the given parameters. A random JWT ID guarantees
// uniqueness even for tokens minted within the same second.
func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl time.Duration, tokenType string) (string, error) {
jti, err := randomJTI()
if err != nil {
return "", err
}
now := time.Now()
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
ID: jti,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
UserID: userID,
IsAdmin: isAdmin,
SessionID: sessionID,
TokenType: tokenType,
}
return s.signClaims(claims)
}
// signClaims signs claims into an HS256 JWT with the service secret.
func (s *AuthService) signClaims(claims Claims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(s.secret)
if err != nil {
return "", fmt.Errorf("sign token: %w", err)
}
return signed, nil
}
// parseToken verifies the signature and parses claims from a token string.
func (s *AuthService) parseToken(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return s.secret, nil
})
if err != nil || !token.Valid {
return nil, domain.ErrUnauthorized
}
claims, ok := token.Claims.(*Claims)
if !ok {
return nil, domain.ErrUnauthorized
}
return claims, nil
}
// hashToken returns the SHA-256 hex digest of a token string.
// The raw token is never stored; only the hash goes to the database.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// randomJTI returns a 128-bit random hex string for use as a JWT ID.
func randomJTI() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate jti: %w", err)
}
return hex.EncodeToString(b), nil
}
@@ -0,0 +1,80 @@
package service
import (
"testing"
"time"
)
// newContentTokenService builds an AuthService for content-token tests. The
// content-token methods never touch the user/session repos, so nil is fine.
func newContentTokenService(contentTTL time.Duration) *AuthService {
return NewAuthService(nil, nil, "test-secret", 15*time.Minute, 720*time.Hour, contentTTL)
}
func TestContentTokenRoundTrip(t *testing.T) {
s := newContentTokenService(time.Hour)
const fid = "11111111-1111-1111-1111-111111111111"
tok, expiresIn, err := s.GenerateContentToken(fid, 7, true)
if err != nil {
t.Fatalf("GenerateContentToken: %v", err)
}
if expiresIn != int(time.Hour.Seconds()) {
t.Fatalf("expires_in = %d, want %d", expiresIn, int(time.Hour.Seconds()))
}
claims, err := s.ValidateContentToken(tok, fid)
if err != nil {
t.Fatalf("ValidateContentToken: %v", err)
}
if claims.UserID != 7 || !claims.IsAdmin {
t.Fatalf("claims user mismatch: uid=%d adm=%v", claims.UserID, claims.IsAdmin)
}
if claims.FileID != fid || claims.TokenType != tokenTypeContent {
t.Fatalf("claims scope mismatch: fid=%q typ=%q", claims.FileID, claims.TokenType)
}
}
func TestContentTokenRejectsOtherFile(t *testing.T) {
s := newContentTokenService(time.Hour)
tok, _, err := s.GenerateContentToken("11111111-1111-1111-1111-111111111111", 7, false)
if err != nil {
t.Fatal(err)
}
// A token minted for one file must not authorize another.
if _, err := s.ValidateContentToken(tok, "22222222-2222-2222-2222-222222222222"); err == nil {
t.Fatal("expected rejection for a different file id")
}
}
func TestContentTokenRejectsAccessToken(t *testing.T) {
s := newContentTokenService(time.Hour)
// An ordinary access token must not pass as a content token (wrong type).
access, err := s.issueToken(7, false, 1, 15*time.Minute, tokenTypeAccess)
if err != nil {
t.Fatal(err)
}
if _, err := s.ValidateContentToken(access, ""); err == nil {
t.Fatal("expected rejection of an access token as a content token")
}
}
func TestContentTokenRejectsExpired(t *testing.T) {
// Negative TTL → the token is already expired when minted.
s := newContentTokenService(-time.Minute)
const fid = "11111111-1111-1111-1111-111111111111"
tok, _, err := s.GenerateContentToken(fid, 7, false)
if err != nil {
t.Fatal(err)
}
if _, err := s.ValidateContentToken(tok, fid); err == nil {
t.Fatal("expected rejection of an expired content token")
}
}
func TestContentTokenRejectsGarbage(t *testing.T) {
s := newContentTokenService(time.Hour)
if _, err := s.ValidateContentToken("not-a-jwt", "11111111-1111-1111-1111-111111111111"); err == nil {
t.Fatal("expected rejection of a malformed token")
}
}
@@ -0,0 +1,179 @@
package service
import (
"context"
"encoding/json"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
const categoryObjectType = "category"
const categoryObjectTypeID int16 = 3 // third row in 007_seed_data.sql object_types
// CategoryParams holds the fields for creating or patching a category.
type CategoryParams struct {
Name string
Notes *string
Color *string // nil = no change; pointer to empty string = clear
Metadata json.RawMessage
IsPublic *bool
}
// CategoryService handles category CRUD with ACL enforcement and audit logging.
type CategoryService struct {
categories port.CategoryRepo
tags port.TagRepo
acl *ACLService
audit *AuditService
}
// NewCategoryService creates a CategoryService.
func NewCategoryService(
categories port.CategoryRepo,
tags port.TagRepo,
acl *ACLService,
audit *AuditService,
) *CategoryService {
return &CategoryService{
categories: categories,
tags: tags,
acl: acl,
audit: audit,
}
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
// List returns a paginated list of categories the caller may see.
func (s *CategoryService) List(ctx context.Context, params port.OffsetParams) (*domain.CategoryOffsetPage, error) {
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
return s.categories.List(ctx, params)
}
// Get returns a category by ID, enforcing view ACL.
func (s *CategoryService) Get(ctx context.Context, id uuid.UUID) (*domain.Category, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
c, err := s.categories.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, c.CreatorID, c.IsPublic, categoryObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
return c, nil
}
// Create inserts a new category record.
func (s *CategoryService) Create(ctx context.Context, p CategoryParams) (*domain.Category, error) {
userID, _, _ := domain.UserFromContext(ctx)
c := &domain.Category{
Name: p.Name,
Notes: p.Notes,
Color: p.Color,
Metadata: p.Metadata,
CreatorID: userID,
}
if p.IsPublic != nil {
c.IsPublic = *p.IsPublic
}
created, err := s.categories.Create(ctx, c)
if err != nil {
return nil, err
}
objType := categoryObjectType
_ = s.audit.Log(ctx, "category_create", &objType, &created.ID, nil)
return created, nil
}
// Update applies a partial patch to a category.
func (s *CategoryService) Update(ctx context.Context, id uuid.UUID, p CategoryParams) (*domain.Category, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
current, err := s.categories.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, current.CreatorID, categoryObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
patch := *current
if p.Name != "" {
patch.Name = p.Name
}
if p.Notes != nil {
patch.Notes = p.Notes
}
if p.Color != nil {
patch.Color = p.Color
}
if len(p.Metadata) > 0 {
patch.Metadata = p.Metadata
}
if p.IsPublic != nil {
patch.IsPublic = *p.IsPublic
}
updated, err := s.categories.Update(ctx, id, &patch)
if err != nil {
return nil, err
}
objType := categoryObjectType
_ = s.audit.Log(ctx, "category_edit", &objType, &id, nil)
return updated, nil
}
// Delete removes a category by ID, enforcing edit ACL.
func (s *CategoryService) Delete(ctx context.Context, id uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
c, err := s.categories.GetByID(ctx, id)
if err != nil {
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, c.CreatorID, categoryObjectTypeID, id)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
if err := s.categories.Delete(ctx, id); err != nil {
return err
}
objType := categoryObjectType
_ = s.audit.Log(ctx, "category_delete", &objType, &id, nil)
return nil
}
// ---------------------------------------------------------------------------
// Tags in category
// ---------------------------------------------------------------------------
// ListTags returns a paginated list of tags in this category that the caller
// may see.
func (s *CategoryService) ListTags(ctx context.Context, categoryID uuid.UUID, params port.OffsetParams) (*domain.TagOffsetPage, error) {
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
return s.tags.ListByCategory(ctx, categoryID, params)
}
+148
View File
@@ -0,0 +1,148 @@
package service
import (
"bytes"
"math/bits"
"sort"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
)
// hamming returns the number of differing bits between two perceptual hashes.
func hamming(a, b uint64) int { return bits.OnesCount64(a ^ b) }
// bkNode is a node in a BK-tree over Hamming distance. Files that share the exact
// same hash are collected in ids (a distance-0 collision), so identical images
// don't degenerate the tree into a chain.
type bkNode struct {
hash uint64
ids []uuid.UUID
children map[int]*bkNode
}
// bkTree indexes perceptual hashes for sublinear radius queries. Building one and
// querying every element with a small radius is far cheaper than the O(N²) all-
// pairs comparison at 100k+ files.
type bkTree struct{ root *bkNode }
func (t *bkTree) insert(hash uint64, id uuid.UUID) {
if t.root == nil {
t.root = &bkNode{hash: hash, ids: []uuid.UUID{id}, children: map[int]*bkNode{}}
return
}
node := t.root
for {
d := hamming(hash, node.hash)
if d == 0 {
node.ids = append(node.ids, id)
return
}
child, ok := node.children[d]
if !ok {
node.children[d] = &bkNode{hash: hash, ids: []uuid.UUID{id}, children: map[int]*bkNode{}}
return
}
node = child
}
}
// query visits every node whose hash is within radius of target. The triangle
// inequality bounds which children can hold a match to [d-radius, d+radius].
func (t *bkTree) query(target uint64, radius int, visit func(node *bkNode, dist int)) {
if t.root == nil {
return
}
stack := []*bkNode{t.root}
for len(stack) > 0 {
node := stack[len(stack)-1]
stack = stack[:len(stack)-1]
d := hamming(target, node.hash)
if d <= radius {
visit(node, d)
}
lo, hi := d-radius, d+radius
for cd, child := range node.children {
if cd >= lo && cd <= hi {
stack = append(stack, child)
}
}
}
}
// buildPairs returns every unordered pair of files whose hashes are within
// threshold, each emitted exactly once with FileA < FileB (UUID byte order).
// onProgress, if set, is called periodically with (processed, total).
func buildPairs(entries []domain.PHashEntry, threshold int, onProgress func(done, total int)) []domain.DuplicatePair {
tree := &bkTree{}
for _, e := range entries {
tree.insert(uint64(e.PHash), e.ID)
}
var pairs []domain.DuplicatePair
total := len(entries)
for i := range entries {
e := entries[i]
tree.query(uint64(e.PHash), threshold, func(node *bkNode, dist int) {
for _, other := range node.ids {
// Emit each pair once, from the smaller id, which also skips self.
if bytes.Compare(e.ID[:], other[:]) < 0 {
pairs = append(pairs, domain.DuplicatePair{FileA: e.ID, FileB: other, Distance: dist})
}
}
})
if onProgress != nil && (i+1)%1000 == 0 {
onProgress(i+1, total)
}
}
if onProgress != nil {
onProgress(total, total)
}
return pairs
}
// clusterPairs groups pairs into connected components (transitive closure) via
// union-find. Every returned cluster has at least two files; clusters and the ids
// within them are sorted by UUID for stable pagination.
func clusterPairs(pairs []domain.DuplicatePair) [][]uuid.UUID {
parent := map[uuid.UUID]uuid.UUID{}
var find func(uuid.UUID) uuid.UUID
find = func(x uuid.UUID) uuid.UUID {
p, ok := parent[x]
if !ok {
parent[x] = x
return x
}
if p != x {
parent[x] = find(p)
}
return parent[x]
}
union := func(a, b uuid.UUID) {
ra, rb := find(a), find(b)
if ra != rb {
parent[ra] = rb
}
}
for _, p := range pairs {
union(p.FileA, p.FileB)
}
groups := map[uuid.UUID][]uuid.UUID{}
for node := range parent {
root := find(node)
groups[root] = append(groups[root], node)
}
clusters := make([][]uuid.UUID, 0, len(groups))
for _, ids := range groups {
sort.Slice(ids, func(i, j int) bool { return bytes.Compare(ids[i][:], ids[j][:]) < 0 })
clusters = append(clusters, ids)
}
sort.Slice(clusters, func(i, j int) bool {
return bytes.Compare(clusters[i][0][:], clusters[j][0][:]) < 0
})
return clusters
}
@@ -0,0 +1,361 @@
package service
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// Merge field source values.
const (
mergeKeep = "keep"
mergeDiscard = "discard"
mergeBoth = "both"
mergeMerge = "merge"
)
// MergeFields chooses, per field, which file supplies the survivor's value when
// resolving a duplicate. Scalars accept "keep"/"discard"; metadata also accepts
// "merge" (shallow object merge, survivor wins on key conflicts); relations
// (tags, pools) accept "keep"/"both" (union) — there is deliberately no option
// to drop the survivor's own tags/pools. An empty value defaults to "keep".
type MergeFields struct {
OriginalName string `json:"original_name"`
Notes string `json:"notes"`
ContentDatetime string `json:"content_datetime"`
IsPublic string `json:"is_public"`
Metadata string `json:"metadata"`
Tags string `json:"tags"`
Pools string `json:"pools"`
}
// MergeSpec is the input to a duplicate resolution: keep one file, fold chosen
// fields in from the other, and (usually) trash the other.
type MergeSpec struct {
Keep uuid.UUID
Discard uuid.UUID
Fields MergeFields
DeleteDiscarded bool
}
// normalize fills empty choices with "keep" and rejects unknown values.
func (m *MergeSpec) normalize() error {
scalar := func(v *string) error {
if *v == "" {
*v = mergeKeep
}
if *v != mergeKeep && *v != mergeDiscard {
return domain.ErrValidation
}
return nil
}
relation := func(v *string) error {
if *v == "" {
*v = mergeKeep
}
if *v != mergeKeep && *v != mergeBoth {
return domain.ErrValidation
}
return nil
}
f := &m.Fields
if err := scalar(&f.OriginalName); err != nil {
return err
}
if err := scalar(&f.Notes); err != nil {
return err
}
if err := scalar(&f.ContentDatetime); err != nil {
return err
}
if err := scalar(&f.IsPublic); err != nil {
return err
}
if f.Metadata == "" {
f.Metadata = mergeKeep
}
if f.Metadata != mergeKeep && f.Metadata != mergeDiscard && f.Metadata != mergeMerge {
return domain.ErrValidation
}
if err := relation(&f.Tags); err != nil {
return err
}
if err := relation(&f.Pools); err != nil {
return err
}
return nil
}
// DuplicateService finds near-duplicate clusters and resolves them.
type DuplicateService struct {
files port.FileRepo
pairs port.DuplicatePairRepo
dismissals port.DismissalRepo
acl *ACLService
audit *AuditService
tx port.Transactor
threshold int
}
// NewDuplicateService creates a DuplicateService. threshold is the maximum
// Hamming distance for two files to be treated as duplicate candidates.
func NewDuplicateService(
files port.FileRepo,
pairs port.DuplicatePairRepo,
dismissals port.DismissalRepo,
acl *ACLService,
audit *AuditService,
tx port.Transactor,
threshold int,
) *DuplicateService {
return &DuplicateService{
files: files,
pairs: pairs,
dismissals: dismissals,
acl: acl,
audit: audit,
tx: tx,
threshold: threshold,
}
}
// Clusters returns a page of duplicate clusters visible to the caller. Pairs are
// read from the precomputed table (no all-pairs scan here) and grouped into
// connected components; pagination is over whole clusters.
func (s *DuplicateService) Clusters(ctx context.Context, limit, offset int) (clusters [][]domain.File, total int, err error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
pairs, err := s.pairs.ListVisible(ctx, userID, isAdmin)
if err != nil {
return nil, 0, err
}
groups := clusterPairs(pairs)
total = len(groups)
if offset < 0 {
offset = 0
}
if offset >= len(groups) {
return [][]domain.File{}, total, nil
}
end := offset + limit
if end > len(groups) || limit <= 0 {
end = len(groups)
}
out := make([][]domain.File, 0, end-offset)
for _, ids := range groups[offset:end] {
files := make([]domain.File, 0, len(ids))
for _, id := range ids {
f, err := s.files.GetByID(ctx, id)
if err != nil {
// A file deleted between the pair read and now just drops out.
if errors.Is(err, domain.ErrNotFound) {
continue
}
return nil, 0, err
}
files = append(files, *f)
}
if len(files) >= 2 {
out = append(out, files)
}
}
return out, total, nil
}
// Rescan recomputes the entire duplicate_pairs table from the current set of
// perceptual hashes. It is the only thing that populates the table, so the
// duplicates view reflects state as of the last rescan. Called by the dedup CLI.
func (s *DuplicateService) Rescan(ctx context.Context, onProgress func(done, total int)) error {
entries, err := s.files.ListAllPHashes(ctx)
if err != nil {
return err
}
pairs := buildPairs(entries, s.threshold, onProgress)
return s.pairs.ReplaceAll(ctx, pairs)
}
// Dismiss records two files as "not a duplicate" so the pair stops surfacing.
// The caller must be able to view both files.
func (s *DuplicateService) Dismiss(ctx context.Context, a, b uuid.UUID) error {
if a == b {
return domain.ErrValidation
}
userID, isAdmin, _ := domain.UserFromContext(ctx)
for _, id := range []uuid.UUID{a, b} {
f, err := s.files.GetByID(ctx, id)
if err != nil {
return err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, f.CreatorID, f.IsPublic, fileObjectTypeID, id)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
}
if err := s.dismissals.Add(ctx, a, b, userID); err != nil {
return err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "duplicate_dismiss", &objType, &a, map[string]any{"other": b.String()})
return nil
}
// Resolve merges a duplicate pair: the survivor (keep) takes the chosen fields
// from the other (discard), and the other is trashed when DeleteDiscarded is set.
// The caller must be able to edit both files. Returns the updated survivor.
func (s *DuplicateService) Resolve(ctx context.Context, spec MergeSpec) (*domain.File, error) {
if spec.Keep == spec.Discard {
return nil, domain.ErrValidation
}
if err := spec.normalize(); err != nil {
return nil, err
}
keep, err := s.files.GetByID(ctx, spec.Keep)
if err != nil {
return nil, err
}
discard, err := s.files.GetByID(ctx, spec.Discard)
if err != nil {
return nil, err
}
userID, isAdmin, _ := domain.UserFromContext(ctx)
for _, f := range []*domain.File{keep, discard} {
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, f.ID)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
}
// FileRepo.Update rewrites all editable scalar columns, so build the complete
// resolved set (each field from keep or discard) rather than a sparse patch.
patch := &domain.File{
OriginalName: pickPtr(spec.Fields.OriginalName, keep.OriginalName, discard.OriginalName),
Notes: pickPtr(spec.Fields.Notes, keep.Notes, discard.Notes),
ContentDatetime: pickTime(spec.Fields.ContentDatetime, keep.ContentDatetime, discard.ContentDatetime),
IsPublic: pickBool(spec.Fields.IsPublic, keep.IsPublic, discard.IsPublic),
Metadata: pickMetadata(spec.Fields.Metadata, keep.Metadata, discard.Metadata),
}
var result *domain.File
txErr := s.tx.WithTx(ctx, func(ctx context.Context) error {
updated, err := s.files.Update(ctx, keep.ID, patch)
if err != nil {
return err
}
if spec.Fields.Tags == mergeBoth {
if err := s.files.SetTags(ctx, keep.ID, unionTagIDs(keep.Tags, discard.Tags)); err != nil {
return err
}
tags, err := s.files.ListTags(ctx, keep.ID)
if err != nil {
return err
}
updated.Tags = tags
}
if spec.Fields.Pools == mergeBoth {
if err := s.files.CopyPoolMemberships(ctx, keep.ID, discard.ID); err != nil {
return err
}
}
if spec.DeleteDiscarded {
if err := s.files.SoftDelete(ctx, discard.ID); err != nil {
return err
}
}
result = updated
return nil
})
if txErr != nil {
return nil, txErr
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_merge", &objType, &keep.ID, map[string]any{
"discard": spec.Discard.String(),
"fields": spec.Fields,
"deleted_discarded": spec.DeleteDiscarded,
})
return result, nil
}
// --- field pickers ---------------------------------------------------------
func pickPtr(choice string, keep, discard *string) *string {
if choice == mergeDiscard {
return discard
}
return keep
}
func pickBool(choice string, keep, discard bool) bool {
if choice == mergeDiscard {
return discard
}
return keep
}
func pickTime(choice string, keep, discard time.Time) time.Time {
if choice == mergeDiscard {
return discard
}
return keep
}
func unionTagIDs(a, b []domain.Tag) []uuid.UUID {
seen := make(map[uuid.UUID]bool, len(a)+len(b))
ids := make([]uuid.UUID, 0, len(a)+len(b))
for _, t := range append(append([]domain.Tag{}, a...), b...) {
if !seen[t.ID] {
seen[t.ID] = true
ids = append(ids, t.ID)
}
}
return ids
}
// pickMetadata returns keep's metadata, discard's, or a shallow merge in which
// the survivor's keys win on conflict.
func pickMetadata(choice string, keep, discard json.RawMessage) json.RawMessage {
switch choice {
case mergeDiscard:
return discard
case mergeMerge:
km := map[string]json.RawMessage{}
dm := map[string]json.RawMessage{}
_ = json.Unmarshal(keep, &km)
_ = json.Unmarshal(discard, &dm)
out := make(map[string]json.RawMessage, len(km)+len(dm))
for k, v := range dm {
out[k] = v
}
for k, v := range km { // survivor wins
out[k] = v
}
if len(out) == 0 {
return keep
}
b, err := json.Marshal(out)
if err != nil {
return keep
}
return b
default:
return keep
}
}
+152
View File
@@ -0,0 +1,152 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"sort"
"testing"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
)
// id builds a deterministic UUID whose byte order matches n, so tests can reason
// about the canonical (FileA < FileB) ordering buildPairs produces.
func id(n int) uuid.UUID {
return uuid.MustParse(fmt.Sprintf("00000000-0000-0000-0000-%012d", n))
}
func entry(n int, hash uint64) domain.PHashEntry {
return domain.PHashEntry{ID: id(n), PHash: int64(hash)}
}
// pairKey canonicalises a pair for set comparison regardless of emission order.
func pairKey(p domain.DuplicatePair) string {
a, b := p.FileA, p.FileB
if bytes.Compare(a[:], b[:]) > 0 {
a, b = b, a
}
return fmt.Sprintf("%s|%s|%d", a, b, p.Distance)
}
func TestBuildPairs_ThresholdAndCanonicalOrder(t *testing.T) {
entries := []domain.PHashEntry{
entry(1, 0x0000000000000000),
entry(2, 0x0000000000000001), // distance 1 from #1
entry(3, 0x00000000000000FF), // distance 8 from #1, 7 from #2
entry(4, 0xFFFFFFFFFFFFFFFF), // distance 64 from #1
}
// Tight threshold: only the distance-1 pair qualifies.
got := buildPairs(entries, 2, nil)
if len(got) != 1 {
t.Fatalf("threshold 2: got %d pairs, want 1: %+v", len(got), got)
}
if got[0].FileA != id(1) || got[0].FileB != id(2) || got[0].Distance != 1 {
t.Errorf("threshold 2: unexpected pair %+v", got[0])
}
// Canonical order always FileA < FileB.
if bytes.Compare(got[0].FileA[:], got[0].FileB[:]) >= 0 {
t.Error("pair not in canonical FileA < FileB order")
}
// Looser threshold pulls in #3's pairs but never #4.
got8 := buildPairs(entries, 8, nil)
want := map[string]bool{
pairKey(domain.DuplicatePair{FileA: id(1), FileB: id(2), Distance: 1}): true,
pairKey(domain.DuplicatePair{FileA: id(1), FileB: id(3), Distance: 8}): true,
pairKey(domain.DuplicatePair{FileA: id(2), FileB: id(3), Distance: 7}): true,
}
if len(got8) != len(want) {
t.Fatalf("threshold 8: got %d pairs, want %d: %+v", len(got8), len(want), got8)
}
for _, p := range got8 {
if !want[pairKey(p)] {
t.Errorf("threshold 8: unexpected pair %+v", p)
}
}
}
func TestBuildPairs_IdenticalHashesPairAtDistanceZero(t *testing.T) {
entries := []domain.PHashEntry{
entry(1, 0xABCDABCDABCDABCD),
entry(2, 0xABCDABCDABCDABCD),
}
got := buildPairs(entries, 0, nil)
if len(got) != 1 || got[0].Distance != 0 || got[0].FileA != id(1) || got[0].FileB != id(2) {
t.Fatalf("identical hashes: got %+v, want one distance-0 pair (1,2)", got)
}
}
func TestClusterPairs_ConnectedComponents(t *testing.T) {
pairs := []domain.DuplicatePair{
{FileA: id(1), FileB: id(2)},
{FileA: id(2), FileB: id(3)}, // transitively joins 1-2-3
{FileA: id(5), FileB: id(6)},
}
clusters := clusterPairs(pairs)
if len(clusters) != 2 {
t.Fatalf("got %d clusters, want 2: %+v", len(clusters), clusters)
}
// Sorted by smallest id: {1,2,3} then {5,6}.
if len(clusters[0]) != 3 || clusters[0][0] != id(1) || clusters[0][2] != id(3) {
t.Errorf("cluster 0 = %v, want [1 2 3]", clusters[0])
}
if len(clusters[1]) != 2 || clusters[1][0] != id(5) {
t.Errorf("cluster 1 = %v, want [5 6]", clusters[1])
}
// Each cluster's ids are sorted.
for _, c := range clusters {
if !sort.SliceIsSorted(c, func(i, j int) bool { return bytes.Compare(c[i][:], c[j][:]) < 0 }) {
t.Errorf("cluster not sorted: %v", c)
}
}
}
func TestPickMetadata_Merge(t *testing.T) {
keep := json.RawMessage(`{"a":1,"b":2}`)
discard := json.RawMessage(`{"b":9,"c":3}`)
out := pickMetadata(mergeMerge, keep, discard)
var m map[string]int
if err := json.Unmarshal(out, &m); err != nil {
t.Fatalf("merge result not valid JSON: %v (%s)", err, out)
}
want := map[string]int{"a": 1, "b": 2, "c": 3} // survivor wins on "b"
if fmt.Sprint(m) != fmt.Sprint(want) {
t.Errorf("merge = %v, want %v", m, want)
}
if string(pickMetadata(mergeKeep, keep, discard)) != string(keep) {
t.Error("keep choice should return survivor metadata unchanged")
}
if string(pickMetadata(mergeDiscard, keep, discard)) != string(discard) {
t.Error("discard choice should return the other file's metadata")
}
}
func TestMergeSpec_Normalize(t *testing.T) {
// Empty fields default to "keep".
spec := MergeSpec{Keep: id(1), Discard: id(2)}
if err := spec.normalize(); err != nil {
t.Fatalf("normalize empty: %v", err)
}
if spec.Fields.OriginalName != mergeKeep || spec.Fields.Tags != mergeKeep || spec.Fields.Metadata != mergeKeep {
t.Errorf("empty fields not defaulted to keep: %+v", spec.Fields)
}
// "both" is invalid for a scalar field.
bad := MergeSpec{Keep: id(1), Discard: id(2), Fields: MergeFields{Notes: mergeBoth}}
if err := bad.normalize(); !errors.Is(err, domain.ErrValidation) {
t.Errorf("scalar=both: got %v, want ErrValidation", err)
}
// "discard" is invalid for a relation field.
badRel := MergeSpec{Keep: id(1), Discard: id(2), Fields: MergeFields{Tags: mergeDiscard}}
if err := badRel.normalize(); !errors.Is(err, domain.ErrValidation) {
t.Errorf("relation=discard: got %v, want ErrValidation", err)
}
}
+797
View File
@@ -0,0 +1,797 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/imagehash"
"tanabata/backend/internal/port"
)
const fileObjectType = "file"
// fileObjectTypeID is the primary key of the "file" row in core.object_types.
// It matches the first value inserted in 007_seed_data.sql.
const fileObjectTypeID int16 = 1
// UploadParams holds the parameters for uploading a new file.
type UploadParams struct {
Reader io.Reader
MIMEType string
OriginalName *string
Notes *string
Metadata json.RawMessage
ContentDatetime *time.Time
// ContentDatetimeFallback is used for content_datetime only when neither an
// explicit ContentDatetime nor an EXIF date is available (e.g. the source
// file's mtime on a server-side import).
ContentDatetimeFallback *time.Time
IsPublic bool
TagIDs []uuid.UUID
}
// UpdateParams holds the parameters for updating file metadata.
type UpdateParams struct {
OriginalName *string
Notes *string
Metadata json.RawMessage
ContentDatetime *time.Time
IsPublic *bool
TagIDs *[]uuid.UUID // nil means don't change tags
}
// ContentResult holds the open reader and metadata for a file download.
type ContentResult struct {
Body io.ReadCloser
MIMEType string
OriginalName *string
}
// ImportFileError records a failed file during an import operation.
type ImportFileError struct {
Filename string `json:"filename"`
Reason string `json:"reason"`
}
// ImportResult summarises a directory import.
type ImportResult struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors []ImportFileError `json:"errors"`
}
// ImportEvent is one progress message streamed during an import, letting the UI
// show a live progress bar and a per-file status list. Type is the discriminator:
//
// "start" — total is the number of entries about to be processed.
// "file" — one entry finished: index (1-based), filename, status, optional reason.
// "done" — final tallies (imported/skipped/errors).
type ImportEvent struct {
Type string `json:"type"`
Total int `json:"total,omitempty"`
Index int `json:"index,omitempty"`
Filename string `json:"filename,omitempty"`
Status string `json:"status,omitempty"` // "imported" | "skipped" | "error"
Reason string `json:"reason,omitempty"`
Imported int `json:"imported,omitempty"`
Skipped int `json:"skipped,omitempty"`
Errors int `json:"errors,omitempty"`
}
// FileService handles business logic for file records.
type FileService struct {
files port.FileRepo
mimes port.MimeRepo
storage port.FileStorage
acl *ACLService
audit *AuditService
tags *TagService
tx port.Transactor
importPath string // default server-side import directory
}
// NewFileService creates a FileService.
func NewFileService(
files port.FileRepo,
mimes port.MimeRepo,
storage port.FileStorage,
acl *ACLService,
audit *AuditService,
tags *TagService,
tx port.Transactor,
importPath string,
) *FileService {
return &FileService{
files: files,
mimes: mimes,
storage: storage,
acl: acl,
audit: audit,
tags: tags,
tx: tx,
importPath: importPath,
}
}
// ---------------------------------------------------------------------------
// Core CRUD
// ---------------------------------------------------------------------------
// Upload validates the MIME type, saves the file to storage, creates the DB
// record, and applies any initial tags — all within a single transaction.
// If ContentDatetime is nil and the metadata carries a capture date, it is used.
func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, error) {
userID, _, _ := domain.UserFromContext(ctx)
// Validate MIME type against the whitelist.
mime, err := s.mimes.GetByName(ctx, p.MIMEType)
if err != nil {
return nil, err // ErrUnsupportedMIME or DB error
}
// Buffer the upload so we can extract EXIF without re-reading storage.
var buf bytes.Buffer
if _, err := io.Copy(&buf, p.Reader); err != nil {
return nil, fmt.Errorf("FileService.Upload: read body: %w", err)
}
data := buf.Bytes()
// Extract rich metadata (best-effort; covers images, video and audio).
var origName string
if p.OriginalName != nil {
origName = *p.OriginalName
}
exifData, exifDatetime := extractMetadata(data, origName, p.ContentDatetimeFallback)
// Compute a perceptual hash for images so duplicate detection can later match
// near-identical files. Best-effort: a decode failure just leaves phash unset
// (the dedup CLI backfills it). Video is hashed by that CLI, not inline, to keep
// ffmpeg off the upload path.
var phash *int64
if strings.HasPrefix(mime.Name, "image/") {
if h, ok := imagehash.FromBytes(data); ok {
phash = &h
}
}
// Resolve content datetime: explicit > metadata date > fallback (e.g. import mtime) > zero.
var contentDatetime time.Time
if p.ContentDatetime != nil {
contentDatetime = *p.ContentDatetime
} else if exifDatetime != nil {
contentDatetime = *exifDatetime
} else if p.ContentDatetimeFallback != nil {
contentDatetime = *p.ContentDatetimeFallback
}
// Assign UUID v7 so CreatedAt can be derived from it later.
fileID, err := uuid.NewV7()
if err != nil {
return nil, fmt.Errorf("FileService.Upload: generate UUID: %w", err)
}
// Save file bytes to disk before opening the transaction so that a disk
// failure does not abort an otherwise healthy DB transaction.
if _, err := s.storage.Save(ctx, fileID, bytes.NewReader(data)); err != nil {
return nil, fmt.Errorf("FileService.Upload: save to storage: %w", err)
}
var created *domain.File
txErr := s.tx.WithTx(ctx, func(ctx context.Context) error {
f := &domain.File{
ID: fileID,
OriginalName: p.OriginalName,
MIMEType: mime.Name,
MIMEExtension: mime.Extension,
ContentDatetime: contentDatetime,
Notes: p.Notes,
Metadata: p.Metadata,
EXIF: exifData,
PHash: phash,
CreatorID: userID,
IsPublic: p.IsPublic,
}
var createErr error
created, createErr = s.files.Create(ctx, f)
if createErr != nil {
return createErr
}
if len(p.TagIDs) > 0 {
tags, err := s.tags.SetFileTags(ctx, created.ID, p.TagIDs)
if err != nil {
return err
}
created.Tags = tags
}
return nil
})
if txErr != nil {
// Attempt to clean up the orphaned file; ignore cleanup errors.
_ = s.storage.Delete(ctx, fileID)
return nil, txErr
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_create", &objType, &created.ID, nil)
return created, nil
}
// Get returns a file by ID, enforcing view ACL.
func (s *FileService) Get(ctx context.Context, id uuid.UUID) (*domain.File, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
f, err := s.files.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, f.CreatorID, f.IsPublic, fileObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
return f, nil
}
// RecordView appends a view-history entry for the current user, enforcing view
// ACL (you can only record a view of a file you may see).
func (s *FileService) RecordView(ctx context.Context, id uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
f, err := s.files.GetByID(ctx, id)
if err != nil {
return err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, f.CreatorID, f.IsPublic, fileObjectTypeID, id)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
return s.files.RecordView(ctx, id, userID)
}
// Update applies metadata changes to a file, enforcing edit ACL.
func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams) (*domain.File, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
f, err := s.files.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
patch := &domain.File{}
if p.OriginalName != nil {
patch.OriginalName = p.OriginalName
}
if p.Notes != nil {
patch.Notes = p.Notes
}
if p.Metadata != nil {
patch.Metadata = p.Metadata
}
if p.ContentDatetime != nil {
patch.ContentDatetime = *p.ContentDatetime
}
if p.IsPublic != nil {
patch.IsPublic = *p.IsPublic
}
var updated *domain.File
txErr := s.tx.WithTx(ctx, func(ctx context.Context) error {
var updateErr error
updated, updateErr = s.files.Update(ctx, id, patch)
if updateErr != nil {
return updateErr
}
if p.TagIDs != nil {
tags, err := s.tags.SetFileTags(ctx, id, *p.TagIDs)
if err != nil {
return err
}
updated.Tags = tags
}
return nil
})
if txErr != nil {
return nil, txErr
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_edit", &objType, &id, nil)
return updated, nil
}
// Delete soft-deletes a file (moves to trash), enforcing edit ACL.
func (s *FileService) Delete(ctx context.Context, id uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
f, err := s.files.GetByID(ctx, id)
if err != nil {
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
if err := s.files.SoftDelete(ctx, id); err != nil {
return err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_delete", &objType, &id, nil)
return nil
}
// Restore moves a soft-deleted file out of trash, enforcing edit ACL.
func (s *FileService) Restore(ctx context.Context, id uuid.UUID) (*domain.File, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
f, err := s.files.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
restored, err := s.files.Restore(ctx, id)
if err != nil {
return nil, err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_restore", &objType, &id, nil)
return restored, nil
}
// PermanentDelete removes the file record and its stored bytes. Only allowed
// when the file is already in trash.
func (s *FileService) PermanentDelete(ctx context.Context, id uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
f, err := s.files.GetByID(ctx, id)
if err != nil {
return err
}
if !f.IsDeleted {
return domain.ErrConflict
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
if err := s.files.DeletePermanent(ctx, id); err != nil {
return err
}
_ = s.storage.Delete(ctx, id)
_ = s.storage.InvalidateCache(ctx, id)
objType := fileObjectType
_ = s.audit.Log(ctx, "file_permanent_delete", &objType, &id, nil)
return nil
}
// Replace swaps the stored bytes for a file with new content.
func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams) (*domain.File, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
f, err := s.files.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
mime, err := s.mimes.GetByName(ctx, p.MIMEType)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if _, err := io.Copy(&buf, p.Reader); err != nil {
return nil, fmt.Errorf("FileService.Replace: read body: %w", err)
}
data := buf.Bytes()
var origName string
if p.OriginalName != nil {
origName = *p.OriginalName
}
exifData, _ := extractMetadata(data, origName, nil)
if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil {
return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err)
}
// Drop stale thumbnail/preview so they regenerate from the new content.
_ = s.storage.InvalidateCache(ctx, id)
patch := &domain.File{
MIMEType: mime.Name,
MIMEExtension: mime.Extension,
EXIF: exifData,
}
if p.OriginalName != nil {
patch.OriginalName = p.OriginalName
}
updated, err := s.files.Update(ctx, id, patch)
if err != nil {
return nil, err
}
// Recompute the perceptual hash from the new content: images inline, anything
// else cleared to NULL so the old content's hash never lingers (the dedup CLI
// recomputes video). Best-effort, like on upload — phash is recomputable.
var phash *int64
if strings.HasPrefix(mime.Name, "image/") {
if h, ok := imagehash.FromBytes(data); ok {
phash = &h
}
}
_ = s.files.SetPHash(ctx, id, phash)
updated.PHash = phash
objType := fileObjectType
_ = s.audit.Log(ctx, "file_replace", &objType, &id, nil)
return updated, nil
}
// List delegates to FileRepo with the given params, restricting results to
// files the caller may see (unless they are an admin).
func (s *FileService) List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error) {
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
page, err := s.files.List(ctx, params)
if err != nil {
return nil, err
}
// Log tag usage when a filter is first applied — not on pagination (cursor)
// or an anchored return, so a single browse counts once. Best-effort
// analytics; a failed write never breaks the listing.
if params.Filter != "" && params.Cursor == "" && params.Anchor == nil && params.ViewerID != 0 {
_ = s.files.RecordTagUses(ctx, params.ViewerID, params.Filter)
}
return page, nil
}
// AuthorizeView ensures the caller may view the file. Returns ErrNotFound if the
// file does not exist or ErrForbidden if the caller lacks view access.
func (s *FileService) AuthorizeView(ctx context.Context, id uuid.UUID) error {
_, err := s.Get(ctx, id)
return err
}
// AuthorizeEdit ensures the caller may edit the file. Returns ErrNotFound if the
// file does not exist or ErrForbidden if the caller lacks edit access.
func (s *FileService) AuthorizeEdit(ctx context.Context, id uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
f, err := s.files.GetByID(ctx, id)
if err != nil {
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
return nil
}
// ---------------------------------------------------------------------------
// Content / thumbnail / preview streaming
// ---------------------------------------------------------------------------
// GetContent opens the raw file for download, enforcing view ACL.
func (s *FileService) GetContent(ctx context.Context, id uuid.UUID) (*ContentResult, error) {
f, err := s.Get(ctx, id) // ACL checked inside Get
if err != nil {
return nil, err
}
rc, err := s.storage.Read(ctx, id)
if err != nil {
return nil, err
}
return &ContentResult{
Body: rc,
MIMEType: f.MIMEType,
OriginalName: f.OriginalName,
}, nil
}
// GetThumbnail returns the thumbnail JPEG, enforcing view ACL.
func (s *FileService) GetThumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
if _, err := s.Get(ctx, id); err != nil {
return nil, err
}
return s.storage.Thumbnail(ctx, id)
}
// GetPreview returns the preview JPEG, enforcing view ACL.
func (s *FileService) GetPreview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
if _, err := s.Get(ctx, id); err != nil {
return nil, err
}
return s.storage.Preview(ctx, id)
}
// ---------------------------------------------------------------------------
// Bulk operations
// ---------------------------------------------------------------------------
// BulkDelete soft-deletes multiple files. Files the caller cannot edit are silently skipped.
func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error {
for _, id := range fileIDs {
if err := s.Delete(ctx, id); err != nil {
if err == domain.ErrNotFound || err == domain.ErrForbidden {
continue
}
return err
}
}
return nil
}
// SetNeedsReview sets the review status ("needs tagging" vs marked done) on the
// given files. Each file is checked against edit ACL; files the caller cannot
// edit, or that do not exist, are skipped (same forgiving semantics as
// BulkDelete). Authorized files are updated in a single statement and each is
// audit-logged. Works for one file (single-element slice) or many.
func (s *FileService) SetNeedsReview(ctx context.Context, ids []uuid.UUID, value bool) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
authorized := make([]uuid.UUID, 0, len(ids))
for _, id := range ids {
f, err := s.files.GetByID(ctx, id)
if err != nil {
if err == domain.ErrNotFound {
continue
}
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
if err != nil {
return err
}
if ok {
authorized = append(authorized, id)
}
}
if len(authorized) == 0 {
return nil
}
if err := s.files.SetNeedsReview(ctx, authorized, value); err != nil {
return err
}
objType := fileObjectType
details := map[string]any{"needs_review": value}
for i := range authorized {
_ = s.audit.Log(ctx, "file_review", &objType, &authorized[i], details)
}
return nil
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
// Import scans a server-side directory and uploads all supported files.
// If path is empty, the configured default import path is used.
//
// onProgress, when non-nil, receives a "start" event, one "file" event per
// directory entry as it is processed, and a final "done" event — letting a
// caller stream live progress. It is always called from this goroutine (never
// concurrently). The aggregate result is also returned for non-streaming callers.
func (s *FileService) Import(ctx context.Context, path string, onProgress func(ImportEvent)) (*ImportResult, error) {
if s.importPath == "" {
return nil, domain.ErrValidation
}
dir := s.importPath
if path != "" {
// Confine caller-supplied paths to the configured import directory so a
// directory-traversal value cannot read arbitrary host files.
confined, err := confineToBase(s.importPath, path)
if err != nil {
return nil, err
}
dir = confined
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("FileService.Import: read dir %q: %w", dir, err)
}
// Import oldest-first: process entries in ascending mtime order so the
// resulting records' creation order matches the files' chronological order.
// mtimes are cached once (re-stat'ing per comparison would be wasteful) and
// reused below as the content_datetime fallback. Entries whose info can't be
// read get a zero time (sort first); they surface their error in the loop.
modTimes := make(map[string]time.Time, len(entries))
for _, e := range entries {
if info, infoErr := e.Info(); infoErr == nil {
modTimes[e.Name()] = info.ModTime()
}
}
// SliceStable preserves ReadDir's name order as a deterministic tiebreak for
// entries sharing an mtime.
sort.SliceStable(entries, func(a, b int) bool {
return modTimes[entries[a].Name()].Before(modTimes[entries[b].Name()])
})
emit := func(ev ImportEvent) {
if onProgress != nil {
onProgress(ev)
}
}
result := &ImportResult{Errors: []ImportFileError{}}
total := len(entries)
emit(ImportEvent{Type: "start", Total: total})
for i, entry := range entries {
name := entry.Name()
file := func(status, reason string) {
emit(ImportEvent{
Type: "file", Index: i + 1, Total: total,
Filename: name, Status: status, Reason: reason,
})
}
fail := func(reason string) {
result.Errors = append(result.Errors, ImportFileError{Filename: name, Reason: reason})
file("error", reason)
}
if entry.IsDir() {
result.Skipped++
file("skipped", "directory")
continue
}
fullPath := filepath.Join(dir, name)
mt, err := mimetype.DetectFile(fullPath)
if err != nil {
fail(fmt.Sprintf("MIME detection failed: %s", err))
continue
}
mimeStr := mt.String()
// Strip parameters (e.g. "text/plain; charset=utf-8" → "text/plain").
if j := strings.IndexByte(mimeStr, ';'); j >= 0 {
mimeStr = mimeStr[:j]
}
if _, err := s.mimes.GetByName(ctx, mimeStr); err != nil {
result.Skipped++
file("skipped", "unsupported type: "+mimeStr)
continue
}
f, err := os.Open(fullPath)
if err != nil {
fail(fmt.Sprintf("open failed: %s", err))
continue
}
// Preserve the file's mtime as a content_datetime fallback (used only when
// the file has no EXIF date) — once the source is removed below it's the
// only date left for non-photo files. Reuses the mtime cached for sorting.
var mtime *time.Time
if t, ok := modTimes[name]; ok {
mtime = &t
}
_, uploadErr := s.Upload(ctx, UploadParams{
Reader: f,
MIMEType: mimeStr,
OriginalName: &name,
ContentDatetimeFallback: mtime,
})
f.Close()
if uploadErr != nil {
fail(uploadErr.Error())
continue
}
result.Imported++
// Remove the source on success so the import folder drains and re-running
// doesn't duplicate. The file is already safely copied into storage; a
// removal failure is reported but doesn't undo the import.
if rmErr := os.Remove(fullPath); rmErr != nil {
reason := fmt.Sprintf("imported, but failed to remove source: %s", rmErr)
result.Errors = append(result.Errors, ImportFileError{Filename: name, Reason: reason})
file("imported", reason) // imported, with a warning
continue
}
file("imported", "")
}
emit(ImportEvent{
Type: "done", Total: total,
Imported: result.Imported, Skipped: result.Skipped, Errors: len(result.Errors),
})
return result, nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// confineToBase resolves target and verifies it does not escape base (after
// cleaning and resolving "..") so a caller cannot read files outside the
// configured import directory. Returns the cleaned absolute path on success.
func confineToBase(base, target string) (string, error) {
absBase, err := filepath.Abs(base)
if err != nil {
return "", domain.ErrValidation
}
absTarget, err := filepath.Abs(target)
if err != nil {
return "", domain.ErrValidation
}
rel, err := filepath.Rel(absBase, absTarget)
if err != nil {
return "", domain.ErrValidation
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", domain.ErrForbidden
}
return absTarget, nil
}
+183
View File
@@ -0,0 +1,183 @@
package service
import (
"bytes"
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/rwcarlsen/goexif/exif"
)
// exiftoolPath is resolved once at startup. When exiftool isn't installed we
// skip the subprocess and fall back to the pure-Go EXIF reader, so the server
// still runs (with thinner metadata) on hosts without it.
var exiftoolPath, _ = exec.LookPath("exiftool")
// metadataTimeout bounds a single exiftool invocation so a pathological file
// can't wedge an upload.
const metadataTimeout = 30 * time.Second
// metaTempFileKeys are exiftool fields that describe the temporary file we feed
// it rather than the content. Dropping them avoids leaking internal paths and
// recording the temp file's permissions/inode timestamps.
var metaTempFileKeys = []string{
"SourceFile",
"Directory",
"FileAccessDate",
"FileInodeChangeDate",
"FilePermissions",
}
// metaDateKeys are the metadata fields, in priority order, holding the moment
// the content was actually captured/created — photos first, then video atoms.
var metaDateKeys = []string{
"DateTimeOriginal",
"CreateDate",
"MediaCreateDate",
"TrackCreateDate",
"ModifyDate",
}
// extractMetadata returns rich metadata as JSON plus the best content datetime
// it can find. It prefers exiftool, which understands video, audio and every
// image format and emits machine-readable numeric values (the basis for later
// analytics); when exiftool is unavailable it falls back to the pure-Go EXIF
// reader, which only handles JPEG/TIFF.
//
// originalName supplies the extension exiftool uses for format detection and the
// FileName reported back. mtime, when set (e.g. a server-side import), is stamped
// onto the temp file so FileModifyDate reflects the real source.
func extractMetadata(data []byte, originalName string, mtime *time.Time) (json.RawMessage, *time.Time) {
if exiftoolPath != "" {
if raw, dt, ok := exiftoolExtract(data, originalName, mtime); ok {
return raw, dt
}
}
return extractEXIFWithDatetime(data)
}
// exiftoolExtract stages the bytes in a temp file and shells out to exiftool.
// It returns ok=false on any failure so the caller can fall back.
func exiftoolExtract(data []byte, originalName string, mtime *time.Time) (json.RawMessage, *time.Time, bool) {
// exiftool reads a real file far more reliably than a pipe (it seeks freely,
// e.g. to a trailing MP4 moov atom), so stage the bytes in a temp file whose
// extension matches the original for accurate format detection.
tmp, err := os.CreateTemp("", "tfm-meta-*"+filepath.Ext(originalName))
if err != nil {
return nil, nil, false
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return nil, nil, false
}
if err := tmp.Close(); err != nil {
return nil, nil, false
}
if mtime != nil {
_ = os.Chtimes(tmpName, *mtime, *mtime)
}
ctx, cancel := context.WithTimeout(context.Background(), metadataTimeout)
defer cancel()
// -n forces raw numeric/machine values for every tag (no "3.53 Mbps" strings)
// so the metadata is analytics-ready. -all extracts every tag. largefilesupport
// handles multi-GB videos. Output is a one-element JSON array.
out, err := exec.CommandContext(ctx, exiftoolPath,
"-n", "-all", "-json", "-api", "largefilesupport=1", tmpName,
).Output()
if err != nil {
return nil, nil, false
}
var arr []map[string]json.RawMessage
if err := json.Unmarshal(out, &arr); err != nil || len(arr) == 0 {
return nil, nil, false
}
m := arr[0]
dt := pickMetaDatetime(m)
// Strip temp-file artifacts and substitute the real name.
for _, k := range metaTempFileKeys {
delete(m, k)
}
if mtime == nil {
// Without a real source mtime this is just the temp file's write time.
delete(m, "FileModifyDate")
}
if originalName != "" {
if nb, err := json.Marshal(originalName); err == nil {
m["FileName"] = nb
}
} else {
delete(m, "FileName")
}
raw, err := json.Marshal(m)
if err != nil {
return nil, nil, false
}
return raw, dt, true
}
// pickMetaDatetime returns the first parseable content date among metaDateKeys.
func pickMetaDatetime(m map[string]json.RawMessage) *time.Time {
for _, key := range metaDateKeys {
raw, ok := m[key]
if !ok {
continue
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
continue
}
if t, ok := parseExifDate(s); ok {
return &t
}
}
return nil
}
// parseExifDate parses exiftool's "YYYY:MM:DD HH:MM:SS" timestamps, with or
// without a trailing timezone offset. Zeroed placeholders ("0000:00:00 ...")
// fail to parse and are skipped by the caller.
func parseExifDate(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
for _, layout := range []string{
"2006:01:02 15:04:05-07:00",
"2006:01:02 15:04:05Z07:00",
"2006:01:02 15:04:05",
} {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
}
// extractEXIFWithDatetime is the pure-Go fallback used when exiftool is absent.
// It parses EXIF from raw bytes (JPEG/TIFF only), returning both the JSON
// representation and the DateTimeOriginal (if present). Both may be nil.
func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) {
x, err := exif.Decode(bytes.NewReader(data))
if err != nil {
return nil, nil
}
b, err := x.MarshalJSON()
if err != nil {
return nil, nil
}
var dt *time.Time
if t, err := x.DateTime(); err == nil {
dt = &t
}
return json.RawMessage(b), dt
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"bytes"
"encoding/json"
"image"
"image/color"
"image/png"
"testing"
"time"
)
func TestParseExifDate(t *testing.T) {
cases := []struct {
in string
ok bool
want time.Time
}{
{"2026:03:24 16:57:58", true, time.Date(2026, 3, 24, 16, 57, 58, 0, time.UTC)},
{"2026:05:08 23:07:55+03:00", true, time.Date(2026, 5, 8, 23, 7, 55, 0, time.FixedZone("", 3*3600))},
{" 2026:01:02 03:04:05 ", true, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)},
{"0000:00:00 00:00:00", false, time.Time{}},
{"not a date", false, time.Time{}},
{"", false, time.Time{}},
}
for _, c := range cases {
got, ok := parseExifDate(c.in)
if ok != c.ok {
t.Errorf("parseExifDate(%q) ok=%v, want %v", c.in, ok, c.ok)
continue
}
if ok && !got.Equal(c.want) {
t.Errorf("parseExifDate(%q) = %v, want %v", c.in, got, c.want)
}
}
}
// tinyPNG returns a valid 2x3 PNG with no embedded EXIF/date.
func tinyPNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 2, 3))
img.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
func TestExtractMetadataExiftool(t *testing.T) {
if exiftoolPath == "" {
t.Skip("exiftool not installed; metadata extraction falls back to goexif")
}
raw, dt := extractMetadata(tinyPNG(t), "snapshot.png", nil)
if raw == nil {
t.Fatal("expected non-nil metadata JSON")
}
if dt != nil {
t.Errorf("a PNG without a capture date should yield no content datetime, got %v", dt)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("metadata is not valid JSON: %v", err)
}
// exiftool understood the format (goexif never would for PNG).
if v := jsonString(t, m, "FileType"); v != "PNG" {
t.Errorf("FileType = %q, want PNG", v)
}
// Dimensions are numeric, not human-readable strings.
for _, key := range []string{"ImageWidth", "ImageHeight"} {
raw, ok := m[key]
if !ok {
t.Errorf("missing %s", key)
continue
}
var n float64
if err := json.Unmarshal(raw, &n); err != nil {
t.Errorf("%s is not numeric: %s", key, raw)
}
}
// FileName is the original, not the temp file; temp-file artifacts are gone.
if v := jsonString(t, m, "FileName"); v != "snapshot.png" {
t.Errorf("FileName = %q, want snapshot.png", v)
}
for _, leaked := range []string{"SourceFile", "Directory", "FilePermissions", "FileModifyDate"} {
if _, ok := m[leaked]; ok {
t.Errorf("temp-file field %q should have been stripped", leaked)
}
}
}
func jsonString(t *testing.T, m map[string]json.RawMessage, key string) string {
t.Helper()
raw, ok := m[key]
if !ok {
t.Errorf("missing key %q", key)
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
t.Errorf("key %q is not a string: %s", key, raw)
return ""
}
return s
}
+256
View File
@@ -0,0 +1,256 @@
package service
import (
"context"
"encoding/json"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
const poolObjectType = "pool"
const poolObjectTypeID int16 = 4 // fourth row in 007_seed_data.sql object_types
// PoolParams holds the fields for creating or patching a pool.
type PoolParams struct {
Name string
Notes *string
Metadata json.RawMessage
IsPublic *bool
}
// PoolService handles pool CRUD and poolfile management with ACL + audit.
type PoolService struct {
pools port.PoolRepo
acl *ACLService
audit *AuditService
}
// NewPoolService creates a PoolService.
func NewPoolService(
pools port.PoolRepo,
acl *ACLService,
audit *AuditService,
) *PoolService {
return &PoolService{pools: pools, acl: acl, audit: audit}
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
// List returns a paginated list of pools the caller may see.
func (s *PoolService) List(ctx context.Context, params port.OffsetParams) (*domain.PoolOffsetPage, error) {
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
return s.pools.List(ctx, params)
}
// Get returns a pool by ID, enforcing view ACL.
func (s *PoolService) Get(ctx context.Context, id uuid.UUID) (*domain.Pool, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
p, err := s.pools.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, p.CreatorID, p.IsPublic, poolObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
return p, nil
}
// authorizeView returns nil if the caller may view the pool, else ErrForbidden
// (or ErrNotFound if the pool does not exist).
func (s *PoolService) authorizeView(ctx context.Context, poolID uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
p, err := s.pools.GetByID(ctx, poolID)
if err != nil {
return err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, p.CreatorID, p.IsPublic, poolObjectTypeID, poolID)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
return nil
}
// RecordView appends a view-history entry for the current user, enforcing view
// ACL (you can only record a view of a pool you may see).
func (s *PoolService) RecordView(ctx context.Context, id uuid.UUID) error {
userID, _, _ := domain.UserFromContext(ctx)
if err := s.authorizeView(ctx, id); err != nil {
return err
}
return s.pools.RecordView(ctx, id, userID)
}
// authorizeEdit returns nil if the caller may edit the pool, else ErrForbidden
// (or ErrNotFound if the pool does not exist).
func (s *PoolService) authorizeEdit(ctx context.Context, poolID uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
p, err := s.pools.GetByID(ctx, poolID)
if err != nil {
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, p.CreatorID, poolObjectTypeID, poolID)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
return nil
}
// Create inserts a new pool.
func (s *PoolService) Create(ctx context.Context, p PoolParams) (*domain.Pool, error) {
userID, _, _ := domain.UserFromContext(ctx)
pool := &domain.Pool{
Name: p.Name,
Notes: p.Notes,
Metadata: p.Metadata,
CreatorID: userID,
}
if p.IsPublic != nil {
pool.IsPublic = *p.IsPublic
}
created, err := s.pools.Create(ctx, pool)
if err != nil {
return nil, err
}
objType := poolObjectType
_ = s.audit.Log(ctx, "pool_create", &objType, &created.ID, nil)
return created, nil
}
// Update applies a partial patch to a pool.
func (s *PoolService) Update(ctx context.Context, id uuid.UUID, p PoolParams) (*domain.Pool, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
current, err := s.pools.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, current.CreatorID, poolObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
patch := *current
if p.Name != "" {
patch.Name = p.Name
}
if p.Notes != nil {
patch.Notes = p.Notes
}
if len(p.Metadata) > 0 {
patch.Metadata = p.Metadata
}
if p.IsPublic != nil {
patch.IsPublic = *p.IsPublic
}
updated, err := s.pools.Update(ctx, id, &patch)
if err != nil {
return nil, err
}
objType := poolObjectType
_ = s.audit.Log(ctx, "pool_edit", &objType, &id, nil)
return updated, nil
}
// Delete removes a pool by ID, enforcing edit ACL.
func (s *PoolService) Delete(ctx context.Context, id uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
pool, err := s.pools.GetByID(ctx, id)
if err != nil {
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, pool.CreatorID, poolObjectTypeID, id)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
if err := s.pools.Delete(ctx, id); err != nil {
return err
}
objType := poolObjectType
_ = s.audit.Log(ctx, "pool_delete", &objType, &id, nil)
return nil
}
// ---------------------------------------------------------------------------
// Poolfile operations
// ---------------------------------------------------------------------------
// ListFiles returns cursor-paginated files within a pool ordered by position,
// enforcing view ACL on the pool.
func (s *PoolService) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) {
if err := s.authorizeView(ctx, poolID); err != nil {
return nil, err
}
return s.pools.ListFiles(ctx, poolID, params)
}
// AddFiles adds files to a pool at the given position (nil = append), enforcing
// edit ACL on the pool.
func (s *PoolService) AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error {
if err := s.authorizeEdit(ctx, poolID); err != nil {
return err
}
if err := s.pools.AddFiles(ctx, poolID, fileIDs, position); err != nil {
return err
}
objType := poolObjectType
_ = s.audit.Log(ctx, "file_pool_add", &objType, &poolID, map[string]any{"count": len(fileIDs)})
return nil
}
// RemoveFiles removes files from a pool, enforcing edit ACL on the pool.
func (s *PoolService) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
if err := s.authorizeEdit(ctx, poolID); err != nil {
return err
}
if err := s.pools.RemoveFiles(ctx, poolID, fileIDs); err != nil {
return err
}
objType := poolObjectType
_ = s.audit.Log(ctx, "file_pool_remove", &objType, &poolID, map[string]any{"count": len(fileIDs)})
return nil
}
// Reorder sets the ordered sequence of file IDs within a pool, enforcing edit
// ACL on the pool.
func (s *PoolService) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
if err := s.authorizeEdit(ctx, poolID); err != nil {
return err
}
if err := s.pools.Reorder(ctx, poolID, fileIDs); err != nil {
return err
}
objType := poolObjectType
_ = s.audit.Log(ctx, "file_pool_reorder", &objType, &poolID, map[string]any{"count": len(fileIDs)})
return nil
}
+441
View File
@@ -0,0 +1,441 @@
package service
import (
"context"
"encoding/json"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
const tagObjectType = "tag"
const tagObjectTypeID int16 = 2 // second row in 007_seed_data.sql object_types
// TagParams holds the fields for creating or patching a tag.
type TagParams struct {
Name string
Notes *string
Color *string // nil = no change; pointer to empty string = clear
CategoryID *uuid.UUID // nil = no change; Nil UUID = unassign
Metadata json.RawMessage
IsPublic *bool
}
// TagService handles tag CRUD, tag-rule management, and filetag operations
// including automatic recursive rule application.
type TagService struct {
tags port.TagRepo
rules port.TagRuleRepo
acl *ACLService
audit *AuditService
tx port.Transactor
}
// NewTagService creates a TagService.
func NewTagService(
tags port.TagRepo,
rules port.TagRuleRepo,
acl *ACLService,
audit *AuditService,
tx port.Transactor,
) *TagService {
return &TagService{
tags: tags,
rules: rules,
acl: acl,
audit: audit,
tx: tx,
}
}
// ---------------------------------------------------------------------------
// Tag CRUD
// ---------------------------------------------------------------------------
// List returns a paginated, optionally filtered list of tags the caller may see.
func (s *TagService) List(ctx context.Context, params port.OffsetParams) (*domain.TagOffsetPage, error) {
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
return s.tags.List(ctx, params)
}
// Get returns a tag by ID, enforcing view ACL.
func (s *TagService) Get(ctx context.Context, id uuid.UUID) (*domain.Tag, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
t, err := s.tags.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, t.CreatorID, t.IsPublic, tagObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
return t, nil
}
// Create inserts a new tag record.
func (s *TagService) Create(ctx context.Context, p TagParams) (*domain.Tag, error) {
userID, _, _ := domain.UserFromContext(ctx)
t := &domain.Tag{
Name: p.Name,
Notes: p.Notes,
Color: p.Color,
CategoryID: p.CategoryID,
Metadata: p.Metadata,
CreatorID: userID,
}
if p.IsPublic != nil {
t.IsPublic = *p.IsPublic
}
created, err := s.tags.Create(ctx, t)
if err != nil {
return nil, err
}
objType := tagObjectType
_ = s.audit.Log(ctx, "tag_create", &objType, &created.ID, nil)
return created, nil
}
// Update applies a partial patch to a tag.
// The service reads the current tag first so the caller only needs to supply
// the fields that should change.
func (s *TagService) Update(ctx context.Context, id uuid.UUID, p TagParams) (*domain.Tag, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
current, err := s.tags.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, current.CreatorID, tagObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
// Merge patch into current.
patch := *current // copy
if p.Name != "" {
patch.Name = p.Name
}
if p.Notes != nil {
patch.Notes = p.Notes
}
if p.Color != nil {
patch.Color = p.Color
}
if p.CategoryID != nil {
if *p.CategoryID == uuid.Nil {
patch.CategoryID = nil // explicit unassign
} else {
patch.CategoryID = p.CategoryID
}
}
if len(p.Metadata) > 0 {
patch.Metadata = p.Metadata
}
if p.IsPublic != nil {
patch.IsPublic = *p.IsPublic
}
updated, err := s.tags.Update(ctx, id, &patch)
if err != nil {
return nil, err
}
objType := tagObjectType
_ = s.audit.Log(ctx, "tag_edit", &objType, &id, nil)
return updated, nil
}
// Delete removes a tag by ID, enforcing edit ACL.
func (s *TagService) Delete(ctx context.Context, id uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
t, err := s.tags.GetByID(ctx, id)
if err != nil {
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, t.CreatorID, tagObjectTypeID, id)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
if err := s.tags.Delete(ctx, id); err != nil {
return err
}
objType := tagObjectType
_ = s.audit.Log(ctx, "tag_delete", &objType, &id, nil)
return nil
}
// ---------------------------------------------------------------------------
// Tag rules
// ---------------------------------------------------------------------------
// ListRules returns all rules for a tag (when this tag is applied, these follow).
func (s *TagService) ListRules(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error) {
return s.rules.ListByTag(ctx, tagID)
}
// CreateRule adds a tag rule. When the rule is active and applyToExisting is
// true, the full transitive expansion of thenTagID is retroactively applied to
// every file already carrying whenTagID — same semantics as activating an
// existing rule via SetRuleActive. The insert and retroactive apply run in one
// transaction so a file is never left half-tagged.
func (s *TagService) CreateRule(ctx context.Context, whenTagID, thenTagID uuid.UUID, isActive, applyToExisting bool) (*domain.TagRule, error) {
var created *domain.TagRule
err := s.tx.WithTx(ctx, func(ctx context.Context) error {
rule, err := s.rules.Create(ctx, domain.TagRule{
WhenTagID: whenTagID,
ThenTagID: thenTagID,
IsActive: isActive,
})
if err != nil {
return err
}
created = rule
if isActive && applyToExisting {
return s.rules.ApplyToExisting(ctx, whenTagID, thenTagID)
}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}
// SetRuleActive toggles a rule's is_active flag and returns the updated rule.
// When active and applyToExisting are both true, the full transitive expansion
// of thenTagID is retroactively applied to files already carrying whenTagID.
func (s *TagService) SetRuleActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active, applyToExisting bool) (*domain.TagRule, error) {
if err := s.rules.SetActive(ctx, whenTagID, thenTagID, active, applyToExisting); err != nil {
return nil, err
}
rules, err := s.rules.ListByTag(ctx, whenTagID)
if err != nil {
return nil, err
}
for _, r := range rules {
if r.ThenTagID == thenTagID {
return &r, nil
}
}
return nil, domain.ErrNotFound
}
// DeleteRule removes a tag rule.
func (s *TagService) DeleteRule(ctx context.Context, whenTagID, thenTagID uuid.UUID) error {
return s.rules.Delete(ctx, whenTagID, thenTagID)
}
// ---------------------------------------------------------------------------
// Filetag operations (with auto-rule expansion)
// ---------------------------------------------------------------------------
// ListFileTags returns the tags on a file.
func (s *TagService) ListFileTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
return s.tags.ListByFile(ctx, fileID)
}
// SetFileTags replaces all tags on a file, then applies active rules for all
// newly set tags (BFS expansion). Returns the full resulting tag set.
func (s *TagService) SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) ([]domain.Tag, error) {
expanded, err := s.expandTagSet(ctx, tagIDs)
if err != nil {
return nil, err
}
if err := s.tags.SetFileTags(ctx, fileID, expanded); err != nil {
return nil, err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, nil)
return s.tags.ListByFile(ctx, fileID)
}
// AddFileTag adds a single tag to a file, then recursively applies active rules.
// Returns the full resulting tag set.
func (s *TagService) AddFileTag(ctx context.Context, fileID, tagID uuid.UUID) ([]domain.Tag, error) {
// Compute the full set including rule-expansion from tagID.
extra, err := s.expandTagSet(ctx, []uuid.UUID{tagID})
if err != nil {
return nil, err
}
// Fetch current tags so we don't lose them.
current, err := s.tags.ListByFile(ctx, fileID)
if err != nil {
return nil, err
}
// Union: existing + expanded new tags.
seen := make(map[uuid.UUID]bool, len(current)+len(extra))
for _, t := range current {
seen[t.ID] = true
}
merged := make([]uuid.UUID, len(current))
for i, t := range current {
merged[i] = t.ID
}
for _, id := range extra {
if !seen[id] {
seen[id] = true
merged = append(merged, id)
}
}
if err := s.tags.SetFileTags(ctx, fileID, merged); err != nil {
return nil, err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, map[string]any{"tag_id": tagID})
return s.tags.ListByFile(ctx, fileID)
}
// RemoveFileTag removes a single tag from a file.
func (s *TagService) RemoveFileTag(ctx context.Context, fileID, tagID uuid.UUID) error {
if err := s.tags.RemoveFileTag(ctx, fileID, tagID); err != nil {
return err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_tag_remove", &objType, &fileID, map[string]any{"tag_id": tagID})
return nil
}
// BulkSetTags adds or removes tags on multiple files (with rule expansion for add).
// Returns the tagIDs that were applied (the expanded input set for add; empty for remove).
func (s *TagService) BulkSetTags(ctx context.Context, fileIDs []uuid.UUID, action string, tagIDs []uuid.UUID) ([]uuid.UUID, error) {
if action != "add" && action != "remove" {
return nil, domain.ErrValidation
}
// Pre-expand tag set once; all files get the same expansion.
var expanded []uuid.UUID
if action == "add" {
var err error
expanded, err = s.expandTagSet(ctx, tagIDs)
if err != nil {
return nil, err
}
}
for _, fileID := range fileIDs {
switch action {
case "add":
current, err := s.tags.ListByFile(ctx, fileID)
if err != nil {
if err == domain.ErrNotFound {
continue
}
return nil, err
}
seen := make(map[uuid.UUID]bool, len(current))
merged := make([]uuid.UUID, len(current))
for i, t := range current {
seen[t.ID] = true
merged[i] = t.ID
}
for _, id := range expanded {
if !seen[id] {
seen[id] = true
merged = append(merged, id)
}
}
if err := s.tags.SetFileTags(ctx, fileID, merged); err != nil {
return nil, err
}
case "remove":
current, err := s.tags.ListByFile(ctx, fileID)
if err != nil {
if err == domain.ErrNotFound {
continue
}
return nil, err
}
remove := make(map[uuid.UUID]bool, len(tagIDs))
for _, id := range tagIDs {
remove[id] = true
}
kept := make([]uuid.UUID, 0, len(current))
for _, t := range current {
if !remove[t.ID] {
kept = append(kept, t.ID)
}
}
if err := s.tags.SetFileTags(ctx, fileID, kept); err != nil {
return nil, err
}
}
}
if action == "add" {
return expanded, nil
}
return []uuid.UUID{}, nil
}
// CommonTags returns tags present on ALL given files and tags present on SOME.
func (s *TagService) CommonTags(ctx context.Context, fileIDs []uuid.UUID) (common, partial []domain.Tag, err error) {
common, err = s.tags.CommonTagsForFiles(ctx, fileIDs)
if err != nil {
return nil, nil, err
}
partial, err = s.tags.PartialTagsForFiles(ctx, fileIDs)
if err != nil {
return nil, nil, err
}
return common, partial, nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// expandTagSet runs a BFS from the given seed tags, following active tag rules,
// and returns the full set of tag IDs that should be applied (seeds + auto-applied).
func (s *TagService) expandTagSet(ctx context.Context, seeds []uuid.UUID) ([]uuid.UUID, error) {
visited := make(map[uuid.UUID]bool, len(seeds))
queue := make([]uuid.UUID, 0, len(seeds))
for _, id := range seeds {
if !visited[id] {
visited[id] = true
queue = append(queue, id)
}
}
for i := 0; i < len(queue); i++ {
tagID := queue[i]
rules, err := s.rules.ListByTag(ctx, tagID)
if err != nil {
return nil, err
}
for _, r := range rules {
if r.IsActive && !visited[r.ThenTagID] {
visited[r.ThenTagID] = true
queue = append(queue, r.ThenTagID)
}
}
}
return queue, nil
}
+192
View File
@@ -0,0 +1,192 @@
package service
import (
"context"
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// UserService handles user CRUD and profile management.
type UserService struct {
users port.UserRepo
sessions port.SessionRepo
audit *AuditService
}
// NewUserService creates a UserService.
func NewUserService(users port.UserRepo, sessions port.SessionRepo, audit *AuditService) *UserService {
return &UserService{users: users, sessions: sessions, audit: audit}
}
// EnsureAdmin creates the initial administrator account if it does not already
// exist. It is idempotent and never overwrites an existing user's password, so
// an operator who has changed the admin password keeps it across restarts.
func (s *UserService) EnsureAdmin(ctx context.Context, username, password string) error {
if username == "" || password == "" {
return fmt.Errorf("EnsureAdmin: username and password must be set")
}
if _, err := s.users.GetByName(ctx, username); err == nil {
return nil // already exists
} else if !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("EnsureAdmin: lookup: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("EnsureAdmin: hash: %w", err)
}
_, err = s.users.Create(ctx, &domain.User{
Name: username,
Password: string(hash),
IsAdmin: true,
CanCreate: true,
})
if err != nil {
return fmt.Errorf("EnsureAdmin: create: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// Self-service
// ---------------------------------------------------------------------------
// GetMe returns the profile of the currently authenticated user.
func (s *UserService) GetMe(ctx context.Context) (*domain.User, error) {
userID, _, _ := domain.UserFromContext(ctx)
return s.users.GetByID(ctx, userID)
}
// UpdateMeParams holds fields a user may change on their own profile.
type UpdateMeParams struct {
Name string // empty = no change
Password *string // nil = no change
}
// UpdateMe allows a user to change their own name and/or password.
func (s *UserService) UpdateMe(ctx context.Context, p UpdateMeParams) (*domain.User, error) {
userID, _, _ := domain.UserFromContext(ctx)
current, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
patch := *current
if p.Name != "" {
patch.Name = p.Name
}
if p.Password != nil {
hash, err := bcrypt.GenerateFromPassword([]byte(*p.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("UserService.UpdateMe hash: %w", err)
}
patch.Password = string(hash)
}
return s.users.Update(ctx, userID, &patch)
}
// ---------------------------------------------------------------------------
// Admin CRUD
// ---------------------------------------------------------------------------
// List returns a paginated list of users (admin only — caller must enforce).
func (s *UserService) List(ctx context.Context, params port.OffsetParams) (*domain.UserPage, error) {
return s.users.List(ctx, params)
}
// Get returns a user by ID (admin only).
func (s *UserService) Get(ctx context.Context, id int16) (*domain.User, error) {
return s.users.GetByID(ctx, id)
}
// CreateParams holds fields for creating a new user.
type CreateUserParams struct {
Name string
Password string
IsAdmin bool
CanCreate bool
}
// Create inserts a new user with a bcrypt-hashed password (admin only).
func (s *UserService) Create(ctx context.Context, p CreateUserParams) (*domain.User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(p.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("UserService.Create hash: %w", err)
}
u := &domain.User{
Name: p.Name,
Password: string(hash),
IsAdmin: p.IsAdmin,
CanCreate: p.CanCreate,
}
created, err := s.users.Create(ctx, u)
if err != nil {
return nil, err
}
_ = s.audit.Log(ctx, "user_create", nil, nil, map[string]any{"target_user_id": created.ID})
return created, nil
}
// UpdateAdminParams holds fields an admin may change on any user.
type UpdateAdminParams struct {
IsAdmin *bool
CanCreate *bool
IsBlocked *bool
}
// UpdateAdmin applies an admin-level patch to a user.
func (s *UserService) UpdateAdmin(ctx context.Context, id int16, p UpdateAdminParams) (*domain.User, error) {
current, err := s.users.GetByID(ctx, id)
if err != nil {
return nil, err
}
patch := *current
if p.IsAdmin != nil {
patch.IsAdmin = *p.IsAdmin
}
if p.CanCreate != nil {
patch.CanCreate = *p.CanCreate
}
if p.IsBlocked != nil {
patch.IsBlocked = *p.IsBlocked
}
updated, err := s.users.Update(ctx, id, &patch)
if err != nil {
return nil, err
}
// Log block/unblock specifically, and revoke all sessions on block so the
// user's outstanding access tokens stop working immediately.
if p.IsBlocked != nil {
action := "user_unblock"
if *p.IsBlocked {
action = "user_block"
if err := s.sessions.DeleteByUserID(ctx, id); err != nil {
return nil, fmt.Errorf("UserService.UpdateAdmin revoke sessions: %w", err)
}
}
_ = s.audit.Log(ctx, action, nil, nil, map[string]any{"target_user_id": id})
}
return updated, nil
}
// Delete removes a user by ID (admin only).
func (s *UserService) Delete(ctx context.Context, id int16) error {
if err := s.users.Delete(ctx, id); err != nil {
return err
}
_ = s.audit.Log(ctx, "user_delete", nil, nil, map[string]any{"target_user_id": id})
return nil
}
+455
View File
@@ -0,0 +1,455 @@
// Package storage provides a local-filesystem implementation of port.FileStorage.
package storage
import (
"bytes"
"context"
"fmt"
_ "golang.org/x/image/webp" // register WebP decoder
"image"
"image/color"
_ "image/gif" // register GIF decoder
"image/jpeg"
_ "image/png" // register PNG decoder
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// DiskStorage implements port.FileStorage using the local filesystem.
//
// Directory layout:
//
// {filesPath}/{id} — original file (UUID basename, no extension)
// {thumbsPath}/{id}_thumb.jpg — thumbnail cache
// {thumbsPath}/{id}_preview.jpg — preview cache
type DiskStorage struct {
filesPath string
thumbsPath string
thumbWidth int
thumbHeight int
previewWidth int
previewHeight int
maxPixels int
// genSem bounds concurrent thumbnail/preview generation. Each resize already
// fans out across every core (imaging uses GOMAXPROCS), and large sources cost
// hundreds of MB to decode, so unbounded parallelism on a burst of big images
// pegs the CPU and can exhaust RAM. A buffered channel caps how many run at once.
genSem chan struct{}
}
var _ port.FileStorage = (*DiskStorage)(nil)
// NewDiskStorage creates a DiskStorage and ensures both directories exist.
//
// maxPixels caps the source pixel count we will decode in-process (0 → a sane
// default). concurrency bounds simultaneous generation (≤0 → half the CPUs).
func NewDiskStorage(
filesPath, thumbsPath string,
thumbW, thumbH, prevW, prevH int,
maxPixels, concurrency int,
) (*DiskStorage, error) {
for _, p := range []string{filesPath, thumbsPath} {
if err := os.MkdirAll(p, 0o755); err != nil {
return nil, fmt.Errorf("storage: create directory %q: %w", p, err)
}
}
if maxPixels <= 0 {
maxPixels = defaultMaxDecodePixels
}
if concurrency <= 0 {
concurrency = max(1, runtime.GOMAXPROCS(0)/2)
}
return &DiskStorage{
filesPath: filesPath,
thumbsPath: thumbsPath,
thumbWidth: thumbW,
thumbHeight: thumbH,
previewWidth: prevW,
previewHeight: prevH,
maxPixels: maxPixels,
genSem: make(chan struct{}, concurrency),
}, nil
}
// ---------------------------------------------------------------------------
// port.FileStorage implementation
// ---------------------------------------------------------------------------
// Save writes r to {filesPath}/{id} and returns the number of bytes written.
func (s *DiskStorage) Save(_ context.Context, id uuid.UUID, r io.Reader) (int64, error) {
dst := s.originalPath(id)
f, err := os.Create(dst)
if err != nil {
return 0, fmt.Errorf("storage.Save create %q: %w", dst, err)
}
n, copyErr := io.Copy(f, r)
closeErr := f.Close()
if copyErr != nil {
os.Remove(dst)
return 0, fmt.Errorf("storage.Save write: %w", copyErr)
}
if closeErr != nil {
os.Remove(dst)
return 0, fmt.Errorf("storage.Save close: %w", closeErr)
}
return n, nil
}
// Read opens the original file for reading. The caller must close the result.
func (s *DiskStorage) Read(_ context.Context, id uuid.UUID) (io.ReadCloser, error) {
f, err := os.Open(s.originalPath(id))
if err != nil {
if os.IsNotExist(err) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("storage.Read: %w", err)
}
return f, nil
}
// Delete removes the original file. Returns ErrNotFound if it does not exist.
func (s *DiskStorage) Delete(_ context.Context, id uuid.UUID) error {
if err := os.Remove(s.originalPath(id)); err != nil {
if os.IsNotExist(err) {
return domain.ErrNotFound
}
return fmt.Errorf("storage.Delete: %w", err)
}
return nil
}
// InvalidateCache removes the cached thumbnail and preview for id, if present,
// so they are regenerated from the current file content on the next request.
func (s *DiskStorage) InvalidateCache(_ context.Context, id uuid.UUID) error {
for _, p := range []string{s.thumbCachePath(id), s.previewCachePath(id)} {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("storage.InvalidateCache remove %q: %w", p, err)
}
}
return nil
}
// Thumbnail returns a JPEG scaled to fit within the configured max width×height,
// preserving the original aspect ratio (never upscaled, never cropped); the grid
// cell letterboxes it as needed. Generated on first call and cached. Video files
// are thumbnailed via ffmpeg; other non-image files get a placeholder.
func (s *DiskStorage) Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
return s.serveGenerated(ctx, id, s.thumbCachePath(id), s.thumbWidth, s.thumbHeight)
}
// Preview returns a JPEG scaled to fit within the configured max width×height,
// preserving the original aspect ratio (never upscaled, never cropped) so the
// viewer shows the whole image. Generated on first call and cached. Video files
// are thumbnailed via ffmpeg; other non-image files get a placeholder.
func (s *DiskStorage) Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
return s.serveGenerated(ctx, id, s.previewCachePath(id), s.previewWidth, s.previewHeight)
}
// VideoFrameMiddle decodes a representative frame from the middle of a video
// (duration/2). The midpoint avoids the shared intros, title cards and black
// lead-in frames that make a fixed early offset collide across unrelated clips,
// so it is the right source for the video's perceptual (duplicate-detection)
// hash. The file must already exist in storage; ffmpeg/ffprobe must be installed.
// This is not part of port.FileStorage — only the dedup CLI needs it, with a
// concrete *DiskStorage — so the interface stays lean and ffmpeg stays out of the
// upload path.
func (s *DiskStorage) VideoFrameMiddle(ctx context.Context, id uuid.UUID) (image.Image, error) {
srcPath := s.originalPath(id)
if _, err := os.Stat(srcPath); err != nil {
if os.IsNotExist(err) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err)
}
return extractVideoFrameMiddle(ctx, srcPath)
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// serveGenerated is the shared implementation for Thumbnail and Preview. Both
// fit the source within maxW×maxH preserving the aspect ratio (no crop, no
// upscale); they differ only in the configured dimensions.
//
// Resolution order:
// 1. Return cached JPEG if present.
// 2. vipsthumbnail (shrink-on-load; the primary still-image path).
// 3. Pure-Go decode + imaging.Fit (fallback when vips is absent).
// 4. Extract a frame with ffmpeg (video files).
// 5. Solid-colour placeholder (archives, unrecognised formats, etc.).
func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePath string, maxW, maxH int) (io.ReadCloser, error) {
// Fast path: cache hit.
if f, err := os.Open(cachePath); err == nil {
return f, nil
}
// Verify the original file exists before doing any work.
srcPath := s.originalPath(id)
if _, err := os.Stat(srcPath); err != nil {
if os.IsNotExist(err) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err)
}
// Bound concurrent generation so a burst of large images can't peg every core
// or exhaust RAM. Queue here (respecting cancellation) rather than starting
// the heavy decode immediately.
select {
case s.genSem <- struct{}{}:
defer func() { <-s.genSem }()
case <-ctx.Done():
return nil, ctx.Err()
}
// Another request may have generated this while we waited on the semaphore.
if f, err := os.Open(cachePath); err == nil {
return f, nil
}
// Primary path: vipsthumbnail. It shrinks on load (e.g. JPEG DCT scaling), so
// even a 200+ Mpx photo is thumbnailed in a fraction of the memory and CPU of a
// full in-process decode, writing the final JPEG straight to the cache. Falls
// through when vips is absent or can't read the source (e.g. a video).
if vipsThumbnailPath != "" {
if rc, err := s.vipsThumbnail(ctx, srcPath, cachePath, maxW, maxH); err == nil {
return rc, nil
}
}
// Fallback pipeline (pure Go):
// 1. Still-image decode (JPEG/PNG/GIF), rejecting oversized rasters.
// 2. Video frame extraction via ffmpeg.
// 3. Solid-colour placeholder.
var img image.Image
if decoded, err := decodeImageLimited(srcPath, s.maxPixels); err == nil {
img = imaging.Fit(decoded, maxW, maxH, imaging.Lanczos)
} else if frame, err := extractVideoFrameMiddle(ctx, srcPath); err == nil {
img = imaging.Fit(frame, maxW, maxH, imaging.Lanczos)
} else {
img = placeholder(maxW, maxH)
}
// Write to cache atomically (temp→rename) and return an open reader.
if rc, err := writeCache(cachePath, img); err == nil {
return rc, nil
}
// Cache write failed (read-only fs, disk full, …). Serve from an
// in-memory buffer so the request still succeeds.
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
return nil, fmt.Errorf("storage: encode in-memory JPEG: %w", err)
}
return io.NopCloser(&buf), nil
}
// writeCache encodes img as JPEG to cachePath via an atomic temp→rename write,
// then opens and returns the cache file.
func writeCache(cachePath string, img image.Image) (io.ReadCloser, error) {
dir := filepath.Dir(cachePath)
tmp, err := os.CreateTemp(dir, ".cache-*.tmp")
if err != nil {
return nil, fmt.Errorf("storage: create temp file: %w", err)
}
tmpName := tmp.Name()
encErr := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 85})
closeErr := tmp.Close()
if encErr != nil {
os.Remove(tmpName)
return nil, fmt.Errorf("storage: encode cache JPEG: %w", encErr)
}
if closeErr != nil {
os.Remove(tmpName)
return nil, fmt.Errorf("storage: close temp file: %w", closeErr)
}
if err := os.Rename(tmpName, cachePath); err != nil {
os.Remove(tmpName)
return nil, fmt.Errorf("storage: rename cache file: %w", err)
}
f, err := os.Open(cachePath)
if err != nil {
return nil, fmt.Errorf("storage: open cache file: %w", err)
}
return f, nil
}
// defaultMaxDecodePixels is the fallback cap when none is configured. It bounds
// the cost of a decompression bomb (a tiny file that expands to an enormous
// raster) and the per-image memory; ~300 Mpx covers e.g. a 13000×17000 photo.
const defaultMaxDecodePixels = 300_000_000
// decodeImageLimited decodes the image at path after first inspecting its header
// dimensions via image.DecodeConfig (which does not allocate the raster), and
// refuses images whose pixel count exceeds maxPixels.
func decodeImageLimited(path string, maxPixels int) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return nil, err
}
if int64(cfg.Width)*int64(cfg.Height) > int64(maxPixels) {
return nil, fmt.Errorf("image too large to decode: %dx%d", cfg.Width, cfg.Height)
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return nil, err
}
return imaging.Decode(f, imaging.AutoOrientation(true))
}
// vipsThumbnailPath is the resolved path to the vipsthumbnail CLI, or "" when it
// isn't installed — in which case generation falls back to the pure-Go pipeline.
var vipsThumbnailPath, _ = exec.LookPath("vipsthumbnail")
// vipsThumbnail generates a JPEG thumbnail with the vipsthumbnail CLI, writing it
// straight to cachePath via an atomic temp→rename. vips decodes large images at a
// reduced scale (shrink-on-load), so this costs a fraction of the memory and CPU
// of a full in-process decode. The result is fit within maxW×maxH and never
// upscaled (the ">" size modifier). Returns an error for inputs vips can't read
// (e.g. videos) so the caller can fall back.
func (s *DiskStorage) vipsThumbnail(ctx context.Context, srcPath, cachePath string, maxW, maxH int) (io.ReadCloser, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
tmp, err := os.CreateTemp(filepath.Dir(cachePath), ".vips-*.jpg")
if err != nil {
return nil, fmt.Errorf("storage: create temp file: %w", err)
}
tmpName := tmp.Name()
_ = tmp.Close()
cmd := exec.CommandContext(ctx, vipsThumbnailPath,
srcPath,
"--size", fmt.Sprintf("%dx%d>", maxW, maxH),
"--output", tmpName+"[Q=85]",
)
cmd.Stderr = io.Discard
if err := cmd.Run(); err != nil {
os.Remove(tmpName)
return nil, fmt.Errorf("vipsthumbnail: %w", err)
}
if fi, err := os.Stat(tmpName); err != nil || fi.Size() == 0 {
os.Remove(tmpName)
return nil, fmt.Errorf("vipsthumbnail: no output produced")
}
if err := os.Rename(tmpName, cachePath); err != nil {
os.Remove(tmpName)
return nil, fmt.Errorf("storage: rename cache file: %w", err)
}
f, err := os.Open(cachePath)
if err != nil {
return nil, fmt.Errorf("storage: open cache file: %w", err)
}
return f, nil
}
// extractVideoFrameMiddle extracts a single frame from the middle of the video
// (duration/2), falling back to a 1s offset when the duration can't be probed.
// The midpoint dodges shared intros, title cards and black lead-in frames, and
// matches the frame used for the perceptual hash so a video's thumbnail/preview
// shows the same representative frame dedup compared. See extractVideoFrameAt for
// the mechanics.
func extractVideoFrameMiddle(ctx context.Context, srcPath string) (image.Image, error) {
at := 1.0
if d, err := videoDurationSeconds(ctx, srcPath); err == nil && d > 0 {
at = d / 2
}
return extractVideoFrameAt(ctx, srcPath, at)
}
// extractVideoFrameAt uses ffmpeg to extract a single frame at atSec seconds into
// the video, piped out as PNG. The fast input seek (-ss before -i) is keyframe-
// accurate and cheap; if atSec is past the end the seek is silently ignored and
// the first available frame is returned instead. Returns an error if ffmpeg is
// not installed or produces no output. The run is bounded by a timeout so a
// malformed file cannot hang the caller indefinitely.
func extractVideoFrameAt(ctx context.Context, srcPath string, atSec float64) (image.Image, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var out bytes.Buffer
cmd := exec.CommandContext(ctx, "ffmpeg",
"-ss", strconv.FormatFloat(atSec, 'f', 3, 64), // fast input seek; ignored gracefully past end
"-i", srcPath,
"-vframes", "1",
"-f", "image2",
"-vcodec", "png",
"pipe:1",
)
cmd.Stdout = &out
cmd.Stderr = io.Discard // suppress ffmpeg progress output
if err := cmd.Run(); err != nil || out.Len() == 0 {
return nil, fmt.Errorf("ffmpeg frame extract: %w", err)
}
return imaging.Decode(&out)
}
// videoDurationSeconds returns the container duration in seconds via ffprobe.
// Used to seek to the middle of a clip for perceptual hashing and thumbnails.
func videoDurationSeconds(ctx context.Context, srcPath string) (float64, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
srcPath,
)
out, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf("ffprobe duration: %w", err)
}
d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil {
return 0, fmt.Errorf("ffprobe duration parse %q: %w", out, err)
}
return d, nil
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
func (s *DiskStorage) originalPath(id uuid.UUID) string {
return filepath.Join(s.filesPath, id.String())
}
func (s *DiskStorage) thumbCachePath(id uuid.UUID) string {
return filepath.Join(s.thumbsPath, id.String()+"_thumb.jpg")
}
func (s *DiskStorage) previewCachePath(id uuid.UUID) string {
return filepath.Join(s.thumbsPath, id.String()+"_preview.jpg")
}
// ---------------------------------------------------------------------------
// Image helpers
// ---------------------------------------------------------------------------
// placeholder returns a solid-colour image of size w×h for files that cannot
// be decoded as images. Uses #444455 from the design palette.
func placeholder(w, h int) *image.NRGBA {
return imaging.New(w, h, color.NRGBA{R: 0x44, G: 0x44, B: 0x55, A: 0xFF})
}
+181
View File
@@ -0,0 +1,181 @@
package storage
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"io"
"os"
"path/filepath"
"testing"
"github.com/disintegration/imaging"
"github.com/google/uuid"
)
// writeTestImage writes a w×h PNG filled with a distinct (non-placeholder)
// colour so a generated thumbnail can be told apart from the grey placeholder.
func writeTestImage(t *testing.T, path string, w, h int) {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.NRGBA{R: 0xC0, G: 0x10, B: 0x20, A: 0xFF})
}
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
}
func TestDecodeImageLimited(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "img.png")
writeTestImage(t, p, 100, 80) // 8000 px
if _, err := decodeImageLimited(p, 4000); err == nil {
t.Fatal("expected rejection for an image over the pixel cap")
}
img, err := decodeImageLimited(p, 100000)
if err != nil {
t.Fatalf("expected decode within the cap, got %v", err)
}
if b := img.Bounds(); b.Dx() != 100 || b.Dy() != 80 {
t.Fatalf("unexpected decoded size %v", b.Size())
}
}
// TestThumbnailGeneratesAndCaches exercises the full generation path (semaphore
// acquire → decode → fit → encode → cache) and the cache fast path on re-request.
func TestThumbnailGeneratesAndCaches(t *testing.T) {
files := t.TempDir()
thumbs := t.TempDir()
id := uuid.New()
writeTestImage(t, filepath.Join(files, id.String()), 100, 80)
s, err := NewDiskStorage(files, thumbs, 160, 160, 1920, 1080, 0, 1)
if err != nil {
t.Fatal(err)
}
rc, err := s.Thumbnail(context.Background(), id)
if err != nil {
t.Fatalf("Thumbnail: %v", err)
}
data, _ := io.ReadAll(rc)
rc.Close()
out, err := imaging.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode thumbnail: %v", err)
}
// The source fits within 160×160, so it is not upscaled.
if b := out.Bounds(); b.Dx() != 100 || b.Dy() != 80 {
t.Fatalf("unexpected thumbnail size %v", b.Size())
}
// Centre pixel should be the source's red, not the grey placeholder.
r, g, b, _ := out.At(50, 40).RGBA()
if !(r>>8 > g>>8+40 && r>>8 > b>>8+40) {
t.Fatalf("thumbnail is not the source image (got r=%d g=%d b=%d) — fell back to placeholder?", r>>8, g>>8, b>>8)
}
// The cache file must now exist, and a second request must serve it.
if _, err := os.Stat(s.thumbCachePath(id)); err != nil {
t.Fatalf("cache file not written: %v", err)
}
rc2, err := s.Thumbnail(context.Background(), id)
if err != nil {
t.Fatalf("Thumbnail (cached): %v", err)
}
rc2.Close()
}
// TestThumbnailFallbackWithoutVips forces the pure-Go pipeline (as if vips were
// not installed) and verifies generation still produces the source image.
func TestThumbnailFallbackWithoutVips(t *testing.T) {
orig := vipsThumbnailPath
vipsThumbnailPath = ""
t.Cleanup(func() { vipsThumbnailPath = orig })
files := t.TempDir()
thumbs := t.TempDir()
id := uuid.New()
writeTestImage(t, filepath.Join(files, id.String()), 100, 80)
s, err := NewDiskStorage(files, thumbs, 160, 160, 1920, 1080, 0, 1)
if err != nil {
t.Fatal(err)
}
rc, err := s.Thumbnail(context.Background(), id)
if err != nil {
t.Fatalf("Thumbnail: %v", err)
}
data, _ := io.ReadAll(rc)
rc.Close()
out, err := imaging.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode thumbnail: %v", err)
}
if b := out.Bounds(); b.Dx() != 100 || b.Dy() != 80 {
t.Fatalf("unexpected thumbnail size %v", b.Size())
}
r, g, b, _ := out.At(50, 40).RGBA()
if !(r>>8 > g>>8+40 && r>>8 > b>>8+40) {
t.Fatalf("fallback produced a placeholder, not the source (r=%d g=%d b=%d)", r>>8, g>>8, b>>8)
}
}
// TestPreviewGeneratesAndCaches verifies Preview runs through the same pipeline
// with the preview dimensions and its own cache file (not the thumbnail's).
func TestPreviewGeneratesAndCaches(t *testing.T) {
files := t.TempDir()
thumbs := t.TempDir()
id := uuid.New()
// Larger than the thumbnail box but within the preview box, so the preview
// keeps full resolution where a thumbnail would shrink it.
writeTestImage(t, filepath.Join(files, id.String()), 400, 300)
s, err := NewDiskStorage(files, thumbs, 160, 160, 1920, 1080, 0, 1)
if err != nil {
t.Fatal(err)
}
rc, err := s.Preview(context.Background(), id)
if err != nil {
t.Fatalf("Preview: %v", err)
}
data, _ := io.ReadAll(rc)
rc.Close()
out, err := imaging.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode preview: %v", err)
}
// 400×300 fits within 1920×1080, so the preview is not downscaled.
if b := out.Bounds(); b.Dx() != 400 || b.Dy() != 300 {
t.Fatalf("unexpected preview size %v", b.Size())
}
r, g, b, _ := out.At(200, 150).RGBA()
if !(r>>8 > g>>8+40 && r>>8 > b>>8+40) {
t.Fatalf("preview is not the source image (r=%d g=%d b=%d)", r>>8, g>>8, b>>8)
}
// The preview cache must be written, and the thumbnail cache must not — they
// are separate files served by the same code with different dimensions.
if _, err := os.Stat(s.previewCachePath(id)); err != nil {
t.Fatalf("preview cache not written: %v", err)
}
if _, err := os.Stat(s.thumbCachePath(id)); err == nil {
t.Fatal("thumbnail cache should not exist after a preview-only request")
}
}
+65
View File
@@ -0,0 +1,65 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE SCHEMA IF NOT EXISTS core;
CREATE SCHEMA IF NOT EXISTS data;
CREATE SCHEMA IF NOT EXISTS acl;
CREATE SCHEMA IF NOT EXISTS activity;
-- UUID v7 generator
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION public.uuid_v7(cts timestamptz DEFAULT clock_timestamp())
RETURNS uuid LANGUAGE plpgsql AS $$
DECLARE
state text = current_setting('uuidv7.old_tp', true);
old_tp text = split_part(state, ':', 1);
base int = coalesce(nullif(split_part(state, ':', 4), '')::int, (random()*16777215/2-1)::int);
tp text;
entropy text;
seq text = base;
seqn int = split_part(state, ':', 2);
ver text = coalesce(split_part(state, ':', 3), to_hex(8+(random()*3)::int));
BEGIN
base = (random()*16777215/2-1)::int;
tp = lpad(to_hex(floor(extract(epoch from cts)*1000)::int8), 12, '0') || '7';
IF tp IS DISTINCT FROM old_tp THEN
old_tp = tp;
ver = to_hex(8+(random()*3)::int);
base = (random()*16777215/2-1)::int;
seqn = base;
ELSE
seqn = seqn + (random()*1000)::int;
END IF;
PERFORM set_config('uuidv7.old_tp', old_tp||':'||seqn||':'||ver||':'||base, false);
entropy = md5(gen_random_uuid()::text);
seq = lpad(to_hex(seqn), 6, '0');
RETURN (tp || substring(seq from 1 for 3) || ver || substring(seq from 4 for 3) ||
substring(entropy from 1 for 12))::uuid;
END;
$$;
-- +goose StatementEnd
-- Extract timestamp from UUID v7
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION public.uuid_extract_timestamp(uuid_val uuid)
RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT to_timestamp(
('x' || left(replace(uuid_val::text, '-', ''), 12))::bit(48)::bigint / 1000.0
);
$$;
-- +goose StatementEnd
-- +goose Down
DROP FUNCTION IF EXISTS public.uuid_extract_timestamp(uuid);
DROP FUNCTION IF EXISTS public.uuid_v7(timestamptz);
DROP SCHEMA IF EXISTS activity;
DROP SCHEMA IF EXISTS acl;
DROP SCHEMA IF EXISTS data;
DROP SCHEMA IF EXISTS core;
DROP EXTENSION IF EXISTS "uuid-ossp";
DROP EXTENSION IF EXISTS pgcrypto;
+37
View File
@@ -0,0 +1,37 @@
-- +goose Up
CREATE TABLE core.users (
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(32) NOT NULL,
password text NOT NULL, -- bcrypt hash via pgcrypto
is_admin boolean NOT NULL DEFAULT false,
can_create boolean NOT NULL DEFAULT false,
is_blocked boolean NOT NULL DEFAULT false,
CONSTRAINT uni__users__name UNIQUE (name)
);
CREATE TABLE core.mime_types (
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(127) NOT NULL,
extension varchar(16) NOT NULL,
CONSTRAINT uni__mime_types__name UNIQUE (name)
);
CREATE TABLE core.object_types (
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(32) NOT NULL,
CONSTRAINT uni__object_types__name UNIQUE (name)
);
COMMENT ON TABLE core.users IS 'Application users';
COMMENT ON TABLE core.mime_types IS 'Whitelist of supported MIME types';
COMMENT ON TABLE core.object_types IS 'Reference: entity types for ACL and audit log';
-- +goose Down
DROP TABLE IF EXISTS core.object_types;
DROP TABLE IF EXISTS core.mime_types;
DROP TABLE IF EXISTS core.users;
+148
View File
@@ -0,0 +1,148 @@
-- +goose Up
CREATE TABLE data.categories (
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
name varchar(256) NOT NULL,
notes text,
color char(6),
metadata jsonb,
creator_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
is_public boolean NOT NULL DEFAULT false,
CONSTRAINT uni__categories__name UNIQUE (name),
CONSTRAINT chk__categories__color_hex
CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$')
);
CREATE TABLE data.tags (
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
name varchar(256) NOT NULL,
notes text,
color char(6),
category_id uuid REFERENCES data.categories(id)
ON UPDATE CASCADE ON DELETE SET NULL,
metadata jsonb,
creator_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
is_public boolean NOT NULL DEFAULT false,
CONSTRAINT uni__tags__name UNIQUE (name),
CONSTRAINT chk__tags__color_hex
CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$')
);
CREATE TABLE data.tag_rules (
when_tag_id uuid NOT NULL REFERENCES data.tags(id)
ON UPDATE CASCADE ON DELETE CASCADE,
then_tag_id uuid NOT NULL REFERENCES data.tags(id)
ON UPDATE CASCADE ON DELETE CASCADE,
is_active boolean NOT NULL DEFAULT true,
PRIMARY KEY (when_tag_id, then_tag_id)
);
CREATE TABLE data.files (
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
original_name varchar(256), -- original filename at upload time
mime_id smallint NOT NULL REFERENCES core.mime_types(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
content_datetime timestamptz NOT NULL DEFAULT clock_timestamp(), -- content datetime (e.g. photo taken)
notes text,
metadata jsonb, -- user-editable key-value data
exif jsonb, -- EXIF data extracted at upload (immutable)
phash bigint, -- perceptual hash for duplicate detection (future)
creator_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
is_public boolean NOT NULL DEFAULT false,
is_deleted boolean NOT NULL DEFAULT false, -- soft delete (trash)
needs_review boolean NOT NULL DEFAULT true -- tagging not yet marked done; cleared explicitly
);
CREATE TABLE data.file_tag (
file_id uuid NOT NULL REFERENCES data.files(id)
ON UPDATE CASCADE ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES data.tags(id)
ON UPDATE CASCADE ON DELETE CASCADE,
PRIMARY KEY (file_id, tag_id)
);
CREATE TABLE data.pools (
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
name varchar(256) NOT NULL,
notes text,
metadata jsonb,
creator_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
is_public boolean NOT NULL DEFAULT false,
CONSTRAINT uni__pools__name UNIQUE (name)
);
-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow
-- insertions without renumbering. Compact when gaps get too small.
CREATE TABLE data.file_pool (
file_id uuid NOT NULL REFERENCES data.files(id)
ON UPDATE CASCADE ON DELETE CASCADE,
pool_id uuid NOT NULL REFERENCES data.pools(id)
ON UPDATE CASCADE ON DELETE CASCADE,
position integer NOT NULL DEFAULT 0,
PRIMARY KEY (file_id, pool_id)
);
-- Precomputed near-duplicate candidates (phash Hamming distance <= threshold),
-- (re)built in full by the dedup rescan. Stored once per unordered pair with a
-- canonical file_a < file_b ordering so a pair is never duplicated as (a,b)/(b,a).
CREATE TABLE data.duplicate_pairs (
file_a uuid NOT NULL REFERENCES data.files(id) ON UPDATE CASCADE ON DELETE CASCADE,
file_b uuid NOT NULL REFERENCES data.files(id) ON UPDATE CASCADE ON DELETE CASCADE,
distance smallint NOT NULL,
CONSTRAINT chk__duplicate_pairs__order CHECK (file_a < file_b),
PRIMARY KEY (file_a, file_b)
);
-- "Not a duplicate" decisions: a global overlay that hides a candidate pair from
-- the duplicates view. Survives rescans (the pair may be re-found but stays
-- hidden). Same canonical file_a < file_b ordering as data.duplicate_pairs.
CREATE TABLE data.duplicate_dismissals (
file_a uuid NOT NULL REFERENCES data.files(id) ON UPDATE CASCADE ON DELETE CASCADE,
file_b uuid NOT NULL REFERENCES data.files(id) ON UPDATE CASCADE ON DELETE CASCADE,
dismissed_by smallint NOT NULL REFERENCES core.users(id) ON UPDATE CASCADE ON DELETE RESTRICT,
dismissed_at timestamptz NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT chk__duplicate_dismissals__order CHECK (file_a < file_b),
PRIMARY KEY (file_a, file_b)
);
COMMENT ON TABLE data.categories IS 'Logical grouping of tags';
COMMENT ON TABLE data.tags IS 'File labels/tags';
COMMENT ON TABLE data.tag_rules IS 'Auto-tagging rules: when when_tag is assigned, then_tag follows';
COMMENT ON TABLE data.files IS 'Managed files; actual content stored on disk as {id}.{ext}';
COMMENT ON TABLE data.file_tag IS 'Many-to-many: files <-> tags';
COMMENT ON TABLE data.pools IS 'Ordered collections of files';
COMMENT ON TABLE data.file_pool IS 'Many-to-many: files <-> pools, with ordering';
COMMENT ON TABLE data.duplicate_pairs IS 'Precomputed near-duplicate candidate pairs (perceptual-hash distance)';
COMMENT ON TABLE data.duplicate_dismissals IS 'Pairs marked "not a duplicate"; hidden from the duplicates view';
COMMENT ON COLUMN data.files.original_name IS 'Original filename at upload time';
COMMENT ON COLUMN data.files.content_datetime IS 'Content datetime (e.g. when photo was taken); falls back to EXIF DateTimeOriginal';
COMMENT ON COLUMN data.files.metadata IS 'User-editable key-value metadata';
COMMENT ON COLUMN data.files.exif IS 'EXIF data extracted at upload time (immutable, system-managed)';
COMMENT ON COLUMN data.files.phash IS 'Perceptual hash for image/video duplicate detection';
COMMENT ON COLUMN data.files.is_deleted IS 'Soft-deleted files (trash); true = in recycle bin';
COMMENT ON COLUMN data.file_pool.position IS 'Manual ordering within pool; uses gapped integers';
-- +goose Down
DROP TABLE IF EXISTS data.duplicate_dismissals;
DROP TABLE IF EXISTS data.duplicate_pairs;
DROP TABLE IF EXISTS data.file_pool;
DROP TABLE IF EXISTS data.pools;
DROP TABLE IF EXISTS data.file_tag;
DROP TABLE IF EXISTS data.files;
DROP TABLE IF EXISTS data.tag_rules;
DROP TABLE IF EXISTS data.tags;
DROP TABLE IF EXISTS data.categories;
+22
View File
@@ -0,0 +1,22 @@
-- +goose Up
-- If is_public=true on the object, it is accessible to everyone (ACL ignored).
-- If is_public=false, only creator and users with can_view=true see it.
-- Admins bypass all ACL checks.
CREATE TABLE acl.permissions (
user_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
object_type_id smallint NOT NULL REFERENCES core.object_types(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
object_id uuid NOT NULL,
can_view boolean NOT NULL DEFAULT true,
can_edit boolean NOT NULL DEFAULT false,
PRIMARY KEY (user_id, object_type_id, object_id)
);
COMMENT ON TABLE acl.permissions IS 'Per-object permissions (used when is_public=false)';
-- +goose Down
DROP TABLE IF EXISTS acl.permissions;
@@ -0,0 +1,82 @@
-- +goose Up
CREATE TABLE activity.action_types (
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(64) NOT NULL,
CONSTRAINT uni__action_types__name UNIQUE (name)
);
CREATE TABLE activity.sessions (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
token_hash text NOT NULL, -- hashed session token
user_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
user_agent varchar(256) NOT NULL,
started_at timestamptz NOT NULL DEFAULT statement_timestamp(),
expires_at timestamptz,
last_activity timestamptz NOT NULL DEFAULT statement_timestamp(),
is_active boolean NOT NULL DEFAULT true,
CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash)
);
CREATE TABLE activity.file_views (
file_id uuid NOT NULL REFERENCES data.files(id)
ON UPDATE CASCADE ON DELETE CASCADE,
user_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
PRIMARY KEY (file_id, viewed_at, user_id)
);
CREATE TABLE activity.pool_views (
pool_id uuid NOT NULL REFERENCES data.pools(id)
ON UPDATE CASCADE ON DELETE CASCADE,
user_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
PRIMARY KEY (pool_id, viewed_at, user_id)
);
CREATE TABLE activity.tag_uses (
tag_id uuid NOT NULL REFERENCES data.tags(id)
ON UPDATE CASCADE ON DELETE CASCADE,
user_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
used_at timestamptz NOT NULL DEFAULT statement_timestamp(),
is_included boolean NOT NULL, -- true=included in filter, false=excluded
PRIMARY KEY (tag_id, used_at, user_id)
);
CREATE TABLE activity.audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
action_type_id smallint NOT NULL REFERENCES activity.action_types(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
object_type_id smallint REFERENCES core.object_types(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
object_id uuid,
details jsonb, -- action-specific payload
performed_at timestamptz NOT NULL DEFAULT statement_timestamp()
);
COMMENT ON TABLE activity.action_types IS 'Reference: types of auditable user actions';
COMMENT ON TABLE activity.sessions IS 'Active user sessions';
COMMENT ON TABLE activity.file_views IS 'File view history';
COMMENT ON TABLE activity.pool_views IS 'Pool view history';
COMMENT ON TABLE activity.tag_uses IS 'Tag usage in filters';
COMMENT ON TABLE activity.audit_log IS 'Unified audit trail for all user actions';
-- +goose Down
DROP TABLE IF EXISTS activity.audit_log;
DROP TABLE IF EXISTS activity.tag_uses;
DROP TABLE IF EXISTS activity.pool_views;
DROP TABLE IF EXISTS activity.file_views;
DROP TABLE IF EXISTS activity.sessions;
DROP TABLE IF EXISTS activity.action_types;
+97
View File
@@ -0,0 +1,97 @@
-- +goose Up
-- core
CREATE INDEX idx__users__name ON core.users USING hash (name);
-- data.categories
CREATE INDEX idx__categories__creator_id ON data.categories USING hash (creator_id);
-- data.tags
CREATE INDEX idx__tags__category_id ON data.tags USING hash (category_id);
CREATE INDEX idx__tags__creator_id ON data.tags USING hash (creator_id);
-- data.tag_rules
CREATE INDEX idx__tag_rules__when ON data.tag_rules USING hash (when_tag_id);
CREATE INDEX idx__tag_rules__then ON data.tag_rules USING hash (then_tag_id);
-- data.files
CREATE INDEX idx__files__mime_id ON data.files USING hash (mime_id);
CREATE INDEX idx__files__creator_id ON data.files USING hash (creator_id);
CREATE INDEX idx__files__content_datetime ON data.files USING btree (content_datetime DESC NULLS LAST);
CREATE INDEX idx__files__is_deleted ON data.files USING btree (is_deleted) WHERE is_deleted = true;
CREATE INDEX idx__files__phash ON data.files USING btree (phash) WHERE phash IS NOT NULL;
CREATE INDEX idx__files__needs_review ON data.files USING btree (id) WHERE needs_review = true;
-- data.file_tag
CREATE INDEX idx__file_tag__tag_id ON data.file_tag USING hash (tag_id);
CREATE INDEX idx__file_tag__file_id ON data.file_tag USING hash (file_id);
-- data.duplicate_pairs / data.duplicate_dismissals
-- The composite primary keys cover lookups on file_a; these add the file_b side
-- (used by the ON DELETE CASCADE and by the visibility join on the second file).
CREATE INDEX idx__duplicate_pairs__file_b ON data.duplicate_pairs USING hash (file_b);
CREATE INDEX idx__duplicate_dismissals__file_b ON data.duplicate_dismissals USING hash (file_b);
-- data.pools
CREATE INDEX idx__pools__creator_id ON data.pools USING hash (creator_id);
-- data.file_pool
CREATE INDEX idx__file_pool__pool_id ON data.file_pool USING hash (pool_id);
CREATE INDEX idx__file_pool__file_id ON data.file_pool USING hash (file_id);
-- acl.permissions
CREATE INDEX idx__acl__object ON acl.permissions USING btree (object_type_id, object_id);
CREATE INDEX idx__acl__user ON acl.permissions USING hash (user_id);
-- activity.sessions
CREATE INDEX idx__sessions__user_id ON activity.sessions USING hash (user_id);
CREATE INDEX idx__sessions__token_hash ON activity.sessions USING hash (token_hash);
-- activity.file_views
CREATE INDEX idx__file_views__user_id ON activity.file_views USING hash (user_id);
-- activity.pool_views
CREATE INDEX idx__pool_views__user_id ON activity.pool_views USING hash (user_id);
-- activity.tag_uses
CREATE INDEX idx__tag_uses__user_id ON activity.tag_uses USING hash (user_id);
-- activity.audit_log
CREATE INDEX idx__audit_log__user_id ON activity.audit_log USING hash (user_id);
CREATE INDEX idx__audit_log__action_type_id ON activity.audit_log USING hash (action_type_id);
CREATE INDEX idx__audit_log__object ON activity.audit_log USING btree (object_type_id, object_id)
WHERE object_id IS NOT NULL;
CREATE INDEX idx__audit_log__performed_at ON activity.audit_log USING btree (performed_at DESC);
-- +goose Down
DROP INDEX IF EXISTS activity.idx__audit_log__performed_at;
DROP INDEX IF EXISTS activity.idx__audit_log__object;
DROP INDEX IF EXISTS activity.idx__audit_log__action_type_id;
DROP INDEX IF EXISTS activity.idx__audit_log__user_id;
DROP INDEX IF EXISTS activity.idx__tag_uses__user_id;
DROP INDEX IF EXISTS activity.idx__pool_views__user_id;
DROP INDEX IF EXISTS activity.idx__file_views__user_id;
DROP INDEX IF EXISTS activity.idx__sessions__token_hash;
DROP INDEX IF EXISTS activity.idx__sessions__user_id;
DROP INDEX IF EXISTS acl.idx__acl__user;
DROP INDEX IF EXISTS acl.idx__acl__object;
DROP INDEX IF EXISTS data.idx__duplicate_dismissals__file_b;
DROP INDEX IF EXISTS data.idx__duplicate_pairs__file_b;
DROP INDEX IF EXISTS data.idx__file_pool__file_id;
DROP INDEX IF EXISTS data.idx__file_pool__pool_id;
DROP INDEX IF EXISTS data.idx__pools__creator_id;
DROP INDEX IF EXISTS data.idx__file_tag__file_id;
DROP INDEX IF EXISTS data.idx__file_tag__tag_id;
DROP INDEX IF EXISTS data.idx__files__needs_review;
DROP INDEX IF EXISTS data.idx__files__phash;
DROP INDEX IF EXISTS data.idx__files__is_deleted;
DROP INDEX IF EXISTS data.idx__files__content_datetime;
DROP INDEX IF EXISTS data.idx__files__creator_id;
DROP INDEX IF EXISTS data.idx__files__mime_id;
DROP INDEX IF EXISTS data.idx__tag_rules__then;
DROP INDEX IF EXISTS data.idx__tag_rules__when;
DROP INDEX IF EXISTS data.idx__tags__creator_id;
DROP INDEX IF EXISTS data.idx__tags__category_id;
DROP INDEX IF EXISTS data.idx__categories__creator_id;
DROP INDEX IF EXISTS core.idx__users__name;
+50
View File
@@ -0,0 +1,50 @@
-- +goose Up
INSERT INTO core.mime_types (name, extension) VALUES
('image/jpeg', 'jpg'),
('image/png', 'png'),
('image/gif', 'gif'),
('image/webp', 'webp'),
('video/mp4', 'mp4'),
('video/quicktime', 'mov'),
('video/x-msvideo', 'avi'),
('video/webm', 'webm'),
('video/3gpp', '3gp'),
('video/x-m4v', 'm4v');
INSERT INTO core.object_types (name) VALUES
('file'), ('tag'), ('category'), ('pool');
INSERT INTO activity.action_types (name) VALUES
-- Auth
('user_login'), ('user_logout'),
-- Files
('file_create'), ('file_edit'), ('file_delete'), ('file_restore'),
('file_permanent_delete'), ('file_replace'), ('file_review'),
('file_merge'), ('duplicate_dismiss'),
-- Tags
('tag_create'), ('tag_edit'), ('tag_delete'),
-- Categories
('category_create'), ('category_edit'), ('category_delete'),
-- Pools
('pool_create'), ('pool_edit'), ('pool_delete'),
-- Relations
('file_tag_add'), ('file_tag_remove'),
('file_pool_add'), ('file_pool_remove'), ('file_pool_reorder'),
-- ACL
('acl_change'),
-- Admin
('user_create'), ('user_delete'), ('user_block'), ('user_unblock'),
('user_role_change'),
-- Sessions
('session_terminate');
-- The initial administrator is created at application startup from the
-- ADMIN_USERNAME / ADMIN_PASSWORD environment variables (see UserService.
-- EnsureAdmin), so no default credentials are seeded here.
-- +goose Down
DELETE FROM activity.action_types;
DELETE FROM core.object_types;
DELETE FROM core.mime_types;
+8
View File
@@ -0,0 +1,8 @@
package migrations
import "embed"
// FS holds all goose migration SQL files, embedded at build time.
//
//go:embed *.sql
var FS embed.FS
-75
View File
@@ -1,75 +0,0 @@
package models
import (
"time"
"github.com/jackc/pgx/v5/pgtype"
)
type User struct {
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
}
type MIME struct {
Name string `json:"name"`
Extension string `json:"extension"`
}
type Category struct {
ID string `json:"id"`
Name string `json:"name"`
Color pgtype.Text `json:"color"`
CreatedAt time.Time `json:"created_at"`
Creator User `json:"creator"`
}
type File struct {
ID string `json:"id"`
Name pgtype.Text `json:"name"`
MIME MIME `json:"mime"`
CreatedAt time.Time `json:"created_at"`
Creator User `json:"creator"`
}
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
Color pgtype.Text `json:"color"`
Category Category `json:"category"`
CreatedAt time.Time `json:"created_at"`
Creator User `json:"creator"`
}
type Autotag struct {
TriggerTag Tag `json:"trigger_tag"`
AddTag Tag `json:"add_tag"`
IsActive bool `json:"is_active"`
}
type Pool struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
Creator User `json:"creator"`
}
type Session struct {
ID int `json:"id"`
UserAgent string `json:"user_agent"`
StartedAt time.Time `json:"started_at"`
ExpiresAt time.Time `json:"expires_at"`
LastActivity time.Time `json:"last_activity"`
}
type Pagination struct {
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Count int `json:"count"`
}
type Slice[T any] struct {
Pagination Pagination `json:"pagination"`
Data []T `json:"data"`
}
-547
View File
@@ -1,547 +0,0 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 14.18 (Ubuntu 14.18-0ubuntu0.22.04.1)
-- Dumped by pg_dump version 17.4
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: acl; Type: SCHEMA; Schema: -; Owner: -
--
CREATE SCHEMA acl;
--
-- Name: activity; Type: SCHEMA; Schema: -; Owner: -
--
CREATE SCHEMA activity;
--
-- Name: data; Type: SCHEMA; Schema: -; Owner: -
--
CREATE SCHEMA data;
--
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
--
-- *not* creating schema, since initdb creates it
--
-- Name: system; Type: SCHEMA; Schema: -; Owner: -
--
CREATE SCHEMA system;
--
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
--
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: -
--
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
--
-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public;
--
-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: -
--
COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)';
--
-- Name: uuid_v7(timestamp with time zone); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION public.uuid_v7(cts timestamp with time zone DEFAULT clock_timestamp()) RETURNS uuid
LANGUAGE plpgsql
AS $$
DECLARE
state text = current_setting('uuidv7.old_tp',true);
old_tp text = split_part(state, ':',1);
base int = coalesce(nullif(split_part(state,':',4),'')::int,(random()*16777215/2-1)::int);
tp text;
entropy text;
seq text=base;
seqn int=split_part(state,':',2);
ver text = coalesce(split_part(state,':',3),to_hex(8+(random()*3)::int));
BEGIN
base = (random()*16777215/2-1)::int;
tp = lpad(to_hex(floor(extract(epoch from cts)*1000)::int8),12,'0')||'7';
if tp is distinct from old_tp then
old_tp = tp;
ver = to_hex(8+(random()*3)::int);
base = (random()*16777215/2-1)::int;
seqn = base;
else
seqn = seqn+(random()*1000)::int;
end if;
perform set_config('uuidv7.old_tp',old_tp||':'||seqn||':'||ver||':'||base, false);
entropy = md5(gen_random_uuid()::text);
seq = lpad(to_hex(seqn),6,'0');
return (tp || substring(seq from 1 for 3) || ver || substring(seq from 4 for 3) ||
substring(entropy from 1 for 12))::uuid;
END
$$;
SET default_table_access_method = heap;
--
-- Name: categories; Type: TABLE; Schema: acl; Owner: -
--
CREATE TABLE acl.categories (
user_id smallint NOT NULL,
category_id uuid NOT NULL,
view boolean NOT NULL,
edit boolean NOT NULL
);
--
-- Name: files; Type: TABLE; Schema: acl; Owner: -
--
CREATE TABLE acl.files (
user_id smallint NOT NULL,
file_id uuid NOT NULL,
view boolean NOT NULL,
edit boolean NOT NULL
);
--
-- Name: pools; Type: TABLE; Schema: acl; Owner: -
--
CREATE TABLE acl.pools (
user_id smallint NOT NULL,
pool_id uuid NOT NULL,
view boolean NOT NULL,
edit boolean NOT NULL
);
--
-- Name: tags; Type: TABLE; Schema: acl; Owner: -
--
CREATE TABLE acl.tags (
user_id smallint NOT NULL,
tag_id uuid NOT NULL,
view boolean NOT NULL,
edit boolean NOT NULL
);
--
-- Name: file_views; Type: TABLE; Schema: activity; Owner: -
--
CREATE TABLE activity.file_views (
file_id uuid NOT NULL,
"timestamp" timestamp with time zone NOT NULL,
user_id smallint NOT NULL
);
--
-- Name: pool_views; Type: TABLE; Schema: activity; Owner: -
--
CREATE TABLE activity.pool_views (
pool_id uuid NOT NULL,
"timestamp" timestamp with time zone NOT NULL,
user_id smallint NOT NULL
);
--
-- Name: sessions; Type: TABLE; Schema: activity; Owner: -
--
CREATE TABLE activity.sessions (
id integer NOT NULL,
token text NOT NULL,
user_id smallint NOT NULL,
user_agent character varying(256) NOT NULL,
started_at timestamp with time zone DEFAULT statement_timestamp() NOT NULL,
expires_at timestamp with time zone,
last_activity timestamp with time zone DEFAULT statement_timestamp() NOT NULL
);
--
-- Name: sessions_id_seq; Type: SEQUENCE; Schema: activity; Owner: -
--
CREATE SEQUENCE activity.sessions_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: sessions_id_seq; Type: SEQUENCE OWNED BY; Schema: activity; Owner: -
--
ALTER SEQUENCE activity.sessions_id_seq OWNED BY activity.sessions.id;
--
-- Name: tag_uses; Type: TABLE; Schema: activity; Owner: -
--
CREATE TABLE activity.tag_uses (
tag_id uuid NOT NULL,
"timestamp" timestamp with time zone NOT NULL,
user_id smallint NOT NULL,
included boolean NOT NULL
);
--
-- Name: autotags; Type: TABLE; Schema: data; Owner: -
--
CREATE TABLE data.autotags (
trigger_tag_id uuid NOT NULL,
add_tag_id uuid NOT NULL,
is_active boolean DEFAULT true NOT NULL
);
--
-- Name: categories; Type: TABLE; Schema: data; Owner: -
--
CREATE TABLE data.categories (
id uuid DEFAULT public.uuid_v7() NOT NULL,
name character varying(256) NOT NULL,
notes text,
color character(6),
creator_id smallint NOT NULL
);
--
-- Name: file_pool; Type: TABLE; Schema: data; Owner: -
--
CREATE TABLE data.file_pool (
file_id uuid NOT NULL,
pool_id uuid NOT NULL,
number smallint NOT NULL
);
--
-- Name: file_tag; Type: TABLE; Schema: data; Owner: -
--
CREATE TABLE data.file_tag (
file_id uuid NOT NULL,
tag_id uuid NOT NULL
);
--
-- Name: files; Type: TABLE; Schema: data; Owner: -
--
CREATE TABLE data.files (
id uuid DEFAULT public.uuid_v7() NOT NULL,
name character varying(256),
mime_id smallint NOT NULL,
datetime timestamp with time zone DEFAULT clock_timestamp() NOT NULL,
notes text,
metadata jsonb NOT NULL,
creator_id smallint NOT NULL
);
--
-- Name: pools; Type: TABLE; Schema: data; Owner: -
--
CREATE TABLE data.pools (
id uuid DEFAULT public.uuid_v7() NOT NULL,
name character varying(256) NOT NULL,
notes text,
creator_id smallint NOT NULL
);
--
-- Name: tags; Type: TABLE; Schema: data; Owner: -
--
CREATE TABLE data.tags (
id uuid DEFAULT public.uuid_v7() NOT NULL,
name character varying(256) NOT NULL,
notes text,
color character(6),
category_id uuid,
creator_id smallint NOT NULL
);
--
-- Name: mime; Type: TABLE; Schema: system; Owner: -
--
CREATE TABLE system.mime (
id smallint NOT NULL,
name character varying(127) NOT NULL,
extension character varying(16) NOT NULL
);
--
-- Name: mime_id_seq; Type: SEQUENCE; Schema: system; Owner: -
--
CREATE SEQUENCE system.mime_id_seq
AS smallint
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: mime_id_seq; Type: SEQUENCE OWNED BY; Schema: system; Owner: -
--
ALTER SEQUENCE system.mime_id_seq OWNED BY system.mime.id;
--
-- Name: users; Type: TABLE; Schema: system; Owner: -
--
CREATE TABLE system.users (
id smallint NOT NULL,
name character varying(32) NOT NULL,
password text NOT NULL,
is_admin boolean DEFAULT false NOT NULL
);
--
-- Name: users_id_seq; Type: SEQUENCE; Schema: system; Owner: -
--
CREATE SEQUENCE system.users_id_seq
AS smallint
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: system; Owner: -
--
ALTER SEQUENCE system.users_id_seq OWNED BY system.users.id;
--
-- Name: sessions id; Type: DEFAULT; Schema: activity; Owner: -
--
ALTER TABLE ONLY activity.sessions ALTER COLUMN id SET DEFAULT nextval('activity.sessions_id_seq'::regclass);
--
-- Name: mime id; Type: DEFAULT; Schema: system; Owner: -
--
ALTER TABLE ONLY system.mime ALTER COLUMN id SET DEFAULT nextval('system.mime_id_seq'::regclass);
--
-- Name: users id; Type: DEFAULT; Schema: system; Owner: -
--
ALTER TABLE ONLY system.users ALTER COLUMN id SET DEFAULT nextval('system.users_id_seq'::regclass);
--
-- Name: categories categories_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
--
ALTER TABLE ONLY acl.categories
ADD CONSTRAINT categories_pkey PRIMARY KEY (user_id, category_id);
--
-- Name: files files_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
--
ALTER TABLE ONLY acl.files
ADD CONSTRAINT files_pkey PRIMARY KEY (user_id, file_id);
--
-- Name: pools pools_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
--
ALTER TABLE ONLY acl.pools
ADD CONSTRAINT pools_pkey PRIMARY KEY (user_id, pool_id);
--
-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
--
ALTER TABLE ONLY acl.tags
ADD CONSTRAINT tags_pkey PRIMARY KEY (user_id, tag_id);
--
-- Name: file_views file_views_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
--
ALTER TABLE ONLY activity.file_views
ADD CONSTRAINT file_views_pkey PRIMARY KEY (file_id, "timestamp", user_id);
--
-- Name: pool_views pool_views_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
--
ALTER TABLE ONLY activity.pool_views
ADD CONSTRAINT pool_views_pkey PRIMARY KEY (pool_id, "timestamp", user_id);
--
-- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
--
ALTER TABLE ONLY activity.sessions
ADD CONSTRAINT sessions_pkey PRIMARY KEY (id);
--
-- Name: tag_uses tag_uses_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
--
ALTER TABLE ONLY activity.tag_uses
ADD CONSTRAINT tag_uses_pkey PRIMARY KEY (tag_id, "timestamp", user_id);
--
-- Name: autotags autotags_pkey; Type: CONSTRAINT; Schema: data; Owner: -
--
ALTER TABLE ONLY data.autotags
ADD CONSTRAINT autotags_pkey PRIMARY KEY (trigger_tag_id, add_tag_id);
--
-- Name: categories categories_pkey; Type: CONSTRAINT; Schema: data; Owner: -
--
ALTER TABLE ONLY data.categories
ADD CONSTRAINT categories_pkey PRIMARY KEY (id);
--
-- Name: file_pool file_pool_pkey; Type: CONSTRAINT; Schema: data; Owner: -
--
ALTER TABLE ONLY data.file_pool
ADD CONSTRAINT file_pool_pkey PRIMARY KEY (file_id, pool_id, number);
--
-- Name: file_tag file_tag_pkey; Type: CONSTRAINT; Schema: data; Owner: -
--
ALTER TABLE ONLY data.file_tag
ADD CONSTRAINT file_tag_pkey PRIMARY KEY (file_id, tag_id);
--
-- Name: files files_pkey; Type: CONSTRAINT; Schema: data; Owner: -
--
ALTER TABLE ONLY data.files
ADD CONSTRAINT files_pkey PRIMARY KEY (id);
--
-- Name: pools pools_pkey; Type: CONSTRAINT; Schema: data; Owner: -
--
ALTER TABLE ONLY data.pools
ADD CONSTRAINT pools_pkey PRIMARY KEY (id);
--
-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: data; Owner: -
--
ALTER TABLE ONLY data.tags
ADD CONSTRAINT tags_pkey PRIMARY KEY (id);
--
-- Name: mime mime_pkey; Type: CONSTRAINT; Schema: system; Owner: -
--
ALTER TABLE ONLY system.mime
ADD CONSTRAINT mime_pkey PRIMARY KEY (id);
--
-- Name: users users_pkey; Type: CONSTRAINT; Schema: system; Owner: -
--
ALTER TABLE ONLY system.users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
--
-- PostgreSQL database dump complete
--
+170
View File
@@ -0,0 +1,170 @@
# =============================================================================
# Tanabata File Manager — Docker Compose
#
# Quick start:
# cp .env.example .env # then edit the secrets
# docker compose up -d --build
#
# Database — two supported modes, selected in .env:
#
# 1. Bundled Postgres container (default).
# COMPOSE_PROFILES=with-db
# DATABASE_URL=postgres://tanabata:password@db:5432/tanabata?sslmode=disable
#
# 2. Postgres already running on the host.
# COMPOSE_PROFILES= # empty → the db container is not started
# DATABASE_URL=postgres://tanabata:password@host.docker.internal:5432/tanabata?sslmode=disable
#
# Requires Docker Compose v2.20+ (for depends_on.required).
# =============================================================================
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: tfm
restart: unless-stopped
# All application config (secrets, DATABASE_URL, tunables) comes from .env.
env_file: .env
# Pin STATIC_DIR to the path baked into the image. .env intentionally leaves
# it unset; pinning here guarantees in-container SPA serving can't be
# disabled by an empty value leaking in through env_file.
environment:
STATIC_DIR: /app/static
# Published on loopback only: a reverse proxy on the host (e.g. nginx) fronts
# the app and proxies to 127.0.0.1:${APP_PORT}. Binding to 127.0.0.1 keeps the
# app off the LAN/WAN — a plain "PORT:42776" would publish on 0.0.0.0 and, since
# Docker's DNAT rules sit ahead of the host firewall, bypass ufw/firewalld. The
# container always listens on 42776 (Dockerfile default); APP_PORT only changes
# the host-published port. Drop the 127.0.0.1 prefix if exposing it directly.
ports:
- "127.0.0.1:${APP_PORT:-42776}:42776"
# Two-tier networking. `web` is the app's public-facing bridge (reached via the
# published loopback port above; it also provides egress, e.g. to a host
# Postgres via host.docker.internal). `backend` is the private tier the app
# uses to reach the bundled DB. The DB sits only on `backend`, so nothing on
# the host-facing side can reach it.
networks:
- web
- backend
# Wait for the bundled DB when the with-db profile is active. When using a
# host Postgres the db service is disabled, and required:false keeps this
# dependency from erroring or auto-starting it.
depends_on:
db:
condition: service_healthy
required: false
# Lets DATABASE_URL reach a Postgres on the host via host.docker.internal
# (needed on Linux; harmless elsewhere).
extra_hosts:
- "host.docker.internal:host-gateway"
# Run as this uid:gid. Relevant when the mounts below are bind-mounted to
# host folders: set PUID/PGID (in .env) to the owner of those folders so the
# container can write to them. Defaults to the image's tanabata user
# (42776), which owns the named volumes.
user: "${PUID:-42776}:${PGID:-42776}"
# Storage for originals, the thumbnail cache, and the import drop folder.
# Each source defaults to a named volume but can be pointed at a specific
# host folder via FILES_DIR / THUMBS_DIR / IMPORT_DIR in .env (a path turns
# the mount into a host bind mount; a bare name stays a named volume).
volumes:
- "${FILES_DIR:-app_files}:/data/files"
- "${THUMBS_DIR:-app_thumbs}:/data/thumbs"
- "${IMPORT_DIR:-app_import}:/data/import"
db:
image: postgres:14-alpine
restart: unless-stopped
# Only started when COMPOSE_PROFILES includes "with-db". Disable it to point
# the app at a Postgres running on the host instead.
profiles: ["with-db"]
# Private back-end tier only — never on `web`, never published.
networks:
- backend
environment:
POSTGRES_DB: ${POSTGRES_DB:-tanabata}
POSTGRES_USER: ${POSTGRES_USER:-tanabata}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
# Defaults to a named volume; set DB_DIR in .env to a host folder to bind
# mount it instead. Postgres fixes the folder's ownership itself, so DB_DIR
# needs no PUID/PGID.
volumes:
- "${DB_DIR:-db_data}:/var/lib/postgresql/data"
# Uncomment to reach the DB from the host (e.g. with psql) for debugging.
# ports:
# - "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tanabata} -d ${POSTGRES_DB:-tanabata}"]
interval: 5s
timeout: 5s
retries: 10
# One-shot maintenance task for duplicate detection: computes missing
# perceptual hashes (images + video) and rebuilds the duplicate-pairs table.
# It is NOT a daemon — the "tools" profile keeps it out of `docker compose up`;
# run it on demand, and it exits when done:
#
# docker compose run --rm dedup # hashes, then rebuild pairs
# docker compose run --rm dedup -pairs # only rebuild pairs (after uploads)
# docker compose run --rm dedup -hashes # only backfill hashes
#
# Reuses the app image, .env, volumes and networks; only the entrypoint differs
# (/app/dedup instead of the server). Connects to the same DB the app uses, so
# the app's DB (bundled or host) must be reachable when it runs.
dedup:
build:
context: .
dockerfile: Dockerfile
profiles: ["tools"]
env_file: .env
networks:
- web
- backend
depends_on:
db:
condition: service_healthy
required: false
extra_hosts:
- "host.docker.internal:host-gateway"
user: "${PUID:-42776}:${PGID:-42776}"
volumes:
- "${FILES_DIR:-app_files}:/data/files"
- "${THUMBS_DIR:-app_thumbs}:/data/thumbs"
entrypoint: ["/app/dedup"]
restart: "no"
networks:
# Public-facing bridge for this app. The explicit bridge name (instead of
# Docker's random br-<hash>) makes it identifiable on the host for tcpdump and
# firewall rules.
web:
driver_opts:
com.docker.network.bridge.name: dk-tanabata
# Private back-end tier (app ↔ DB). internal:true drops the gateway so the DB
# has no route off-host. Note: Linux caps interface names at 15 chars, and
# dk-tanabata-bnd is exactly 15 — a longer app name would need a shorter suffix.
backend:
internal: true
driver_opts:
com.docker.network.bridge.name: dk-tanabata-bnd
volumes:
app_files:
app_thumbs:
app_import:
db_data:
+182
View File
@@ -0,0 +1,182 @@
# Deployment (Gitea Actions → host)
Tanabata is deployed by a [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
workflow ([`.gitea/workflows/deploy.yml`](../.gitea/workflows/deploy.yml)) that
runs on the **production host itself**. On every push to `master` it updates the
git clone in `/opt/tanabata` and runs `docker compose up -d --build` there, so the
image is built from the freshly-pushed code and the stack is restarted.
```
push master ──> Gitea (container) ──> act_runner (host, "host" label)
│ git fetch + reset --hard (in /opt/tanabata)
└ docker compose up -d --build
```
The Gitea server runs in a container, but the **runner runs directly on the host**
(shell executor) so it can use the host's git, the host Docker daemon, and the
clone in `/opt/tanabata`. Nothing needs a registry — the host builds the image
locally. The workflow uses only shell steps, so the host needs just **git** and
**docker** (no node, no rsync).
## What is a runner?
Gitea (like GitHub) only *coordinates* CI: it stores the workflow, queues jobs,
and shows logs. It does **not** execute anything itself. A **runner** is a
separate agent program that polls Gitea for queued jobs, runs the steps on a
machine you control, and reports results back.
Gitea's official runner is **act_runner** (a single Go binary; it uses the
`act` engine to interpret workflow YAML). One act_runner process can serve many
repos. Each runner advertises one or more **labels**, and a job's `runs-on:`
picks a runner by label. A label also decides *how* a job runs — the **executor**:
- **docker executor** — each job runs in a fresh container from an image (e.g.
`node:20-bookworm`). Isolated and reproducible; the usual default. Label form
at registration: `ubuntu:docker://node:20-bookworm`.
- **host / shell executor** — the job runs directly on the host as the runner's
user, using host-installed tools. Label form: `host:host`. This is what we use,
because the deploy needs the host's Docker daemon and `/opt/tanabata`.
So `runs-on: host` in the workflow ⇒ "run this job on a runner that registered a
`host` label" ⇒ our shell executor on the prod box.
## One-time setup
### 1. Enable Actions in Gitea
Gitea 1.21+ has Actions on by default. Otherwise add to `app.ini` and restart:
```ini
[actions]
ENABLED = true
```
### 2. A runner user on the host
Pick (or create) the Linux user the runner runs as. It must be able to use Docker
and own the deploy dir — so the workflow needs no `sudo`:
```bash
sudo useradd -r -m -d /home/gitea-runner gitea-runner # or reuse an existing user
sudo usermod -aG docker gitea-runner # host Docker access
```
The host needs `git` and a Docker engine with the Compose plugin:
```bash
sudo apt install -y git docker.io docker-compose-plugin # Debian/Ubuntu
```
### 3. Clone the repo to /opt/tanabata once
The workflow only does `git fetch` + `reset --hard`, so the clone (and its auth)
is established here, once. Use a **read-only deploy key** so the host never holds
write credentials:
```bash
# As the runner user, create a key and add the PUBLIC half to the repo in Gitea:
# Repo → Settings → Deploy Keys → Add (read-only)
sudo -u gitea-runner ssh-keygen -t ed25519 -f /home/gitea-runner/.ssh/tanabata_deploy -N ''
# Clone with that key (SSH URL of your Gitea repo):
sudo -u gitea-runner GIT_SSH_COMMAND='ssh -i /home/gitea-runner/.ssh/tanabata_deploy' \
git clone git@gitea.example.com:you/tanabata.git /opt/tanabata
sudo chown -R gitea-runner:gitea-runner /opt/tanabata
```
> HTTPS works too — clone with a URL that carries a read-only token. SSH deploy
> keys are the cleaner, per-repo, read-only option.
After cloning, recurring `git fetch` reuses the remote + key stored in
`/opt/tanabata/.git/config`, so the runner itself needs no standing credentials.
### 4. Register and run act_runner on the host
Get a registration token in Gitea. **Where you create it sets the runner's
scope** (and `--name` is only a display label, unrelated to scope):
- **Repository** (Tanabata repo → Settings → Actions → Runners) → serves only
this repo. **Use this.**
- Organization → all repos in the org; Site (admin) → all repos on the instance.
> Security: this runner is a host/shell executor with access to the Docker
> socket — effectively root on the host. Register it at the **repository** level
> so only Tanabata's workflows can run on your prod server; a site-wide runner
> would let any repo's workflow execute arbitrary commands here.
Then, as the runner user:
```bash
# Download act_runner: https://gitea.com/gitea/act_runner/releases
act_runner register --no-interactive \
--instance https://gitea.example.com \
--token <REGISTRATION_TOKEN> \
--name prod-host \
--labels host:host # <-- maps `runs-on: host` to the shell executor
# Run it (use a systemd unit in production so it survives reboots):
act_runner daemon
```
`--labels host:host` is what makes jobs run **on the host** instead of in a
container. The instance URL must be reachable from the host (Gitea's published
port / domain — not the in-container address). Registration writes a `.runner`
file (the runner's credentials) in the working directory.
Minimal systemd unit (`/etc/systemd/system/act_runner.service`):
```ini
[Unit]
Description=Gitea act_runner
After=docker.service
Requires=docker.service
[Service]
User=gitea-runner
WorkingDirectory=/home/gitea-runner
ExecStart=/usr/local/bin/act_runner daemon
Restart=always
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now act_runner
```
### 5. Create /opt/tanabata/.env (secrets)
The workflow **never** writes `.env` — it lives on the host and holds the real
secrets and the chosen DB mode. `.env` is git-ignored, so `git reset --hard`
leaves it untouched. Create it once:
```bash
cd /opt/tanabata
sudo -u gitea-runner cp .env.example .env
sudo -u gitea-runner $EDITOR .env # set JWT_SECRET, ADMIN_PASSWORD, DATABASE_URL, etc.
```
See [`.env.example`](../.env.example) for every variable. For the bundled
Postgres keep `COMPOSE_PROFILES=with-db`; to use a Postgres already on the host,
set it empty and point `DATABASE_URL` at `host.docker.internal`.
> Data lives in named Docker volumes by default (or the `*_DIR` host paths you
> set in `.env`, e.g. `/var/lib/tanabata/...`) — **not** in `/opt/tanabata`. So
> `git reset --hard` on the code dir never touches your data.
## Deploying
Push to `master` (or hit **Run workflow** on the Actions tab). Watch progress
under the repo's **Actions** tab. The first build pulls the Node/Go base images
and takes a few minutes; later builds reuse the host's layer cache.
## Notes / alternatives
- **Docker-executor runner instead of host.** If you'd rather the runner itself
run in a container, register with a Docker label and bind-mount
`/var/run/docker.sock` and `/opt/tanabata` into the job (act_runner
`config.yaml``container.valid_volumes`), then change `runs-on` accordingly.
The host executor above is simpler for host deploys.
- **Zero-downtime** isn't attempted: `compose up` recreates changed containers.
For a single-node setup the brief restart is usually fine.
+422
View File
@@ -0,0 +1,422 @@
# Tanabata File Manager — Frontend Structure
## Stack
- **Framework**: SvelteKit (SPA mode, `ssr: false`)
- **Language**: TypeScript
- **CSS**: Tailwind CSS + CSS custom properties (hybrid)
- **API types**: Auto-generated via openapi-typescript
- **PWA**: Service worker + web manifest
- **Font**: Epilogue (variable weight)
- **Package manager**: npm
## SPA mode — why SvelteKit without the server
This frontend runs as a **pure client-side SPA**: `adapter-static` with
`fallback: 'index.html'` and `ssr = false` globally (see
`src/routes/+layout.ts`). There is no Node server in production — the build is
static assets, and the only backend is the Go API. SvelteKit is used here
purely as an SPA framework: file-based routing, the client router, and build
tooling.
**SvelteKit features we *do* use:**
- File-based routing with nested layouts (`admin/` has its own guard) and
dynamic segments (`[id]`).
- The client router: `goto`, the `page` store/state, `afterNavigate`,
`navigating`.
- **Shallow routing** — `pushState`/`replaceState` + `page.state`. The
Immich-style file viewer in `files/` and `pools/[id]/` opens as an overlay
over the still-mounted list via shallow routing, so the browser back button
dismisses it without reloading the grid. This is the single biggest reason we
stay on SvelteKit rather than a plain router.
- `load` functions, used *only* as client-side route guards (auth redirect,
admin redirect, `/``/files`).
- `$lib` alias, generated `./$types`, Vite/HMR integration.
**SvelteKit features we deliberately do *not* use** (the "server half"):
- SSR / hydration.
- `+page.server.ts`, `+server.ts` endpoints, form actions — all data goes
through the Go API via the `$lib/api` client.
- `hooks.*`, prerendering, server-only modules — no `hooks.server.ts` /
`hooks.client.ts` files exist.
**Decision: stay on SvelteKit, do not migrate to a bare Svelte + router SPA.**
The project already *is* an SPA, so there is no runtime gain from switching
(adapter-static tree-shakes the unused server bits; the client-runtime size
difference is negligible). A migration would mean re-implementing nested
layouts, guards, dynamic params, and — most painfully — shallow routing /
history-state overlays by hand, for zero benefit. New contributors should not
expect SSR, endpoints, or hooks to do anything here; that is intentional.
## Monorepo Layout
```
tanabata/
├── backend/ ← Go project (go.mod in here)
│ ├── cmd/
│ ├── internal/
│ ├── migrations/
│ ├── go.mod
│ └── go.sum
├── frontend/ ← SvelteKit project (package.json in here)
│ └── (see below)
├── openapi.yaml ← Shared API contract (root level)
├── docker-compose.yml
├── Dockerfile
├── .env.example
└── README.md
```
`openapi.yaml` lives at repository root — both backend and frontend
reference it. The frontend generates types from it; the backend
validates its handlers against it.
## Frontend Directory Layout
```
frontend/
├── package.json
├── svelte.config.js
├── vite.config.ts
├── tsconfig.json
├── tailwind.config.ts
├── postcss.config.js
├── src/
│ ├── app.html # Shell HTML (PWA meta, font preload)
│ ├── app.css # Tailwind directives + CSS custom properties
│ │ # (no hooks.* — see "SPA mode" above)
│ │
│ ├── lib/ # Shared code ($lib/ alias)
│ │ │
│ │ ├── api/ # API client layer
│ │ │ ├── client.ts # Base fetch wrapper: auth headers, token refresh,
│ │ │ │ # error parsing, base URL
│ │ │ ├── files.ts # listFiles, getFile, uploadFile, deleteFile, etc.
│ │ │ ├── tags.ts # listTags, createTag, getTag, updateTag, etc.
│ │ │ ├── categories.ts # Category API functions
│ │ │ ├── pools.ts # Pool API functions
│ │ │ ├── auth.ts # login, logout, refresh, listSessions
│ │ │ ├── acl.ts # getPermissions, setPermissions
│ │ │ ├── users.ts # getMe, updateMe, admin user CRUD
│ │ │ ├── audit.ts # queryAuditLog
│ │ │ ├── schema.ts # AUTO-GENERATED from openapi.yaml (do not edit)
│ │ │ └── types.ts # Friendly type aliases:
│ │ │ # export type File = components["schemas"]["File"]
│ │ │ # export type Tag = components["schemas"]["Tag"]
│ │ │
│ │ ├── components/ # Reusable UI components
│ │ │ │
│ │ │ ├── layout/ # App shell
│ │ │ │ ├── Navbar.svelte # Bottom navigation bar (mobile-first)
│ │ │ │ ├── Header.svelte # Section header with sorting controls
│ │ │ │ ├── SelectionBar.svelte # Floating bar for multi-select actions
│ │ │ │ └── Loader.svelte # Full-screen loading overlay
│ │ │ │
│ │ │ ├── file/ # File-related components
│ │ │ │ ├── FileGrid.svelte # Thumbnail grid with infinite scroll
│ │ │ │ ├── FileCard.svelte # Single thumbnail (160×160, selectable)
│ │ │ │ ├── FileViewer.svelte # Full-screen preview with prev/next navigation
│ │ │ │ ├── FileUpload.svelte # Upload form + drag-and-drop zone
│ │ │ │ ├── FileDetail.svelte # Metadata editor (notes, datetime, tags)
│ │ │ │ └── FilterBar.svelte # DSL filter builder UI
│ │ │ │
│ │ │ ├── tag/ # Tag-related components
│ │ │ │ ├── TagBadge.svelte # Colored pill with tag name
│ │ │ │ ├── TagPicker.svelte # Searchable tag selector (add/remove)
│ │ │ │ ├── TagList.svelte # Tag grid for section view
│ │ │ │ └── TagRuleEditor.svelte # Auto-tag rule management
│ │ │ │
│ │ │ ├── pool/ # Pool-related components
│ │ │ │ ├── PoolCard.svelte # Pool preview card
│ │ │ │ ├── PoolFileList.svelte # Ordered file list with drag reorder
│ │ │ │ └── PoolDetail.svelte # Pool metadata editor
│ │ │ │
│ │ │ ├── acl/ # Access control components
│ │ │ │ └── PermissionEditor.svelte # User permission grid
│ │ │ │
│ │ │ └── common/ # Shared primitives
│ │ │ ├── Button.svelte
│ │ │ ├── Modal.svelte
│ │ │ ├── ConfirmDialog.svelte
│ │ │ ├── Toast.svelte
│ │ │ ├── InfiniteScroll.svelte
│ │ │ ├── Pagination.svelte
│ │ │ ├── SortDropdown.svelte
│ │ │ ├── SearchInput.svelte
│ │ │ ├── ColorPicker.svelte
│ │ │ ├── Checkbox.svelte # Three-state: checked, unchecked, partial
│ │ │ └── EmptyState.svelte
│ │ │
│ │ ├── stores/ # Svelte stores (global state)
│ │ │ ├── auth.ts # Current user, JWT tokens, isAuthenticated
│ │ │ ├── selection.ts # Selected item IDs, selection mode toggle
│ │ │ ├── sorting.ts # Per-section sort key + order (persisted to localStorage)
│ │ │ ├── theme.ts # Dark/light mode (persisted, respects prefers-color-scheme)
│ │ │ └── toast.ts # Notification queue (success, error, info)
│ │ │
│ │ └── utils/ # Pure helper functions
│ │ ├── format.ts # formatDate, formatFileSize, formatDuration
│ │ ├── dsl.ts # Filter DSL builder: UI state → query string
│ │ ├── pwa.ts # PWA reset, cache clear, update prompt
│ │ └── keyboard.ts # Keyboard shortcut helpers (Ctrl+A, Escape, etc.)
│ │
│ ├── routes/ # SvelteKit file-based routing
│ │ │
│ │ ├── +layout.svelte # Root layout: Navbar, theme wrapper, toast container
│ │ ├── +layout.ts # Root load: auth guard → redirect to /login if no token
│ │ │
│ │ ├── +page.svelte # / → redirect to /files
│ │ │
│ │ ├── login/
│ │ │ └── +page.svelte # Login form (decorative Tanabata images)
│ │ │
│ │ ├── files/
│ │ │ ├── +page.svelte # File grid: filter bar, sort, multi-select, upload
│ │ │ ├── +page.ts # Load: initial file list (cursor page)
│ │ │ ├── [id]/
│ │ │ │ ├── +page.svelte # File view: preview, metadata, tags, ACL
│ │ │ │ └── +page.ts # Load: file detail + tags
│ │ │ └── trash/
│ │ │ ├── +page.svelte # Trash: restore / permanent delete
│ │ │ └── +page.ts
│ │ │
│ │ ├── tags/
│ │ │ ├── +page.svelte # Tag list: search, sort, multi-select
│ │ │ ├── +page.ts
│ │ │ ├── new/
│ │ │ │ └── +page.svelte # Create tag form
│ │ │ └── [id]/
│ │ │ ├── +page.svelte # Tag detail: edit, category, rules, parent tags
│ │ │ └── +page.ts
│ │ │
│ │ ├── categories/
│ │ │ ├── +page.svelte # Category list
│ │ │ ├── +page.ts
│ │ │ ├── new/
│ │ │ │ └── +page.svelte
│ │ │ └── [id]/
│ │ │ ├── +page.svelte # Category detail: edit, view tags
│ │ │ └── +page.ts
│ │ │
│ │ ├── pools/
│ │ │ ├── +page.svelte # Pool list
│ │ │ ├── +page.ts
│ │ │ ├── new/
│ │ │ │ └── +page.svelte
│ │ │ └── [id]/
│ │ │ ├── +page.svelte # Pool detail: files (reorderable), filter, edit
│ │ │ └── +page.ts
│ │ │
│ │ ├── settings/
│ │ │ ├── +page.svelte # Profile: name, password, active sessions
│ │ │ └── +page.ts
│ │ │
│ │ └── admin/
│ │ ├── +layout.svelte # Admin layout: restrict to is_admin
│ │ ├── users/
│ │ │ ├── +page.svelte # User management list
│ │ │ ├── +page.ts
│ │ │ └── [id]/
│ │ │ ├── +page.svelte # User detail: role, block/unblock
│ │ │ └── +page.ts
│ │ └── audit/
│ │ ├── +page.svelte # Audit log with filters
│ │ └── +page.ts
│ │
│ └── service-worker.ts # PWA: offline cache for pinned files, app shell caching
└── static/
├── favicon.png
├── favicon.ico
├── manifest.webmanifest # PWA manifest (name, icons, theme_color)
├── images/
│ ├── tanabata-left.png # Login page decorations (from current design)
│ ├── tanabata-right.png
│ └── icons/ # PWA icons (192×192, 512×512, etc.)
└── fonts/
└── Epilogue-VariableFont_wght.ttf
```
## Key Architecture Decisions
### CSS Hybrid: Tailwind + Custom Properties
Theme colors defined as CSS custom properties in `app.css`:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-bg-primary: #312F45;
--color-bg-secondary: #181721;
--color-bg-elevated: #111118;
--color-accent: #9592B5;
--color-accent-hover: #7D7AA4;
--color-text-primary: #f0f0f0;
--color-text-muted: #9999AD;
--color-danger: #DB6060;
--color-info: #4DC7ED;
--color-warning: #F5E872;
--color-tag-default: #444455;
}
:root[data-theme="light"] {
--color-bg-primary: #f5f5f5;
--color-bg-secondary: #ffffff;
/* ... */
}
```
Tailwind references them in `tailwind.config.ts`:
```ts
export default {
theme: {
extend: {
colors: {
bg: {
primary: 'var(--color-bg-primary)',
secondary: 'var(--color-bg-secondary)',
elevated: 'var(--color-bg-elevated)',
},
accent: {
DEFAULT: 'var(--color-accent)',
hover: 'var(--color-accent-hover)',
},
// ...
},
fontFamily: {
sans: ['Epilogue', 'sans-serif'],
},
},
},
darkMode: 'class', // controlled via data-theme attribute
};
```
Usage in components: `<div class="bg-bg-primary text-text-primary rounded-xl p-4">`.
Complex cases use scoped `<style>` inside `.svelte` files.
### API Client Pattern
`client.ts` — thin wrapper around fetch:
```ts
// $lib/api/client.ts
import { authStore } from '$lib/stores/auth';
const BASE = '/api/v1';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = get(authStore).accessToken;
const res = await fetch(BASE + path, {
...init,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...init?.headers,
},
});
if (res.status === 401) {
// attempt refresh, retry once
}
if (!res.ok) {
const err = await res.json();
throw new ApiError(res.status, err.code, err.message, err.details);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
upload: <T>(path: string, formData: FormData) =>
request<T>(path, { method: 'POST', body: formData, headers: {} }),
};
```
Domain-specific modules use it:
```ts
// $lib/api/files.ts
import { api } from './client';
import type { File, FileCursorPage } from './types';
export function listFiles(params: Record<string, string>) {
const qs = new URLSearchParams(params).toString();
return api.get<FileCursorPage>(`/files?${qs}`);
}
export function uploadFile(formData: FormData) {
return api.upload<File>('/files', formData);
}
```
### Type Generation
Script in `package.json`:
```json
{
"scripts": {
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
"dev": "npm run generate:types && vite dev",
"build": "npm run generate:types && vite build"
}
}
```
Friendly aliases in `types.ts`:
```ts
import type { components } from './schema';
export type File = components['schemas']['File'];
export type Tag = components['schemas']['Tag'];
export type Category = components['schemas']['Category'];
export type Pool = components['schemas']['Pool'];
export type FileCursorPage = components['schemas']['FileCursorPage'];
export type TagOffsetPage = components['schemas']['TagOffsetPage'];
export type Error = components['schemas']['Error'];
// ...
```
### SPA Mode
`svelte.config.js`:
```js
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({ fallback: 'index.html' }),
// SPA: all routes handled client-side
},
};
```
The Go backend serves `index.html` for all non-API routes (SPA fallback).
In development, Vite dev server proxies `/api` to the Go backend.
### PWA
`service-worker.ts` handles:
- App shell caching (HTML, CSS, JS, fonts)
- User-pinned file caching (explicit, via UI button)
- Cache versioning and cleanup on update
- Reset function (clear all caches except pinned files)
+320
View File
@@ -0,0 +1,320 @@
# Tanabata File Manager — Go Project Structure
## Stack
- **Router**: Gin
- **Database**: pgx v5 (pgxpool)
- **Migrations**: goose v3 + go:embed (auto-migrate on startup)
- **Auth**: JWT (golang-jwt/jwt/v5)
- **Config**: environment variables via .env (joho/godotenv)
- **Logging**: slog (stdlib, Go 1.21+)
- **Validation**: go-playground/validator/v10
- **EXIF**: rwcarlsen/goexif or dsoprea/go-exif
- **Image processing**: disintegration/imaging (thumbnails, previews)
- **Architecture**: Clean Architecture (domain → service → repository/handler)
## Monorepo Layout
```
tanabata/
├── backend/ ← Go project
├── frontend/ ← SvelteKit project
├── openapi.yaml ← Shared API contract
├── docker-compose.yml
├── Dockerfile
├── .env.example
└── README.md
```
## Backend Directory Layout
```
backend/
├── cmd/
│ └── server/
│ └── main.go # Entrypoint: config → DB → migrate → wire → run
├── internal/
│ │
│ ├── domain/ # Pure business entities & value objects
│ │ ├── file.go # File, FileFilter, FilePage
│ │ ├── tag.go # Tag, TagRule
│ │ ├── category.go # Category
│ │ ├── pool.go # Pool, PoolFile
│ │ ├── user.go # User, Session
│ │ ├── acl.go # Permission, ObjectType
│ │ ├── audit.go # AuditEntry, ActionType
│ │ └── errors.go # Domain error types (ErrNotFound, ErrForbidden, etc.)
│ │
│ ├── port/ # Interfaces (ports) — contracts between layers
│ │ ├── repository.go # FileRepo, TagRepo, CategoryRepo, PoolRepo,
│ │ │ # UserRepo, SessionRepo, ACLRepo, AuditRepo,
│ │ │ # MimeRepo, TagRuleRepo
│ │ └── storage.go # FileStorage interface (disk operations)
│ │
│ ├── service/ # Business logic (use cases)
│ │ ├── file_service.go # Upload, update, delete, trash/restore, replace,
│ │ │ # import, filter/list, duplicate detection
│ │ ├── tag_service.go # CRUD + auto-tag application logic
│ │ ├── category_service.go # CRUD (thin, delegates to repo + ACL + audit)
│ │ ├── pool_service.go # CRUD + file ordering, add/remove files
│ │ ├── auth_service.go # Login, logout, JWT issue/refresh, session management
│ │ ├── acl_service.go # Permission checks, grant/revoke
│ │ ├── audit_service.go # Log actions, query audit log
│ │ └── user_service.go # Profile update, admin CRUD, block/unblock
│ │
│ ├── handler/ # HTTP layer (Gin handlers)
│ │ ├── router.go # Route registration, middleware wiring
│ │ ├── middleware.go # Auth middleware (JWT extraction → context)
│ │ ├── request.go # Common request parsing helpers
│ │ ├── response.go # Error/success response builders
│ │ ├── file_handler.go # /files endpoints
│ │ ├── tag_handler.go # /tags endpoints
│ │ ├── category_handler.go # /categories endpoints
│ │ ├── pool_handler.go # /pools endpoints
│ │ ├── auth_handler.go # /auth endpoints
│ │ ├── acl_handler.go # /acl endpoints
│ │ ├── user_handler.go # /users endpoints
│ │ └── audit_handler.go # /audit endpoints
│ │
│ ├── db/ # Database adapters
│ │ ├── db.go # Common helpers: pagination, repo factory, transactor base
│ │ └── postgres/ # PostgreSQL implementation
│ │ ├── postgres.go # pgxpool init, tx-from-context helpers
│ │ ├── file_repo.go # FileRepo implementation
│ │ ├── tag_repo.go # TagRepo + TagRuleRepo implementation
│ │ ├── category_repo.go # CategoryRepo implementation
│ │ ├── pool_repo.go # PoolRepo implementation
│ │ ├── user_repo.go # UserRepo implementation
│ │ ├── session_repo.go # SessionRepo implementation
│ │ ├── acl_repo.go # ACLRepo implementation
│ │ ├── audit_repo.go # AuditRepo implementation
│ │ ├── mime_repo.go # MimeRepo implementation
│ │ └── filter_parser.go # DSL → SQL WHERE clause builder
│ │
│ ├── storage/ # File storage adapter
│ │ └── disk.go # FileStorage implementation (read/write/delete on disk)
│ │
│ └── config/ # Configuration
│ └── config.go # Struct + loader from env vars
├── migrations/ # SQL migration files (goose format)
│ ├── 001_init_schemas.sql
│ ├── 002_core_tables.sql
│ ├── 003_data_tables.sql
│ ├── 004_acl_tables.sql
│ ├── 005_activity_tables.sql
│ ├── 006_indexes.sql
│ └── 007_seed_data.sql
├── go.mod
└── go.sum
```
## Layer Dependency Rules
```
handler → service → port (interfaces) ← db/postgres / storage
domain (entities, value objects, errors)
```
- **domain/**: zero imports from other internal packages. Only stdlib.
- **port/**: imports only domain/. Defines interfaces.
- **service/**: imports domain/ and port/. Never imports db/ or handler/.
- **handler/**: imports domain/ and service/. Never imports db/.
- **db/postgres/**: imports domain/, port/, and db/ (common helpers). Implements port interfaces.
- **db/**: imports domain/ and port/. Shared utilities for all DB adapters.
- **storage/**: imports domain/ and port/. Implements FileStorage.
No layer may import a layer above it. No circular dependencies.
## Key Design Decisions
### Dependency Injection (Wiring)
Manual wiring in `cmd/server/main.go`. No DI frameworks.
```go
// Pseudocode
pool := postgres.NewPool(cfg.DatabaseURL)
goose.Up(pool, migrations)
// Repos (all from internal/db/postgres/)
fileRepo := postgres.NewFileRepo(pool)
tagRepo := postgres.NewTagRepo(pool)
// ...
// Storage
diskStore := storage.NewDiskStorage(cfg.FilesPath)
// Services
aclSvc := service.NewACLService(aclRepo, objectTypeRepo)
auditSvc := service.NewAuditService(auditRepo, actionTypeRepo)
fileSvc := service.NewFileService(fileRepo, mimeRepo, tagRepo, diskStore, aclSvc, auditSvc)
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc)
// ...
// Handlers
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
// ...
router := handler.NewRouter(cfg, fileHandler, tagHandler, ...)
router.Run(cfg.ListenAddr)
```
### Context Propagation
Every service method receives `context.Context` as the first argument.
The handler extracts user info from JWT (via middleware) and puts it
into context. Services read the current user from context for ACL checks
and audit logging.
```go
// middleware.go
func (m *AuthMiddleware) Handle(c *gin.Context) {
claims := parseJWT(c.GetHeader("Authorization"))
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
// domain/context.go
type ctxKey int
const userKey ctxKey = iota
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context { ... }
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) { ... }
```
### Transaction Management
Repository interfaces include a `Transactor`:
```go
// port/repository.go
type Transactor interface {
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
}
```
The postgres implementation wraps `pgxpool.Pool.BeginTx`. Inside `fn`,
all repo calls use the transaction from context. This allows services
to compose multiple repo calls in a single transaction:
```go
// service/file_service.go
func (s *FileService) Upload(ctx context.Context, input UploadInput) (*domain.File, error) {
return s.tx.WithTx(ctx, func(ctx context.Context) error {
file, err := s.fileRepo.Create(ctx, ...) // uses tx
if err != nil { return err }
for _, tagID := range input.TagIDs {
s.tagRepo.AddFileTag(ctx, file.ID, tagID) // same tx
}
s.auditRepo.Log(ctx, ...) // same tx
return nil
})
}
```
### ACL Check Pattern
ACL logic is centralized in `ACLService`. Other services call it before
any data mutation or retrieval:
```go
// service/acl_service.go
func (s *ACLService) CanView(ctx context.Context, objectType string, objectID uuid.UUID) error {
userID, isAdmin := domain.UserFromContext(ctx)
if isAdmin { return nil }
// Check is_public on the object
// If not public, check creator_id == userID
// If not creator, check acl.permissions
// Return domain.ErrForbidden if none match
}
```
### Error Mapping
Domain errors → HTTP status codes (handled in handler/response.go):
| Domain Error | HTTP Status | Error Code |
|-----------------------|-------------|-------------------|
| ErrNotFound | 404 | not_found |
| ErrForbidden | 403 | forbidden |
| ErrUnauthorized | 401 | unauthorized |
| ErrConflict | 409 | conflict |
| ErrValidation | 400 | validation_error |
| ErrUnsupportedMIME | 415 | unsupported_mime |
| (unexpected) | 500 | internal_error |
### Filter DSL
The DSL parser lives in `db/postgres/filter_parser.go` because it produces
SQL WHERE clauses — it is a PostgreSQL-specific adapter concern.
The service layer passes the raw DSL string to the repository; the
repository parses it and builds the query.
For a different DBMS, a corresponding parser would live in
`db/<dbms>/filter_parser.go`.
The interface:
```go
// port/repository.go
type FileRepo interface {
List(ctx context.Context, params FileListParams) (*domain.FilePage, error)
// ...
}
// domain/file.go
type FileListParams struct {
Filter string // raw DSL string
Sort string
Order string
Cursor string
Anchor *uuid.UUID
Direction string // "forward" or "backward"
Limit int
Trash bool
Search string
}
```
### JWT Structure
```go
type Claims struct {
jwt.RegisteredClaims
UserID int16 `json:"uid"`
IsAdmin bool `json:"adm"`
SessionID int `json:"sid"`
}
```
Access token: short-lived (15 min). Refresh token: long-lived (30 days),
stored as hash in `activity.sessions.token_hash`.
### Configuration (.env)
```env
# Server
LISTEN_ADDR=:42776
JWT_SECRET=<random-32-bytes>
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h
# Database
DATABASE_URL=postgres://user:pass@host:5432/tanabata?sslmode=disable
# Storage
FILES_PATH=/data/files
THUMBS_CACHE_PATH=/data/thumbs
# Thumbnails
THUMB_WIDTH=160
THUMB_HEIGHT=160
PREVIEW_WIDTH=1920
PREVIEW_HEIGHT=1080
# Import
IMPORT_PATH=/data/import
```
-210
View File
@@ -1,210 +0,0 @@
@startuml Tanabata File Manager entity relationship diagram
' skinparam linetype ortho
' ========== SYSTEM ==========
entity "system.users" as usr {
* id : smallserial <<generated>>
--
* name : varchar(32)
* password : text
* is_admin : boolean
}
entity "system.mime" as mime {
* id : smallserial <<generated>>
--
* name : varchar(127)
* extension : varchar(16)
}
' ========== DATA ==========
entity "data.categories" as cty {
* id : uuid <<generated>>
--
* name : varchar(256)
notes : text
color : char(6)
' * created_at : timestamptz <<generated>>
* creator_id : smallint
' * is_private : boolean
}
cty::creator_id }o--|| usr::id
entity "data.files" as fle {
* id : uuid <<generated>>
--
name : varchar(256)
* mime_id : smallint
* datetime : timestamptz
notes : text
* metadata : jsonb
' * created_at : timestamptz <<generated>>
* creator_id : smallint
' * is_private : boolean
}
fle::mime_id }o--|| mime::id
fle::creator_id }o--|| usr::id
entity "data.tags" as tag {
* id : uuid <<generated>>
--
* name : varchar(256)
notes : text
color : char(6)
category_id : uuid
' * created_at : timestamptz <<generated>>
* creator_id : smallint
' * is_private : boolean
}
tag::category_id }o--o| cty::id
tag::creator_id }o--|| usr::id
entity "data.file_tag" as ft {
* file_id : uuid
* tag_id : uuid
}
ft::file_id }o--|| fle::id
ft::tag_id }o--|| tag::id
entity "data.autotags" as atg {
* trigger_tag_id : uuid
* add_tag_id : uuid
--
* is_active : boolean
}
atg::trigger_tag_id }o--|| tag::id
atg::add_tag_id }o--|| tag::id
entity "data.pools" as pool {
* id : uuid <<generated>>
--
* name : varchar(256)
notes : text
' parent_id : uuid
' * created_at : timestamptz
* creator_id : smallint
' * is_private : boolean
}
pool::creator_id }o--|| usr::id
' pool::parent_id }o--o| pool::id
entity "data.file_pool" as fp {
* file_id : uuid
* pool_id : uuid
* number : smallint
}
fp::file_id }o--|| fle::id
fp::pool_id }o--|| pool::id
' ========== ACL ==========
entity "acl.files" as acl_f {
* user_id : smallint
* file_id : uuid
--
* view : boolean
* edit : boolean
}
acl_f::user_id }o--|| usr::id
acl_f::file_id }o--|| fle::id
entity "acl.tags" as acl_t {
* user_id : smallint
* tag_id : uuid
--
* view : boolean
* edit : boolean
' * files_view : boolean
' * files_edit : boolean
}
acl_t::user_id }o--|| usr::id
acl_t::tag_id }o--|| tag::id
entity "acl.categories" as acl_c {
* user_id : smallint
* category_id : uuid
--
* view : boolean
* edit : boolean
' * tags_view : boolean
' * tags_edit : boolean
}
acl_c::user_id }o--|| usr::id
acl_c::category_id }o--|| cty::id
entity "acl.pools" as acl_p {
* user_id : smallint
* pool_id : uuid
--
* view : boolean
* edit : boolean
' * files_view : boolean
' * files_edit : boolean
}
acl_p::user_id }o--|| usr::id
acl_p::pool_id }o--|| pool::id
' ========== ACTIVITY ==========
entity "activity.sessions" as ssn {
* id : serial <<generated>>
--
* token : text
* user_id : smallint
* user_agent : varchar(512)
* started_at : timestamptz
expires_at : timestamptz
* last_activity : timestamptz
}
ssn::user_id }o--|| usr::id
entity "activity.file_views" as fv {
* file_id : uuid
* timestamp : timestamptz
* user_id : smallint
}
fv::file_id }o--|| fle::id
fv::user_id }o--|| usr::id
entity "activity.tag_uses" as tu {
* tag_id : uuid
* timestamp : timestamptz
* user_id : smallint
--
* included : boolean
}
tu::tag_id }o--|| tag::id
tu::user_id }o--|| usr::id
entity "activity.pool_views" as pv {
* pool_id : uuid
* timestamp : timestamptz
* user_id : smallint
}
pv::pool_id }o--|| pool::id
pv::user_id }o--|| usr::id
@enduml
+374
View File
@@ -0,0 +1,374 @@
from configparser import ConfigParser
from psycopg2.pool import ThreadedConnectionPool
from psycopg2.extras import RealDictCursor
from contextlib import contextmanager
from os import access, W_OK, makedirs, chmod, system
from os.path import isfile, join, basename
from shutil import move
from magic import Magic
from preview_generator.manager import PreviewManager
conf = None
mage = None
previewer = None
db_pool = None
DEFAULT_SORTING = {
"files": {
"key": "created",
"asc": False
},
"tags": {
"key": "created",
"asc": False
},
"categories": {
"key": "created",
"asc": False
},
"pools": {
"key": "created",
"asc": False
},
}
def Initialize(conf_path="/etc/tfm/tfm.conf"):
global mage, previewer
load_config(conf_path)
mage = Magic(mime=True)
previewer = PreviewManager(conf["Paths"]["Thumbs"])
db_connect(conf["DB.limits"]["MinimumConnections"], conf["DB.limits"]["MaximumConnections"], **conf["DB.params"])
def load_config(path):
global conf
conf = ConfigParser()
conf.read(path)
def db_connect(minconn, maxconn, **kwargs):
global db_pool
db_pool = ThreadedConnectionPool(minconn, maxconn, **kwargs)
@contextmanager
def _db_cursor():
global db_pool
try:
conn = db_pool.getconn()
except:
raise RuntimeError("Database not connected")
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
yield cur
conn.commit()
except:
conn.rollback()
raise
finally:
db_pool.putconn(conn)
def _validate_column_name(cur, table, column):
cur.execute("SELECT get_column_names(%s) AS name", (table,))
if all([column!=col["name"] for col in cur.fetchall()]):
raise RuntimeError("Invalid column name")
def authorize(username, password, useragent):
with _db_cursor() as cur:
cur.execute("SELECT tfm_session_request(tfm_user_auth(%s, %s), %s) AS sid", (username, password, useragent))
sid = cur.fetchone()["sid"]
return TSession(sid)
class TSession:
sid = None
def __init__(self, sid):
with _db_cursor() as cur:
cur.execute("SELECT tfm_session_validate(%s) IS NOT NULL AS valid", (sid,))
if not cur.fetchone()["valid"]:
raise RuntimeError("Invalid sid")
self.sid = sid
def terminate(self):
with _db_cursor() as cur:
cur.execute("CALL tfm_session_terminate(%s)", (self.sid,))
del self
@property
def username(self):
with _db_cursor() as cur:
cur.execute("SELECT tfm_session_username(%s) AS name", (self.sid,))
return cur.fetchone()["name"]
@property
def is_admin(self):
with _db_cursor() as cur:
cur.execute("SELECT * FROM tfm_user_get_info(%s)", (self.sid,))
return cur.fetchone()["can_edit"]
def get_files(self, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_files", order_key)
cur.execute("SELECT * FROM tfm_get_files(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid,))
return list(map(dict, cur.fetchall()))
def get_files_by_filter(self, philter=None, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_files", order_key)
cur.execute("SELECT * FROM tfm_get_files_by_filter(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid, philter))
return list(map(dict, cur.fetchall()))
def get_tags(self, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_tags", order_key)
cur.execute("SELECT * FROM tfm_get_tags(%%s) ORDER BY %s %s, name ASC OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid,))
return list(map(dict, cur.fetchall()))
def get_categories(self, order_key=DEFAULT_SORTING["categories"]["key"], order_asc=DEFAULT_SORTING["categories"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_categories", order_key)
cur.execute("SELECT * FROM tfm_get_categories(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid,))
return list(map(dict, cur.fetchall()))
def get_pools(self, order_key=DEFAULT_SORTING["pools"]["key"], order_asc=DEFAULT_SORTING["pools"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_pools", order_key)
cur.execute("SELECT * FROM tfm_get_pools(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid,))
return list(map(dict, cur.fetchall()))
def get_autotags(self, order_key="child_id", order_asc=True, offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_autotags", order_key)
cur.execute("SELECT * FROM tfm_get_autotags(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid,))
return list(map(dict, cur.fetchall()))
def get_my_sessions(self, order_key="started", order_asc=False, offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_sessions", order_key)
cur.execute("SELECT * FROM tfm_get_my_sessions(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid,))
return list(map(dict, cur.fetchall()))
def get_tags_by_file(self, file_id, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_tags", order_key)
cur.execute("SELECT * FROM tfm_get_tags_by_file(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid, file_id))
return list(map(dict, cur.fetchall()))
def get_files_by_tag(self, tag_id, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_files", order_key)
cur.execute("SELECT * FROM tfm_get_files_by_tag(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid, tag_id))
return list(map(dict, cur.fetchall()))
def get_files_by_pool(self, pool_id, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_files", order_key)
cur.execute("SELECT * FROM tfm_get_files_by_pool(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid, pool_id))
return list(map(dict, cur.fetchall()))
def get_parent_tags(self, tag_id, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_tags", order_key)
cur.execute("SELECT * FROM tfm_get_parent_tags(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid, tag_id))
return list(map(dict, cur.fetchall()))
def get_my_file_views(self, file_id=None, order_key="datetime", order_asc=False, offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_files", order_key)
cur.execute("SELECT * FROM tfm_get_my_file_views(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid, file_id))
return list(map(dict, cur.fetchall()))
def get_file(self, file_id):
with _db_cursor() as cur:
cur.execute("SELECT * FROM tfm_get_files(%s) WHERE id=%s", (self.sid, file_id))
return cur.fetchone()
def get_tag(self, tag_id):
with _db_cursor() as cur:
cur.execute("SELECT * FROM tfm_get_tags(%s) WHERE id=%s", (self.sid, tag_id))
return cur.fetchone()
def get_category(self, category_id):
with _db_cursor() as cur:
cur.execute("SELECT * FROM tfm_get_categories(%s) WHERE id=%s", (self.sid, category_id))
return cur.fetchone()
def view_file(self, file_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_view_file(%s, %s)", (self.sid, file_id))
def add_file(self, path, datetime=None, notes=None, is_private=None, orig_name=True):
if not isfile(path):
raise FileNotFoundError("No such file '%s'" % path)
if not access(conf["Paths"]["Files"], W_OK) or not access(conf["Paths"]["Thumbs"], W_OK):
raise PermissionError("Invalid directories for files and thumbs")
mime = mage.from_file(path)
if orig_name == True:
orig_name = basename(path)
with _db_cursor() as cur:
cur.execute("SELECT * FROM tfm_add_file(%s, %s, %s, %s, %s, %s)", (self.sid, mime, datetime, notes, is_private, orig_name))
res = cur.fetchone()
file_id = res["f_id"]
ext = res["ext"]
file_path = join(conf["Paths"]["Files"], file_id)
move(path, file_path)
thumb_path = previewer.get_jpeg_preview(file_path, height=160, width=160)
preview_path = previewer.get_jpeg_preview(file_path, height=1080, width=1920)
chmod(file_path, 0o664)
chmod(thumb_path, 0o664)
chmod(preview_path, 0o664)
return file_id, ext
def add_tag(self, name, notes=None, color=None, category_id=None, is_private=None):
if color is not None:
color = color.replace('#', '')
if not category_id:
category_id = None
with _db_cursor() as cur:
cur.execute("SELECT tfm_add_tag(%s, %s, %s, %s, %s, %s) AS id", (self.sid, name, notes, color, category_id, is_private))
return cur.fetchone()["id"]
def add_category(self, name, notes=None, color=None, is_private=None):
if color is not None:
color = color.replace('#', '')
with _db_cursor() as cur:
cur.execute("SELECT tfm_add_category(%s, %s, %s, %s, %s) AS id", (self.sid, name, notes, color, is_private))
return cur.fetchone()["id"]
def add_pool(self, name, notes=None, parent_id=None, is_private=None):
with _db_cursor() as cur:
cur.execute("SELECT tfm_add_pool(%s, %s, %s, %s, %s) AS id", (self.sid, name, notes, parent_id, is_private))
return cur.fetchone()["id"]
def add_autotag(self, child_id, parent_id, is_active=None, apply_to_existing=None):
with _db_cursor() as cur:
cur.execute("SELECT tfm_add_autotag(%s, %s, %s, %s, %s) AS added", (self.sid, child_id, parent_id, is_active, apply_to_existing))
return cur.fetchone()["added"]
def add_file_to_tag(self, file_id, tag_id):
with _db_cursor() as cur:
cur.execute("SELECT tfm_add_file_to_tag(%s, %s, %s) AS id", (self.sid, file_id, tag_id))
return list(map(lambda t: t["id"], cur.fetchall()))
def add_file_to_pool(self, file_id, pool_id):
with _db_cursor() as cur:
cur.execute("SELECT tfm_add_file_to_pool(%s, %s, %s) AS added", (self.sid, file_id, pool_id))
return cur.fetchone()["added"]
def edit_file(self, file_id, mime=None, datetime=None, notes=None, is_private=None):
with _db_cursor() as cur:
cur.execute("CALL tfm_edit_file(%s, %s, %s, %s, %s, %s)", (self.sid, file_id, mime, datetime, notes, is_private))
def edit_tag(self, tag_id, name=None, notes=None, color=None, category_id=None, is_private=None):
if color is not None:
color = color.replace('#', '')
if not category_id:
category_id = None
with _db_cursor() as cur:
cur.execute("CALL tfm_edit_tag(%s, %s, %s, %s, %s, %s, %s)", (self.sid, tag_id, name, notes, color, category_id, is_private))
def edit_category(self, category_id, name=None, notes=None, color=None, is_private=None):
if color is not None:
color = color.replace('#', '')
with _db_cursor() as cur:
cur.execute("CALL tfm_edit_category(%s, %s, %s, %s, %s, %s)", (self.sid, category_id, name, notes, color, is_private))
def edit_pool(self, pool_id, name=None, notes=None, parent_id=None, is_private=None):
with _db_cursor() as cur:
cur.execute("CALL tfm_edit_pool(%s, %s, %s, %s, %s, %s)", (self.sid, pool_id, name, notes, parent_id, is_private))
def remove_file(self, file_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_file(%s, %s)", (self.sid, file_id))
if system("rm %s/%s*" % (conf["Paths"]["Files"], file_id)):
raise RuntimeError("Failed to remove file '%s'" % file_id)
def remove_tag(self, tag_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_tag(%s, %s)", (self.sid, tag_id))
def remove_category(self, category_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_category(%s, %s)", (self.sid, category_id))
def remove_pool(self, pool_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_pool(%s, %s)", (self.sid, pool_id))
def remove_autotag(self, child_id, parent_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_autotag(%s, %s, %s)", (self.sid, child_id, parent_id))
def remove_file_to_tag(self, file_id, tag_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_file_to_tag(%s, %s, %s)", (self.sid, file_id, tag_id))
def remove_file_to_pool(self, file_id, pool_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_file_to_pool(%s, %s, %s)", (self.sid, file_id, pool_id))
+22
View File
@@ -0,0 +1,22 @@
package main
import (
"tanabata/internal/storage/postgres"
)
func main() {
postgres.InitDB("postgres://hiko:taikibansei@192.168.0.25/Tanabata_new?application_name=Tanabata%20testing")
// test_json := json.RawMessage([]byte("{\"valery\": \"ponosoff\"}"))
// data, statusCode, err := db.FileGetSlice(1, "", "+2", -2, 0)
// data, statusCode, err := db.FileGet(1, "0197d056-cfb0-76b5-97e0-bd588826393c")
// data, statusCode, err := db.FileAdd(1, "ABOBA.png", "image/png", time.Now(), "slkdfjsldkflsdkfj;sldkf", test_json)
// statusCode, err := db.FileUpdate(2, "0197d159-bf3a-7617-a3a8-a4a9fc39eca6", map[string]interface{}{
// "name": "ponos.png",
// })
// statusCode, err := db.FileDelete(1, "0197d155-848f-7221-ba4a-4660f257c7d5")
// v, e, err := postgres.FileGetAccess(1, "0197d15a-57f9-712c-991e-c512290e774f")
// fmt.Printf("V: %s, E: %s\n", v, e)
// fmt.Printf("Status: %d\n", statusCode)
// fmt.Printf("Error: %s\n", err)
// fmt.Printf("%+v\n", data)
}
@@ -0,0 +1,22 @@
package main
import (
"fmt"
"tanabata/db"
)
func main() {
db.InitDB("postgres://hiko:taikibansei@192.168.0.25/Tanabata_new?application_name=Tanabata%20testing")
// test_json := json.RawMessage([]byte("{\"valery\": \"ponosoff\"}"))
// data, statusCode, err := db.FileGetSlice(2, "", "+2", -2, 0)
// data, statusCode, err := db.FileGet(1, "0197d056-cfb0-76b5-97e0-bd588826393c")
// data, statusCode, err := db.FileAdd(1, "ABOBA.png", "image/png", time.Now(), "slkdfjsldkflsdkfj;sldkf", test_json)
// statusCode, err := db.FileUpdate(2, "0197d159-bf3a-7617-a3a8-a4a9fc39eca6", map[string]interface{}{
// "name": "ponos.png",
// })
statusCode, err := db.FileDelete(1, "0197d155-848f-7221-ba4a-4660f257c7d5")
fmt.Printf("Status: %d\n", statusCode)
fmt.Printf("Error: %s\n", err)
// fmt.Printf("%+v\n", data)
}
@@ -2,11 +2,13 @@ package db
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -48,3 +50,30 @@ func transaction(handler func(context.Context, pgx.Tx) (statusCode int, err erro
}
return
}
// Handle database error
func handleDBError(errIn error) (statusCode int, err error) {
if errIn == nil {
statusCode = http.StatusOK
return
}
if errors.Is(errIn, pgx.ErrNoRows) {
err = fmt.Errorf("not found")
statusCode = http.StatusNotFound
return
}
var pgErr *pgconn.PgError
if errors.As(errIn, &pgErr) {
switch pgErr.Code {
case "22P02", "22007": // Invalid data format
err = fmt.Errorf("%s", pgErr.Message)
statusCode = http.StatusBadRequest
return
case "23505": // Unique constraint violation
err = fmt.Errorf("already exists")
statusCode = http.StatusConflict
return
}
}
return http.StatusInternalServerError, errIn
}
+19
View File
@@ -0,0 +1,19 @@
module tanabata
go 1.23.0
toolchain go1.23.10
require github.com/jackc/pgx/v5 v5.7.5
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx v3.6.2+incompatible // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/stretchr/testify v1.9.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
+32
View File
@@ -0,0 +1,32 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=
github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,122 @@
package domain
import (
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
type User struct {
Name string `json:"name"`
IsAdmin bool `json:"isAdmin"`
CanCreate bool `json:"canCreate"`
}
type MIME struct {
Name string `json:"name"`
Extension string `json:"extension"`
}
type (
CategoryCore struct {
ID string `json:"id"`
Name string `json:"name"`
Color pgtype.Text `json:"color"`
}
CategoryItem struct {
CategoryCore
}
CategoryFull struct {
CategoryCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
}
)
type (
FileCore struct {
ID string `json:"id"`
Name pgtype.Text `json:"name"`
MIME MIME `json:"mime"`
}
FileItem struct {
FileCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
}
FileFull struct {
FileCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
Metadata json.RawMessage `json:"metadata"`
Tags []TagCore `json:"tags"`
Viewed int `json:"viewed"`
}
)
type (
TagCore struct {
ID string `json:"id"`
Name string `json:"name"`
Color pgtype.Text `json:"color"`
}
TagItem struct {
TagCore
Category CategoryCore `json:"category"`
}
TagFull struct {
TagCore
Category CategoryCore `json:"category"`
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
UsedIncl int `json:"usedIncl"`
UsedExcl int `json:"usedExcl"`
}
)
type Autotag struct {
TriggerTag TagCore `json:"triggerTag"`
AddTag TagCore `json:"addTag"`
IsActive bool `json:"isActive"`
}
type (
PoolCore struct {
ID string `json:"id"`
Name string `json:"name"`
}
PoolItem struct {
PoolCore
}
PoolFull struct {
PoolCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
Viewed int `json:"viewed"`
}
)
type Session struct {
ID int `json:"id"`
UserAgent string `json:"userAgent"`
StartedAt time.Time `json:"startedAt"`
ExpiresAt time.Time `json:"expiresAt"`
LastActivity time.Time `json:"lastActivity"`
}
type Pagination struct {
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Count int `json:"count"`
}
type Slice[T any] struct {
Pagination Pagination `json:"pagination"`
Data []T `json:"data"`
}
@@ -0,0 +1,16 @@
package postgres
import "context"
func UserLogin(ctx context.Context, name, password string) (user_id int, err error) {
row := connPool.QueryRow(ctx, "SELECT id FROM users WHERE name=$1 AND password=crypt($2, password)", name, password)
err = row.Scan(&user_id)
return
}
func UserAuth(ctx context.Context, user_id int) (ok, isAdmin bool) {
row := connPool.QueryRow(ctx, "SELECT is_admin FROM users WHERE id=$1", user_id)
err := row.Scan(&isAdmin)
ok = (err == nil)
return
}
@@ -0,0 +1,268 @@
package postgres
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/internal/domain"
)
type FileStore struct {
db *pgxpool.Pool
}
func NewFileStore(db *pgxpool.Pool) *FileStore {
return &FileStore{db: db}
}
// Get user's access rights to file
func (s *FileStore) getAccess(user_id int, file_id string) (canView, canEdit bool, err error) {
ctx := context.Background()
row := connPool.QueryRow(ctx, `
SELECT
COALESCE(a.view, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE),
COALESCE(a.edit, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE)
FROM data.files f
LEFT JOIN acl.files a ON a.file_id=f.id AND a.user_id=$1
LEFT JOIN system.users u ON u.id=$1
WHERE f.id=$2
`, user_id, file_id)
err = row.Scan(&canView, &canEdit)
return
}
// Get a set of files
func (s *FileStore) GetSlice(user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], statusCode int, err error) {
filterCond, statusCode, err := filterToSQL(filter)
if err != nil {
return
}
sortExpr, statusCode, err := sortToSQL(sort)
if err != nil {
return
}
// prepare query
query := `
SELECT
f.id,
f.name,
m.name,
m.extension,
uuid_extract_timestamp(f.id),
u.name,
u.is_admin
FROM data.files f
JOIN system.mime m ON m.id=f.mime_id
JOIN system.users u ON u.id=f.creator_id
WHERE NOT f.is_deleted AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=f.id AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1)) AND
`
query += filterCond
queryCount := query
query += sortExpr
if limit >= 0 {
query += fmt.Sprintf(" LIMIT %d", limit)
}
if offset > 0 {
query += fmt.Sprintf(" OFFSET %d", offset)
}
// execute query
statusCode, err = transaction(func(ctx context.Context, tx pgx.Tx) (statusCode int, err error) {
rows, err := tx.Query(ctx, query, user_id)
if err != nil {
statusCode, err = handleDBError(err)
return
}
defer rows.Close()
count := 0
for rows.Next() {
var file domain.FileItem
err = rows.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin)
if err != nil {
statusCode = http.StatusInternalServerError
return
}
files.Data = append(files.Data, file)
count++
}
err = rows.Err()
if err != nil {
statusCode = http.StatusInternalServerError
return
}
files.Pagination.Limit = limit
files.Pagination.Offset = offset
files.Pagination.Count = count
row := tx.QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM (%s) tmp", queryCount), user_id)
err = row.Scan(&files.Pagination.Total)
if err != nil {
statusCode = http.StatusInternalServerError
}
return
})
if err == nil {
statusCode = http.StatusOK
}
return
}
// Get file
func (s *FileStore) Get(user_id int, file_id string) (file domain.FileFull, statusCode int, err error) {
ctx := context.Background()
row := connPool.QueryRow(ctx, `
SELECT
f.id,
f.name,
m.name,
m.extension,
uuid_extract_timestamp(f.id),
u.name,
u.is_admin,
f.notes,
f.metadata,
(SELECT COUNT(*) FROM activity.file_views fv WHERE fv.file_id=$2 AND fv.user_id=$1)
FROM data.files f
JOIN system.mime m ON m.id=f.mime_id
JOIN system.users u ON u.id=f.creator_id
WHERE NOT f.is_deleted AND f.id=$2 AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))
`, user_id, file_id)
err = row.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin, &file.Notes, &file.Metadata, &file.Viewed)
if err != nil {
statusCode, err = handleDBError(err)
return
}
rows, err := connPool.Query(ctx, `
SELECT
t.id,
t.name,
COALESCE(t.color, c.color)
FROM data.tags t
LEFT JOIN data.categories c ON c.id=t.category_id
JOIN data.file_tag ft ON ft.tag_id=t.id
WHERE ft.file_id=$1
`, file_id)
if err != nil {
statusCode, err = handleDBError(err)
return
}
defer rows.Close()
for rows.Next() {
var tag domain.TagCore
err = rows.Scan(&tag.ID, &tag.Name, &tag.Color)
if err != nil {
statusCode = http.StatusInternalServerError
return
}
file.Tags = append(file.Tags, tag)
}
err = rows.Err()
if err != nil {
statusCode = http.StatusInternalServerError
return
}
statusCode = http.StatusOK
return
}
// Add file
func (s *FileStore) Add(user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, statusCode int, err error) {
ctx := context.Background()
var mime_id int
var extension string
row := connPool.QueryRow(ctx, "SELECT id, extension FROM system.mime WHERE name=$1", mime)
err = row.Scan(&mime_id, &extension)
if err != nil {
if err == pgx.ErrNoRows {
err = fmt.Errorf("unsupported file type: %q", mime)
statusCode = http.StatusBadRequest
} else {
statusCode, err = handleDBError(err)
}
return
}
row = connPool.QueryRow(ctx, `
INSERT INTO data.files (name, mime_id, datetime, creator_id, notes, metadata)
VALUES (NULLIF($1, ''), $2, $3, $4, NULLIF($5 ,''), $6)
RETURNING id
`, name, mime_id, datetime, user_id, notes, metadata)
err = row.Scan(&file.ID)
if err != nil {
statusCode, err = handleDBError(err)
return
}
file.Name.String = name
file.Name.Valid = (name != "")
file.MIME.Name = mime
file.MIME.Extension = extension
statusCode = http.StatusOK
return
}
// Update file
func (s *FileStore) Update(user_id int, file_id string, updates map[string]interface{}) (statusCode int, err error) {
if len(updates) == 0 {
err = fmt.Errorf("no fields provided for update")
statusCode = http.StatusBadRequest
return
}
writableFields := map[string]bool{
"name": true,
"datetime": true,
"notes": true,
"metadata": true,
}
query := "UPDATE data.files SET"
newValues := []interface{}{user_id}
count := 2
for field, value := range updates {
if !writableFields[field] {
err = fmt.Errorf("invalid field: %q", field)
statusCode = http.StatusBadRequest
return
}
query += fmt.Sprintf(" %s=NULLIF($%d, '')", field, count)
newValues = append(newValues, value)
count++
}
query += fmt.Sprintf(
" WHERE id=$%d AND (creator_id=$1 OR (SELECT edit FROM acl.files WHERE file_id=$%d AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))",
count, count)
newValues = append(newValues, file_id)
ctx := context.Background()
commandTag, err := connPool.Exec(ctx, query, newValues...)
if err != nil {
statusCode, err = handleDBError(err)
return
}
if commandTag.RowsAffected() == 0 {
err = fmt.Errorf("not found")
statusCode = http.StatusNotFound
return
}
statusCode = http.StatusNoContent
return
}
// Delete file
func (s *FileStore) Delete(user_id int, file_id string) (statusCode int, err error) {
ctx := context.Background()
commandTag, err := connPool.Exec(ctx,
"DELETE FROM data.files WHERE id=$2 AND (creator_id=$1 OR (SELECT edit FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))",
user_id, file_id)
if err != nil {
statusCode, err = handleDBError(err)
return
}
if commandTag.RowsAffected() == 0 {
err = fmt.Errorf("not found")
statusCode = http.StatusNotFound
return
}
statusCode = http.StatusNoContent
return
}
@@ -0,0 +1,92 @@
package postgres
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type Storage struct {
db *pgxpool.Pool
}
var connPool *pgxpool.Pool
// Initialize new database storage
func New(dbURL string) (*Storage, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
config, err := pgxpool.ParseConfig(dbURL)
if err != nil {
return nil, fmt.Errorf("failed to parse DB URL: %w", err)
}
config.MaxConns = 10
config.MinConns = 2
config.HealthCheckPeriod = time.Minute
db, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
err = db.Ping(ctx)
if err != nil {
return nil, fmt.Errorf("database ping failed: %w", err)
}
return &Storage{db: db}, nil
}
// Close database storage
func (s *Storage) Close() {
s.db.Close()
}
// Run handler inside transaction
func (s *Storage) transaction(ctx context.Context, handler func(context.Context, pgx.Tx) (statusCode int, err error)) (statusCode int, err error) {
tx, err := connPool.Begin(ctx)
if err != nil {
statusCode = http.StatusInternalServerError
return
}
statusCode, err = handler(ctx, tx)
if err != nil {
tx.Rollback(ctx)
return
}
err = tx.Commit(ctx)
if err != nil {
statusCode = http.StatusInternalServerError
}
return
}
// Handle database error
func (s *Storage) handleDBError(errIn error) (statusCode int, err error) {
if errIn == nil {
statusCode = http.StatusOK
return
}
if errors.Is(errIn, pgx.ErrNoRows) {
err = fmt.Errorf("not found")
statusCode = http.StatusNotFound
return
}
var pgErr *pgconn.PgError
if errors.As(errIn, &pgErr) {
switch pgErr.Code {
case "22P02", "22007": // Invalid data format
err = fmt.Errorf("%s", pgErr.Message)
statusCode = http.StatusBadRequest
return
case "23505": // Unique constraint violation
err = fmt.Errorf("already exists")
statusCode = http.StatusConflict
return
}
}
return http.StatusInternalServerError, errIn
}

Some files were not shown because too many files have changed in this diff Show More