Compare commits
22 Commits
master
..
38294e20dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 38294e20dd | |||
| 4154c1b0b9 | |||
| 1cb2d54c0c | |||
| a6387f2eb8 | |||
| cf7317747e | |||
| dea6a55dfc | |||
| ee251c8727 | |||
| a8ec2a80cb | |||
| 6c9b1bf1cd | |||
| caeff6786e | |||
| 296f44b4ed | |||
| f7cf8cb914 | |||
| 1b3fc04e06 | |||
| d8f6364008 | |||
| 8dd2d631e5 | |||
| a9209ae3a3 | |||
| ba0713151c | |||
| b692fabed5 | |||
| 830e411d92 | |||
| b7995b7e4a | |||
| f043d38eb2 | |||
| 780f85de59 |
@@ -1,33 +0,0 @@
|
||||
# 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
|
||||
@@ -1,108 +1,27 @@
|
||||
# =============================================================================
|
||||
# Tanabata File Manager — environment variables
|
||||
#
|
||||
# Copy to .env and fill in the secrets:
|
||||
# cp .env.example .env
|
||||
# docker compose up -d --build
|
||||
# Copy to .env and fill in the values.
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
LISTEN_ADDR=:8080
|
||||
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
|
||||
DATABASE_URL=postgres://tanabata:password@localhost:5432/tanabata?sslmode=disable
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage (paths inside the container; backed by named volumes in compose)
|
||||
# Storage
|
||||
# ---------------------------------------------------------------------------
|
||||
FILES_PATH=/data/files
|
||||
THUMBS_CACHE_PATH=/data/thumbs
|
||||
|
||||
# Maximum accepted upload size in bytes (default 500 MiB).
|
||||
MAX_UPLOAD_BYTES=524288000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thumbnails
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -110,37 +29,8 @@ 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
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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
|
||||
@@ -21,8 +21,8 @@ Monorepo: `backend/` (Go) + `frontend/` (SvelteKit).
|
||||
|
||||
## Design reference
|
||||
|
||||
Visual design tokens for the frontend (carried over from the previous
|
||||
Python/Flask version):
|
||||
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
|
||||
@@ -52,7 +52,4 @@ npm run generate:types # regenerate API types from openapi.yaml
|
||||
- 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, structure)
|
||||
- Git: conventional commits (feat:, fix:, docs:, refactor:)
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# =============================================================================
|
||||
# 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"]
|
||||
@@ -1,98 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,217 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -3,9 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
@@ -52,7 +50,6 @@ func main() {
|
||||
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)
|
||||
@@ -68,10 +65,6 @@ func main() {
|
||||
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
|
||||
@@ -81,16 +74,10 @@ func main() {
|
||||
cfg.JWTSecret,
|
||||
cfg.JWTAccessTTL,
|
||||
cfg.JWTRefreshTTL,
|
||||
cfg.ContentTokenTTL,
|
||||
)
|
||||
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
||||
aclSvc := service.NewACLService(aclRepo)
|
||||
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,
|
||||
@@ -101,49 +88,17 @@ func main() {
|
||||
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)
|
||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
||||
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,
|
||||
}
|
||||
r := handler.NewRouter(authMiddleware, authHandler, fileHandler, tagHandler)
|
||||
|
||||
slog.Info("starting server", "addr", cfg.ListenAddr)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
if err := r.Run(cfg.ListenAddr); err != nil {
|
||||
slog.Error("server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -5,100 +5,51 @@ go 1.26
|
||||
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/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // 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/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/kr/text v0.2.0 // 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/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // 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/crypto v0.39.0 // indirect
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
||||
golang.org/x/net v0.33.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
|
||||
)
|
||||
|
||||
@@ -1,69 +1,25 @@
|
||||
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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=
|
||||
@@ -76,14 +32,11 @@ 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=
|
||||
@@ -98,65 +51,29 @@ 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/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
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=
|
||||
@@ -167,88 +84,39 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK
|
||||
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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
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/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
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/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
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=
|
||||
@@ -257,8 +125,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
||||
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=
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
@@ -18,24 +17,6 @@ type Config struct {
|
||||
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
|
||||
@@ -43,36 +24,15 @@ type Config struct {
|
||||
// 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
|
||||
@@ -98,10 +58,6 @@ func Load() (*Config, error) {
|
||||
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)
|
||||
@@ -109,10 +65,6 @@ func Load() (*Config, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -129,62 +81,23 @@ func Load() (*Config, error) {
|
||||
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"),
|
||||
ListenAddr: defaultStr("LISTEN_ADDR", ":8080"),
|
||||
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 {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -36,7 +36,6 @@ type fileRow struct {
|
||||
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.
|
||||
@@ -82,7 +81,6 @@ func toFile(r fileRow) domain.File {
|
||||
CreatorName: r.CreatorName,
|
||||
IsPublic: r.IsPublic,
|
||||
IsDeleted: r.IsDeleted,
|
||||
NeedsReview: r.NeedsReview,
|
||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
||||
}
|
||||
}
|
||||
@@ -295,7 +293,7 @@ const fileSelectCTE = `
|
||||
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
|
||||
r.is_public, r.is_deleted
|
||||
FROM r
|
||||
JOIN core.mime_types mt ON mt.id = r.mime_id
|
||||
JOIN core.users u ON u.id = r.creator_id`
|
||||
@@ -304,27 +302,25 @@ const fileSelectCTE = `
|
||||
// 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.
|
||||
// Create inserts a new file record. The MIME type is resolved from
|
||||
// f.MIMEType (name string) via a subquery; the DB generates the UUID v7 id.
|
||||
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)
|
||||
(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
|
||||
(SELECT id FROM core.mime_types WHERE name = $2),
|
||||
$3, $4, $5, $6, $7, $8, $9
|
||||
)
|
||||
RETURNING id, original_name, mime_id, content_datetime, notes,
|
||||
metadata, exif, phash, creator_id, is_public, is_deleted,
|
||||
needs_review
|
||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
||||
)` + fileSelectCTE
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sqlStr,
|
||||
f.ID, f.OriginalName, f.MIMEType, f.ContentDatetime,
|
||||
f.OriginalName, f.MIMEType, f.ContentDatetime,
|
||||
f.Notes, f.Metadata, f.EXIF, f.PHash,
|
||||
f.CreatorID, f.IsPublic,
|
||||
)
|
||||
@@ -349,7 +345,7 @@ func (r *FileRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.File, err
|
||||
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
|
||||
f.is_public, f.is_deleted
|
||||
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
|
||||
@@ -392,8 +388,7 @@ func (r *FileRepo) Update(ctx context.Context, id uuid.UUID, f *domain.File) (*d
|
||||
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
|
||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
||||
)` + fileSelectCTE
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
@@ -420,115 +415,6 @@ func (r *FileRepo) Update(ctx context.Context, id uuid.UUID, f *domain.File) (*d
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -557,8 +443,7 @@ func (r *FileRepo) Restore(ctx context.Context, id uuid.UUID) (*domain.File, err
|
||||
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
|
||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
||||
)` + fileSelectCTE
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
@@ -721,13 +606,6 @@ func (r *FileRepo) List(ctx context.Context, params domain.FileListParams) (*dom
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -752,7 +630,7 @@ func (r *FileRepo) List(ctx context.Context, params domain.FileListParams) (*dom
|
||||
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
|
||||
f.is_public, f.is_deleted
|
||||
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
|
||||
@@ -915,43 +793,3 @@ func (r *FileRepo) loadTagsBatch(ctx context.Context, fileIDs []uuid.UUID) (map[
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ const (
|
||||
ftkTag // t=<uuid>
|
||||
ftkMimeExact // m=<int>
|
||||
ftkMimeLike // m~<pattern>
|
||||
ftkReview // r=<0|1>
|
||||
)
|
||||
|
||||
type filterToken struct {
|
||||
@@ -32,7 +31,6 @@ type filterToken struct {
|
||||
untagged bool // ftkTag with zero UUID → "file has no tags"
|
||||
mimeID int16 // ftkMimeExact
|
||||
pattern string // ftkMimeLike
|
||||
review bool // ftkReview → needs_review value
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -82,8 +80,6 @@ func (l *leafNode) toSQL(n int, args []any) (string, int, []any) {
|
||||
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")
|
||||
}
|
||||
@@ -134,15 +130,6 @@ func lexFilter(dsl string) ([]filterToken, error) {
|
||||
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)
|
||||
}
|
||||
@@ -254,7 +241,7 @@ func (p *filterParser) parseAtom() (filterNode, error) {
|
||||
return expr, nil
|
||||
}
|
||||
switch t.kind {
|
||||
case ftkTag, ftkMimeExact, ftkMimeLike, ftkReview:
|
||||
case ftkTag, ftkMimeExact, ftkMimeLike:
|
||||
p.next()
|
||||
return &leafNode{t}, nil
|
||||
default:
|
||||
@@ -266,31 +253,6 @@ func (p *filterParser) parseAtom() (filterNode, error) {
|
||||
// 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
|
||||
@@ -300,62 +262,25 @@ func parseFilterAST(dsl string) (filterNode, error) {
|
||||
// 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)
|
||||
dsl = strings.TrimSpace(dsl)
|
||||
if dsl == "" || dsl == "{}" {
|
||||
return "", argStart, nil, nil
|
||||
}
|
||||
toks, err := lexFilter(dsl)
|
||||
if err != nil {
|
||||
return "", argStart, nil, err
|
||||
}
|
||||
if node == nil {
|
||||
if len(toks) == 0 {
|
||||
return "", argStart, nil, nil
|
||||
}
|
||||
p := &filterParser{tokens: toks}
|
||||
node, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return "", argStart, nil, err
|
||||
}
|
||||
if p.pos != len(p.tokens) {
|
||||
return "", argStart, nil, fmt.Errorf("filter: trailing tokens at position %d", p.pos)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,725 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -11,30 +11,12 @@ import (
|
||||
"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)
|
||||
pool, err := pgxpool.New(ctx, 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)
|
||||
return nil, fmt.Errorf("pgxpool.New: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
@@ -83,29 +65,3 @@ func connOrTx(ctx context.Context, pool *pgxpool.Pool) db.Querier {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -74,28 +74,6 @@ func (r *SessionRepo) Create(ctx context.Context, s *domain.Session) (*domain.Se
|
||||
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
|
||||
|
||||
@@ -155,13 +155,6 @@ func (r *TagRepo) listTags(ctx context.Context, params port.OffsetParams, catego
|
||||
}
|
||||
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
|
||||
@@ -176,12 +169,6 @@ func (r *TagRepo) listTags(ctx context.Context, params port.OffsetParams, catego
|
||||
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 {
|
||||
@@ -211,8 +198,8 @@ 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)
|
||||
ORDER BY %s %s NULLS LAST, t.id ASC
|
||||
LIMIT $%d OFFSET $%d`, where, sortCol, order, n, n+1)
|
||||
|
||||
args = append(args, limit, offset)
|
||||
|
||||
@@ -272,7 +259,7 @@ 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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
)
|
||||
SELECT
|
||||
@@ -321,7 +308,7 @@ WITH upd AS (
|
||||
UPDATE data.tags SET
|
||||
name = $2,
|
||||
notes = $3,
|
||||
color = NULLIF($4, ''),
|
||||
color = $4,
|
||||
category_id = $5,
|
||||
metadata = COALESCE($6, metadata),
|
||||
is_public = $7
|
||||
@@ -587,51 +574,19 @@ JOIN data.tags t ON t.id = ins.then_tag_id`
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *TagRuleRepo) SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active, applyToExisting bool) error {
|
||||
const updateQuery = `
|
||||
func (r *TagRuleRepo) SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active bool) error {
|
||||
const query = `
|
||||
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)
|
||||
ct, err := q.Exec(ctx, query, 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
|
||||
}
|
||||
|
||||
|
||||
@@ -162,12 +162,12 @@ func (r *UserRepo) Create(ctx context.Context, u *domain.User) (*domain.User, er
|
||||
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
|
||||
SET name = $2, password = $3, is_admin = $4, can_create = $5
|
||||
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)
|
||||
rows, err := q.Query(ctx, sql, id, u.Name, u.Password, u.IsAdmin, u.CanCreate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("UserRepo.Update: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -29,7 +29,6 @@ type File struct {
|
||||
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
|
||||
}
|
||||
@@ -50,12 +49,6 @@ type FileListParams struct {
|
||||
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.
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -22,34 +21,11 @@ import (
|
||||
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
|
||||
// NewFileHandler creates a FileHandler.
|
||||
func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService) *FileHandler {
|
||||
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -84,7 +60,6 @@ type fileJSON struct {
|
||||
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"`
|
||||
}
|
||||
@@ -132,7 +107,6 @@ func toFileJSON(f domain.File) fileJSON {
|
||||
CreatorName: f.CreatorName,
|
||||
IsPublic: f.IsPublic,
|
||||
IsDeleted: f.IsDeleted,
|
||||
NeedsReview: f.NeedsReview,
|
||||
CreatedAt: f.CreatedAt.Format(time.RFC3339),
|
||||
Tags: tags,
|
||||
}
|
||||
@@ -212,8 +186,9 @@ func (h *FileHandler) List(c *gin.Context) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (h *FileHandler) Upload(c *gin.Context) {
|
||||
fh, ok := h.formFileLimited(c)
|
||||
if !ok {
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
respondError(c, domain.ErrValidation)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -303,25 +278,6 @@ func (h *FileHandler) GetMeta(c *gin.Context) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -386,38 +342,6 @@ func (h *FileHandler) SoftDelete(c *gin.Context) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -436,26 +360,9 @@ func (h *FileHandler) GetContent(c *gin.Context) {
|
||||
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
|
||||
fmt.Sprintf("attachment; filename=%q", *res.OriginalName))
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, res.Body) //nolint:errcheck
|
||||
@@ -471,8 +378,9 @@ func (h *FileHandler) ReplaceContent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fh, ok := h.formFileLimited(c)
|
||||
if !ok {
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
respondError(c, domain.ErrValidation)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -528,7 +436,6 @@ func (h *FileHandler) GetThumbnail(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
@@ -551,7 +458,6 @@ func (h *FileHandler) GetPreview(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
@@ -663,33 +569,6 @@ func (h *FileHandler) BulkDelete(c *gin.Context) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -734,48 +613,19 @@ func (h *FileHandler) CommonTags(c *gin.Context) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 {
|
||||
result, err := h.fileSvc.Import(c.Request.Context(), body.Path)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// Headers already sent; surface the failure as a terminal stream event.
|
||||
emit(service.ImportEvent{Type: "error", Reason: err.Error()})
|
||||
}
|
||||
|
||||
respondJSON(c, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"tanabata/backend/internal/domain"
|
||||
"tanabata/backend/internal/service"
|
||||
@@ -25,8 +24,8 @@ func NewAuthMiddleware(authSvc *service.AuthService) *AuthMiddleware {
|
||||
// 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 == "" {
|
||||
raw := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(raw, "Bearer ") {
|
||||
c.JSON(http.StatusUnauthorized, errorBody{
|
||||
Code: domain.ErrUnauthorized.Code(),
|
||||
Message: "authorization header missing or malformed",
|
||||
@@ -34,8 +33,9 @@ func (m *AuthMiddleware) Handle() gin.HandlerFunc {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
token := strings.TrimPrefix(raw, "Bearer ")
|
||||
|
||||
claims, err := m.authSvc.ValidateAccessToken(c.Request.Context(), token)
|
||||
claims, err := m.authSvc.ParseAccessToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, errorBody{
|
||||
Code: domain.ErrUnauthorized.Code(),
|
||||
@@ -50,67 +50,3 @@ func (m *AuthMiddleware) Handle() gin.HandlerFunc {
|
||||
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 ""
|
||||
}
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,20 @@
|
||||
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) {
|
||||
) *gin.Engine {
|
||||
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)
|
||||
}
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
|
||||
// Health check — no auth required.
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
@@ -60,10 +28,8 @@ func NewRouter(
|
||||
// -------------------------------------------------------------------------
|
||||
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)
|
||||
authGroup.POST("/login", authHandler.Login)
|
||||
authGroup.POST("/refresh", authHandler.Refresh)
|
||||
|
||||
protected := authGroup.Group("", auth.Handle())
|
||||
{
|
||||
@@ -81,14 +47,9 @@ func NewRouter(
|
||||
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)
|
||||
// Bulk + import routes registered before /:id to prevent param collision.
|
||||
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)
|
||||
|
||||
@@ -97,12 +58,10 @@ func NewRouter(
|
||||
files.PATCH("/:id", fileHandler.UpdateMeta)
|
||||
files.DELETE("/:id", fileHandler.SoftDelete)
|
||||
|
||||
files.GET("/:id/content", fileHandler.GetContent)
|
||||
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)
|
||||
|
||||
@@ -113,15 +72,6 @@ func NewRouter(
|
||||
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)
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -138,83 +88,8 @@ func NewRouter(
|
||||
|
||||
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
|
||||
return r
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -354,45 +354,6 @@ func (h *TagHandler) CreateRule(c *gin.Context) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -430,11 +391,6 @@ func (h *TagHandler) FileListTags(c *gin.Context) {
|
||||
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)
|
||||
@@ -470,11 +426,6 @@ func (h *TagHandler) FileSetTags(c *gin.Context) {
|
||||
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)
|
||||
@@ -501,11 +452,6 @@ func (h *TagHandler) FileAddTag(c *gin.Context) {
|
||||
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)
|
||||
@@ -532,11 +478,6 @@ func (h *TagHandler) FileRemoveTag(c *gin.Context) {
|
||||
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
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// 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 (0–64) 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))
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,6 @@ type OffsetParams struct {
|
||||
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.
|
||||
@@ -48,19 +41,6 @@ type FileRepo interface {
|
||||
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).
|
||||
@@ -72,28 +52,6 @@ type FileRepo interface {
|
||||
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.
|
||||
@@ -125,14 +83,8 @@ 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
|
||||
// SetActive toggles a rule's is_active flag.
|
||||
SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active bool) error
|
||||
Delete(ctx context.Context, whenTagID, thenTagID uuid.UUID) error
|
||||
}
|
||||
|
||||
@@ -161,9 +113,6 @@ type PoolRepo interface {
|
||||
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.
|
||||
@@ -181,9 +130,6 @@ type UserRepo interface {
|
||||
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)
|
||||
|
||||
@@ -21,10 +21,6 @@ type FileStorage interface {
|
||||
// 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)
|
||||
|
||||
@@ -13,31 +13,10 @@ import (
|
||||
// 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,
|
||||
}
|
||||
func NewACLService(aclRepo port.ACLRepo) *ACLService {
|
||||
return &ACLService{aclRepo: aclRepo}
|
||||
}
|
||||
|
||||
// CanView returns true if the user may view the object.
|
||||
@@ -91,86 +70,12 @@ func (s *ACLService) CanEdit(
|
||||
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
|
||||
}
|
||||
// GetPermissions returns all explicit ACL entries for an object.
|
||||
func (s *ACLService) GetPermissions(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error) {
|
||||
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 {
|
||||
func (s *ACLService) SetPermissions(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
@@ -15,32 +14,12 @@ import (
|
||||
"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.
|
||||
@@ -57,7 +36,6 @@ type AuthService struct {
|
||||
secret []byte
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
contentTTL time.Duration
|
||||
}
|
||||
|
||||
// NewAuthService creates an AuthService.
|
||||
@@ -67,7 +45,6 @@ func NewAuthService(
|
||||
jwtSecret string,
|
||||
accessTTL time.Duration,
|
||||
refreshTTL time.Duration,
|
||||
contentTTL time.Duration,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
users: users,
|
||||
@@ -75,7 +52,6 @@ func NewAuthService(
|
||||
secret: []byte(jwtSecret),
|
||||
accessTTL: accessTTL,
|
||||
refreshTTL: refreshTTL,
|
||||
contentTTL: contentTTL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,16 +59,8 @@ func NewAuthService(
|
||||
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 ErrUnauthorized regardless of whether the user exists,
|
||||
// to avoid username enumeration.
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
@@ -100,7 +68,48 @@ func (s *AuthService) Login(ctx context.Context, name, password, userAgent strin
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
|
||||
return s.issuePair(ctx, user, userAgent)
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if s.refreshTTL > 0 {
|
||||
t := time.Now().Add(s.refreshTTL)
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
// Issue the refresh token first so we can store its hash.
|
||||
refreshToken, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue access token: %w", err)
|
||||
}
|
||||
|
||||
// Re-issue the refresh token with the real session ID now that we have it.
|
||||
refreshToken, err = s.issueToken(user.ID, user.IsAdmin, session.ID, s.refreshTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int(s.accessTTL.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Logout deactivates the session identified by sessionID.
|
||||
@@ -115,7 +124,7 @@ func (s *AuthService) Logout(ctx context.Context, sessionID int) error {
|
||||
// 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 {
|
||||
if err != nil {
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
@@ -143,30 +152,19 @@ func (s *AuthService) Refresh(ctx context.Context, refreshToken, userAgent strin
|
||||
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)
|
||||
newRefresh, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
||||
}
|
||||
|
||||
session, err := s.sessions.Create(ctx, &domain.Session{
|
||||
TokenHash: hashToken(refreshToken),
|
||||
newSession, err := s.sessions.Create(ctx, &domain.Session{
|
||||
TokenHash: hashToken(newRefresh),
|
||||
UserID: user.ID,
|
||||
UserAgent: userAgent,
|
||||
ExpiresAt: expiresAt,
|
||||
@@ -175,14 +173,19 @@ func (s *AuthService) issuePair(ctx context.Context, user *domain.User, userAgen
|
||||
return nil, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
|
||||
accessToken, err := s.issueToken(user.ID, user.IsAdmin, session.ID, s.accessTTL, tokenTypeAccess)
|
||||
accessToken, err := s.issueToken(user.ID, user.IsAdmin, newSession.ID, s.accessTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue access token: %w", err)
|
||||
}
|
||||
|
||||
newRefresh, err = s.issueToken(user.ID, user.IsAdmin, newSession.ID, s.refreshTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
RefreshToken: newRefresh,
|
||||
ExpiresIn: int(s.accessTTL.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
@@ -224,96 +227,27 @@ func (s *AuthService) TerminateSession(ctx context.Context, callerID int16, isAd
|
||||
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) {
|
||||
// ParseAccessToken parses and validates an access token, returning its claims.
|
||||
func (s *AuthService) ParseAccessToken(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
|
||||
}
|
||||
// issueToken signs a JWT with the given parameters.
|
||||
func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl time.Duration) (string, error) {
|
||||
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 {
|
||||
@@ -346,12 +280,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,13 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
|
||||
"tanabata/backend/internal/domain"
|
||||
"tanabata/backend/internal/imagehash"
|
||||
"tanabata/backend/internal/port"
|
||||
)
|
||||
|
||||
@@ -34,10 +32,6 @@ type UploadParams struct {
|
||||
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
|
||||
}
|
||||
@@ -72,24 +66,6 @@ type ImportResult struct {
|
||||
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
|
||||
@@ -131,7 +107,7 @@ func NewFileService(
|
||||
|
||||
// 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.
|
||||
// If ContentDatetime is nil and EXIF DateTimeOriginal is present, it is used.
|
||||
func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, error) {
|
||||
userID, _, _ := domain.UserFromContext(ctx)
|
||||
|
||||
@@ -148,32 +124,15 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File,
|
||||
}
|
||||
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)
|
||||
// Extract EXIF metadata (best-effort; non-image files will error silently).
|
||||
exifData, exifDatetime := extractEXIFWithDatetime(data)
|
||||
|
||||
// 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.
|
||||
// Resolve content datetime: explicit > EXIF > zero value.
|
||||
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.
|
||||
@@ -199,7 +158,6 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File,
|
||||
Notes: p.Notes,
|
||||
Metadata: p.Metadata,
|
||||
EXIF: exifData,
|
||||
PHash: phash,
|
||||
CreatorID: userID,
|
||||
IsPublic: p.IsPublic,
|
||||
}
|
||||
@@ -249,26 +207,6 @@ func (s *FileService) Get(ctx context.Context, id uuid.UUID) (*domain.File, erro
|
||||
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)
|
||||
@@ -406,7 +344,6 @@ func (s *FileService) PermanentDelete(ctx context.Context, id uuid.UUID) error {
|
||||
return err
|
||||
}
|
||||
_ = s.storage.Delete(ctx, id)
|
||||
_ = s.storage.InvalidateCache(ctx, id)
|
||||
|
||||
objType := fileObjectType
|
||||
_ = s.audit.Log(ctx, "file_permanent_delete", &objType, &id, nil)
|
||||
@@ -440,17 +377,11 @@ func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams)
|
||||
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)
|
||||
exifData, _ := extractEXIFWithDatetime(data)
|
||||
|
||||
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,
|
||||
@@ -466,67 +397,14 @@ func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams)
|
||||
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).
|
||||
// List delegates to FileRepo with the given params.
|
||||
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
|
||||
return s.files.List(ctx, params)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -583,189 +461,87 @@ func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error
|
||||
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 == "" {
|
||||
func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, error) {
|
||||
dir := path
|
||||
if dir == "" {
|
||||
dir = s.importPath
|
||||
}
|
||||
if dir == "" {
|
||||
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)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
result.Skipped++
|
||||
file("skipped", "directory")
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(dir, name)
|
||||
fullPath := filepath.Join(dir, entry.Name())
|
||||
|
||||
mt, err := mimetype.DetectFile(fullPath)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("MIME detection failed: %s", err))
|
||||
result.Errors = append(result.Errors, ImportFileError{
|
||||
Filename: entry.Name(),
|
||||
Reason: 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 idx := len(mimeStr); idx > 0 {
|
||||
for i, c := range mimeStr {
|
||||
if c == ';' {
|
||||
mimeStr = mimeStr[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
result.Errors = append(result.Errors, ImportFileError{
|
||||
Filename: entry.Name(),
|
||||
Reason: 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
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
_, uploadErr := s.Upload(ctx, UploadParams{
|
||||
Reader: f,
|
||||
MIMEType: mimeStr,
|
||||
OriginalName: &name,
|
||||
ContentDatetimeFallback: mtime,
|
||||
})
|
||||
f.Close()
|
||||
|
||||
if uploadErr != nil {
|
||||
fail(uploadErr.Error())
|
||||
result.Errors = append(result.Errors, ImportFileError{
|
||||
Filename: entry.Name(),
|
||||
Reason: 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
|
||||
}
|
||||
@@ -774,24 +550,20 @@ func (s *FileService) Import(ctx context.Context, path string, onProgress func(I
|
||||
// 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)
|
||||
// extractEXIFWithDatetime parses EXIF from raw bytes, 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 "", domain.ErrValidation
|
||||
return nil, nil
|
||||
}
|
||||
absTarget, err := filepath.Abs(target)
|
||||
b, err := x.MarshalJSON()
|
||||
if err != nil {
|
||||
return "", domain.ErrValidation
|
||||
return nil, nil
|
||||
}
|
||||
rel, err := filepath.Rel(absBase, absTarget)
|
||||
if err != nil {
|
||||
return "", domain.ErrValidation
|
||||
var dt *time.Time
|
||||
if t, err := x.DateTime(); err == nil {
|
||||
dt = &t
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", domain.ErrForbidden
|
||||
}
|
||||
return absTarget, nil
|
||||
return json.RawMessage(b), dt
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
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 pool–file 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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pool–file 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
|
||||
}
|
||||
@@ -54,27 +54,14 @@ func NewTagService(
|
||||
// Tag CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// List returns a paginated, optionally filtered list of tags the caller may see.
|
||||
// List returns a paginated, optionally filtered list of tags.
|
||||
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.
|
||||
// Get returns a tag by ID.
|
||||
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
|
||||
return s.tags.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Create inserts a new tag record.
|
||||
@@ -192,51 +179,16 @@ func (s *TagService) ListRules(ctx context.Context, tagID uuid.UUID) ([]domain.T
|
||||
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{
|
||||
// CreateRule adds a tag rule. If applyToExisting is true, the then_tag is
|
||||
// retroactively applied to all files that already carry the when_tag.
|
||||
// Retroactive application requires a FileRepo; it is deferred until wired
|
||||
// in a future iteration (see port.FileRepo.ListByTag).
|
||||
func (s *TagService) CreateRule(ctx context.Context, whenTagID, thenTagID uuid.UUID, isActive, _ bool) (*domain.TagRule, error) {
|
||||
return 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.
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -5,20 +5,16 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
_ "golang.org/x/image/webp" // register WebP decoder
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/gif" // register GIF decoder
|
||||
"image/jpeg"
|
||||
_ "image/gif" // register GIF decoder
|
||||
_ "image/png" // register PNG decoder
|
||||
_ "golang.org/x/image/webp" // register WebP decoder
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/google/uuid"
|
||||
@@ -41,36 +37,20 @@ type DiskStorage struct {
|
||||
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,
|
||||
@@ -78,8 +58,6 @@ func NewDiskStorage(
|
||||
thumbHeight: thumbH,
|
||||
previewWidth: prevW,
|
||||
previewHeight: prevH,
|
||||
maxPixels: maxPixels,
|
||||
genSem: make(chan struct{}, concurrency),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -130,66 +108,32 @@ func (s *DiskStorage) Delete(_ context.Context, id uuid.UUID) error {
|
||||
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.
|
||||
// Thumbnail returns a JPEG that fits within the configured max width×height
|
||||
// (never upscaled, never cropped). 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.
|
||||
// Preview returns a JPEG that fits within the configured max width×height
|
||||
// (never upscaled, never cropped). 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.
|
||||
// serveGenerated is the shared implementation for Thumbnail and Preview.
|
||||
// imaging.Thumbnail fits the source within maxW×maxH without upscaling or cropping.
|
||||
//
|
||||
// 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.).
|
||||
// 2. Decode as still image (JPEG/PNG/GIF via imaging).
|
||||
// 3. Extract a frame with ffmpeg (video files).
|
||||
// 4. 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 {
|
||||
@@ -205,40 +149,14 @@ func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePat
|
||||
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.
|
||||
// 1. Try still-image decode (JPEG/PNG/GIF).
|
||||
// 2. Try video frame extraction via ffmpeg.
|
||||
// 3. Fall back to 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)
|
||||
if decoded, err := imaging.Open(srcPath, imaging.AutoOrientation(true)); err == nil {
|
||||
img = imaging.Thumbnail(decoded, maxW, maxH, imaging.Lanczos)
|
||||
} else if frame, err := extractVideoFrame(ctx, srcPath); err == nil {
|
||||
img = imaging.Thumbnail(frame, maxW, maxH, imaging.Lanczos)
|
||||
} else {
|
||||
img = placeholder(maxW, maxH)
|
||||
}
|
||||
@@ -288,108 +206,15 @@ func writeCache(cachePath string, img image.Image) (io.ReadCloser, error) {
|
||||
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()
|
||||
|
||||
// extractVideoFrame uses ffmpeg to extract a single frame from a video file.
|
||||
// It seeks 1 second in (keyframe-accurate fast seek) and pipes the frame out
|
||||
// as PNG. If the video is shorter than 1 s the seek is silently ignored by
|
||||
// ffmpeg and the first available frame is returned instead.
|
||||
// Returns an error if ffmpeg is not installed or produces no output.
|
||||
func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error) {
|
||||
var out bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg",
|
||||
"-ss", strconv.FormatFloat(atSec, 'f', 3, 64), // fast input seek; ignored gracefully past end
|
||||
"-ss", "1", // fast input seek; ignored gracefully on short files
|
||||
"-i", srcPath,
|
||||
"-vframes", "1",
|
||||
"-f", "image2",
|
||||
@@ -405,29 +230,6 @@ func extractVideoFrameAt(ctx context.Context, srcPath string, atSec float64) (im
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -50,13 +50,12 @@ CREATE TABLE data.files (
|
||||
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)
|
||||
exif jsonb NOT NULL DEFAULT '{}'::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
|
||||
is_deleted boolean NOT NULL DEFAULT false -- soft delete (trash)
|
||||
);
|
||||
|
||||
CREATE TABLE data.file_tag (
|
||||
@@ -92,31 +91,6 @@ CREATE TABLE data.file_pool (
|
||||
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';
|
||||
@@ -124,8 +98,6 @@ COMMENT ON TABLE data.files IS 'Managed files; actual content stored on di
|
||||
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';
|
||||
@@ -137,8 +109,6 @@ COMMENT ON COLUMN data.file_pool.position IS 'Manual ordering within pool; u
|
||||
|
||||
-- +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;
|
||||
|
||||
@@ -20,18 +20,11 @@ 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);
|
||||
|
||||
@@ -76,14 +69,11 @@ 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;
|
||||
|
||||
@@ -20,8 +20,7 @@ INSERT INTO activity.action_types (name) VALUES
|
||||
('user_login'), ('user_logout'),
|
||||
-- Files
|
||||
('file_create'), ('file_edit'), ('file_delete'), ('file_restore'),
|
||||
('file_permanent_delete'), ('file_replace'), ('file_review'),
|
||||
('file_merge'), ('duplicate_dismiss'),
|
||||
('file_permanent_delete'), ('file_replace'),
|
||||
-- Tags
|
||||
('tag_create'), ('tag_edit'), ('tag_delete'),
|
||||
-- Categories
|
||||
@@ -30,7 +29,7 @@ INSERT INTO activity.action_types (name) VALUES
|
||||
('pool_create'), ('pool_edit'), ('pool_delete'),
|
||||
-- Relations
|
||||
('file_tag_add'), ('file_tag_remove'),
|
||||
('file_pool_add'), ('file_pool_remove'), ('file_pool_reorder'),
|
||||
('file_pool_add'), ('file_pool_remove'),
|
||||
-- ACL
|
||||
('acl_change'),
|
||||
-- Admin
|
||||
@@ -39,12 +38,12 @@ INSERT INTO activity.action_types (name) VALUES
|
||||
-- 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.
|
||||
INSERT INTO core.users (name, password, is_admin, can_create) VALUES
|
||||
('admin', '$2a$10$zk.VTFjRRxbkTE7cKfc7KOWeZfByk1VEkbkgZMJggI1fFf.yDEHZy', true, true);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DELETE FROM core.users WHERE name = 'admin';
|
||||
DELETE FROM activity.action_types;
|
||||
DELETE FROM core.object_types;
|
||||
DELETE FROM core.mime_types;
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
# =============================================================================
|
||||
# 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:
|
||||
@@ -1,182 +0,0 @@
|
||||
# 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.
|
||||
@@ -10,46 +10,6 @@
|
||||
- **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
|
||||
|
||||
```
|
||||
@@ -89,7 +49,8 @@ frontend/
|
||||
├── src/
|
||||
│ ├── app.html # Shell HTML (PWA meta, font preload)
|
||||
│ ├── app.css # Tailwind directives + CSS custom properties
|
||||
│ │ # (no hooks.* — see "SPA mode" above)
|
||||
│ ├── hooks.server.ts # Server hooks (not used in SPA mode)
|
||||
│ ├── hooks.client.ts # Client hooks (global error handling)
|
||||
│ │
|
||||
│ ├── lib/ # Shared code ($lib/ alias)
|
||||
│ │ │
|
||||
|
||||
@@ -297,7 +297,7 @@ stored as hash in `activity.sessions.token_hash`.
|
||||
|
||||
```env
|
||||
# Server
|
||||
LISTEN_ADDR=:42776
|
||||
LISTEN_ADDR=:8080
|
||||
JWT_SECRET=<random-32-bytes>
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=720h
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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"
|
||||
)
|
||||
|
||||
var connPool *pgxpool.Pool
|
||||
|
||||
func InitDB(connString string) error {
|
||||
poolConfig, err := pgxpool.ParseConfig(connString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while parsing connection string: %w", err)
|
||||
}
|
||||
|
||||
poolConfig.MaxConns = 100
|
||||
poolConfig.MinConns = 0
|
||||
poolConfig.MaxConnLifetime = time.Hour
|
||||
poolConfig.HealthCheckPeriod = 30 * time.Second
|
||||
|
||||
connPool, err = pgxpool.NewWithConfig(context.Background(), poolConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while initializing DB connections pool: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func transaction(handler func(context.Context, pgx.Tx) (statusCode int, err error)) (statusCode int, err error) {
|
||||
ctx := context.Background()
|
||||
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 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
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Convert "filter" URL param to SQL "WHERE" condition
|
||||
func filterToSQL(filter string) (sql string, statusCode int, err error) {
|
||||
// filterTokens := strings.Split(string(filter), ";")
|
||||
sql = "(true)"
|
||||
return
|
||||
}
|
||||
|
||||
// Convert "sort" URL param to SQL "ORDER BY"
|
||||
func sortToSQL(sort string) (sql string, statusCode int, err error) {
|
||||
if sort == "" {
|
||||
return
|
||||
}
|
||||
sortOptions := strings.Split(sort, ",")
|
||||
sql = " ORDER BY "
|
||||
for i, sortOption := range sortOptions {
|
||||
sortOrder := sortOption[:1]
|
||||
sortColumn := sortOption[1:]
|
||||
// parse sorting order marker
|
||||
switch sortOrder {
|
||||
case "+":
|
||||
sortOrder = "ASC"
|
||||
case "-":
|
||||
sortOrder = "DESC"
|
||||
default:
|
||||
err = fmt.Errorf("invalid sorting order mark: %q", sortOrder)
|
||||
statusCode = http.StatusBadRequest
|
||||
return
|
||||
}
|
||||
// validate sorting column
|
||||
var n int
|
||||
n, err = strconv.Atoi(sortColumn)
|
||||
if err != nil || n < 0 {
|
||||
err = fmt.Errorf("invalid sorting column: %q", sortColumn)
|
||||
statusCode = http.StatusBadRequest
|
||||
return
|
||||
}
|
||||
// add sorting option to query
|
||||
if i > 0 {
|
||||
sql += ","
|
||||
}
|
||||
sql += fmt.Sprintf("%s %s NULLS LAST", sortColumn, sortOrder)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Convert "filter" URL param to SQL "WHERE" condition
|
||||
func filterToSQL(filter string) (sql string, statusCode int, err error) {
|
||||
// filterTokens := strings.Split(string(filter), ";")
|
||||
sql = "(true)"
|
||||
return
|
||||
}
|
||||
|
||||
// Convert "sort" URL param to SQL "ORDER BY"
|
||||
func sortToSQL(sort string) (sql string, statusCode int, err error) {
|
||||
if sort == "" {
|
||||
return
|
||||
}
|
||||
sortOptions := strings.Split(sort, ",")
|
||||
sql = " ORDER BY "
|
||||
for i, sortOption := range sortOptions {
|
||||
sortOrder := sortOption[:1]
|
||||
sortColumn := sortOption[1:]
|
||||
// parse sorting order marker
|
||||
switch sortOrder {
|
||||
case "+":
|
||||
sortOrder = "ASC"
|
||||
case "-":
|
||||
sortOrder = "DESC"
|
||||
default:
|
||||
err = fmt.Errorf("invalid sorting order mark: %q", sortOrder)
|
||||
statusCode = http.StatusBadRequest
|
||||
return
|
||||
}
|
||||
// validate sorting column
|
||||
var n int
|
||||
n, err = strconv.Atoi(sortColumn)
|
||||
if err != nil || n < 0 {
|
||||
err = fmt.Errorf("invalid sorting column: %q", sortColumn)
|
||||
statusCode = http.StatusBadRequest
|
||||
return
|
||||
}
|
||||
// add sorting option to query
|
||||
if i > 0 {
|
||||
sql += ","
|
||||
}
|
||||
sql += fmt.Sprintf("%s %s NULLS LAST", sortColumn, sortOrder)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"tanabata/internal/domain"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
FileRepository
|
||||
Close()
|
||||
}
|
||||
|
||||
type FileRepository interface {
|
||||
GetSlice(user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], statusCode int, err error)
|
||||
Get(user_id int, file_id string) (file domain.FileFull, statusCode int, err error)
|
||||
Add(user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, statusCode int, err error)
|
||||
Update(user_id int, file_id string, updates map[string]interface{}) (statusCode int, err error)
|
||||
Delete(user_id int, file_id string) (statusCode int, err error)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package models
|
||||
|
||||
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"`
|
||||
}
|
||||
)
|
||||
|
||||
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,36 @@
|
||||
body {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.decoration.left {
|
||||
left: 0;
|
||||
width: 20vw;
|
||||
}
|
||||
|
||||
.decoration.right {
|
||||
right: 0;
|
||||
width: 20vw;
|
||||
}
|
||||
|
||||
#auth {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#auth h1 {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
#auth .form-control {
|
||||
margin: 14px 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
#login {
|
||||
margin-top: 20px;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #312F45;
|
||||
color: #f0f0f0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
font-family: Epilogue;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #9592B5;
|
||||
border-color: #454261;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #7D7AA4;
|
||||
border-color: #454261;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #DB6060;
|
||||
border-color: #851E1E;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #D64848;
|
||||
border-color: #851E1E;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
header, footer {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 20px;
|
||||
box-shadow: 0 5px 5px #0004;
|
||||
}
|
||||
|
||||
.icon-header {
|
||||
height: .8em;
|
||||
}
|
||||
|
||||
#select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sorting {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#sorting {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
color: #9999AD;
|
||||
}
|
||||
|
||||
#icon-expand {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
#sorting-options {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 114%;
|
||||
padding: 4px 10px;
|
||||
box-sizing: border-box;
|
||||
background-color: #111118;
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
box-shadow: 0 0 10px black;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.sorting-option {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sorting-option input[type="radio"] {
|
||||
float: unset;
|
||||
margin-left: 1.8em;
|
||||
}
|
||||
|
||||
.filtering-wrapper {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.filtering-block {
|
||||
position: absolute;
|
||||
top: 128px;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
padding: 14px;
|
||||
background-color: #111118;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px 4px #0004;
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
main {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
main:after {
|
||||
content: "";
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.item-preview {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-selected:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
background-image: url("/static/images/icon-select.svg");
|
||||
background-size: contain;
|
||||
background-position: right;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
margin: 1px 0;
|
||||
padding: 0;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
max-width: calc(33vw - 7px);
|
||||
max-height: calc(33vw - 7px);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.file-preview .overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: #0002;
|
||||
}
|
||||
|
||||
.file-preview:hover .overlay {
|
||||
background-color: #0004;
|
||||
}
|
||||
|
||||
.tag-preview, .filtering-token {
|
||||
margin: 5px 5px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
background-color: #444455;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-preview {
|
||||
margin: 5px 5px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
background-color: #444455;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.file .preview-img {
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.selection-manager {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 65px;
|
||||
box-sizing: border-box;
|
||||
max-height: 40vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px 10px;
|
||||
background-color: #181721;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 5px #0008;
|
||||
}
|
||||
|
||||
.selection-manager hr {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.selection-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.selection-header > * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#selection-edit-tags {
|
||||
color: #4DC7ED;
|
||||
}
|
||||
|
||||
#selection-add-to-pool {
|
||||
color: #F5E872;
|
||||
}
|
||||
|
||||
#selection-delete {
|
||||
color: #DB6060;
|
||||
}
|
||||
|
||||
.selection-tags {
|
||||
max-height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tags-container, .filtering-operators, .filtering-tokens {
|
||||
padding: 5px;
|
||||
background-color: #212529;
|
||||
border: 1px solid #495057;
|
||||
border-radius: .375rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filtering-operators {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tags-container, .filtering-tokens {
|
||||
margin: 15px 0;
|
||||
height: 200px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.tags-container:after, .filtering-tokens:after {
|
||||
content: "";
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.tags-container-selected {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
#files-filter {
|
||||
margin-bottom: 0;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.viewer-wrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #000a;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* overflow-y: scroll;*/
|
||||
}
|
||||
|
||||
.viewer-nav {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viewer-nav:hover {
|
||||
background-color: #b4adff40;
|
||||
}
|
||||
|
||||
.viewer-nav-prev {
|
||||
left: 0;
|
||||
right: 80vw;
|
||||
}
|
||||
|
||||
.viewer-nav-next {
|
||||
left: 80vw;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.viewer-nav-close {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: unset;
|
||||
height: 15vh;
|
||||
}
|
||||
|
||||
.viewer-nav-icon {
|
||||
width: 20px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.viewer-nav-close > .viewer-nav-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
#viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sessions-wrapper {
|
||||
padding: 14px;
|
||||
background-color: #111118;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.btn-terminate {
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #0007;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
width: 18vw;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.nav.curr, .nav:hover {
|
||||
background-color: #343249;
|
||||
}
|
||||
|
||||
.navicon {
|
||||
display: block;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
#loader {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #000a;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loader-wrapper {
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.loader-img {
|
||||
max-width: 20vw;
|
||||
max-height: 20vh;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |