Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a864ca4f7b | |||
| 5bb53d7f9d | |||
| 48e901cac1 | |||
| 4d11beb296 | |||
| 57192a49f9 | |||
| 9937984a5a | |||
| 6fba04cd00 | |||
| 35b1b2e6d5 | |||
| 98de298e5b | |||
| b470782e97 | |||
| 5a05bb86e1 | |||
| 99668ec0d8 |
+19
-1
@@ -14,7 +14,10 @@
|
|||||||
# DATABASE_URL at host.docker.internal (see the Database section below).
|
# DATABASE_URL at host.docker.internal (see the Database section below).
|
||||||
COMPOSE_PROFILES=with-db
|
COMPOSE_PROFILES=with-db
|
||||||
|
|
||||||
# Host port the app is published on. The container always listens on 42776.
|
# 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
|
APP_PORT=42776
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -51,6 +54,21 @@ JWT_SECRET=change-me-to-a-random-32-byte-secret
|
|||||||
JWT_ACCESS_TTL=15m
|
JWT_ACCESS_TTL=15m
|
||||||
JWT_REFRESH_TTL=720h
|
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.
|
# Initial administrator, created on first startup if it does not yet exist.
|
||||||
# Changing the password later (via the API) is preserved across restarts.
|
# Changing the password later (via the API) is preserved across restarts.
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Tanabata File Manager
|
||||||
|
|
||||||
|
A multi-user, tag-based web file manager for images and video. Go + Gin backend
|
||||||
|
(Clean Architecture, pgx, goose migrations), SvelteKit SPA frontend, PostgreSQL,
|
||||||
|
JWT auth — shipped as a single Docker image that serves both the API and the
|
||||||
|
built SPA on one port.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`openapi.yaml`](openapi.yaml) — full REST API specification
|
||||||
|
- [`docs/DEPLOY.md`](docs/DEPLOY.md) — production deploy (Gitea Actions → host)
|
||||||
|
- [`docs/GO_PROJECT_STRUCTURE.md`](docs/GO_PROJECT_STRUCTURE.md) — backend architecture
|
||||||
|
- [`docs/FRONTEND_STRUCTURE.md`](docs/FRONTEND_STRUCTURE.md) — frontend architecture
|
||||||
|
- [`.env.example`](.env.example) — every configuration variable, documented
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # then edit the secrets (JWT_SECRET, ADMIN_PASSWORD, …)
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
By default this runs the app plus a bundled PostgreSQL container
|
||||||
|
(`COMPOSE_PROFILES=with-db`). To point at a Postgres already on the host, set
|
||||||
|
`COMPOSE_PROFILES=` empty and aim `DATABASE_URL` at `host.docker.internal`. See
|
||||||
|
[`.env.example`](.env.example) for the full matrix.
|
||||||
|
|
||||||
|
The app is published on **127.0.0.1** only and expects a reverse proxy in front
|
||||||
|
(see below). The default port is **42776** — the sum of the Unicode code points
|
||||||
|
of 七夕.
|
||||||
|
|
||||||
|
## Reverse proxy (nginx)
|
||||||
|
|
||||||
|
The container publishes its port on loopback (`127.0.0.1:${APP_PORT}:42776` in
|
||||||
|
[`docker-compose.yml`](docker-compose.yml)), so a reverse proxy on the host
|
||||||
|
terminates TLS and forwards to it. Three settings matter for this app:
|
||||||
|
|
||||||
|
1. **`client_max_body_size`** — uploads go up to `MAX_UPLOAD_BYTES` (500 MiB by
|
||||||
|
default). nginx caps request bodies at **1 MiB** out of the box, so without
|
||||||
|
this every large upload fails with `413`.
|
||||||
|
2. **Forwarded headers** — the app trusts `X-Forwarded-For` only from the hops in
|
||||||
|
`TRUSTED_PROXIES` (default: loopback + Docker bridge ranges) and keys its
|
||||||
|
login/refresh rate limiter on the resulting client IP. If the proxy doesn't
|
||||||
|
send the header, every request looks like it comes from the proxy and shares
|
||||||
|
one rate-limit bucket.
|
||||||
|
3. **Streaming for big media** — turning request/response buffering off lets
|
||||||
|
large uploads stream straight to the app and lets video range-seeks work
|
||||||
|
without nginx spooling whole files to disk first.
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name tanabata.example.com;
|
||||||
|
|
||||||
|
# ssl_certificate / ssl_certificate_key ... (e.g. from certbot)
|
||||||
|
|
||||||
|
# Match MAX_UPLOAD_BYTES (500 MiB default); nginx defaults to 1m → 413.
|
||||||
|
client_max_body_size 512m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:42776; # APP_PORT
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Stream large uploads/downloads instead of buffering to disk; keeps
|
||||||
|
# video range-seek responsive. Scope these to file/preview locations
|
||||||
|
# instead if you'd rather keep buffering for small JSON responses.
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you run the app **without** a proxy and want it reachable on the LAN, drop the
|
||||||
|
`127.0.0.1:` prefix from the `ports` line in
|
||||||
|
[`docker-compose.yml`](docker-compose.yml) and adjust `TRUSTED_PROXIES`
|
||||||
|
accordingly.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
go run ./cmd/server # dev server
|
||||||
|
go test ./... # all tests
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend
|
||||||
|
npm run dev # Vite dev server
|
||||||
|
npm run build # production build
|
||||||
|
npm run generate:types # regenerate API types from openapi.yaml
|
||||||
|
```
|
||||||
@@ -79,6 +79,7 @@ func main() {
|
|||||||
cfg.JWTSecret,
|
cfg.JWTSecret,
|
||||||
cfg.JWTAccessTTL,
|
cfg.JWTAccessTTL,
|
||||||
cfg.JWTRefreshTTL,
|
cfg.JWTRefreshTTL,
|
||||||
|
cfg.ContentTokenTTL,
|
||||||
)
|
)
|
||||||
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
||||||
auditSvc := service.NewAuditService(auditRepo)
|
auditSvc := service.NewAuditService(auditRepo)
|
||||||
@@ -106,7 +107,7 @@ func main() {
|
|||||||
// Handlers
|
// Handlers
|
||||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
||||||
authHandler := handler.NewAuthHandler(authSvc)
|
authHandler := handler.NewAuthHandler(authSvc)
|
||||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, cfg.MaxUploadBytes)
|
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, authSvc, cfg.MaxUploadBytes)
|
||||||
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
||||||
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
||||||
poolHandler := handler.NewPoolHandler(poolSvc)
|
poolHandler := handler.NewPoolHandler(poolSvc)
|
||||||
@@ -114,12 +115,17 @@ func main() {
|
|||||||
aclHandler := handler.NewACLHandler(aclSvc)
|
aclHandler := handler.NewACLHandler(aclSvc)
|
||||||
auditHandler := handler.NewAuditHandler(auditSvc)
|
auditHandler := handler.NewAuditHandler(auditSvc)
|
||||||
|
|
||||||
r := handler.NewRouter(
|
r, err := handler.NewRouter(
|
||||||
authMiddleware, authHandler,
|
authMiddleware, authHandler,
|
||||||
fileHandler, tagHandler, categoryHandler, poolHandler,
|
fileHandler, tagHandler, categoryHandler, poolHandler,
|
||||||
userHandler, aclHandler, auditHandler,
|
userHandler, aclHandler, auditHandler,
|
||||||
cfg.StaticDir,
|
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
|
// ReadHeaderTimeout bounds slow-header (Slowloris) attacks; body read/write
|
||||||
// are left unbounded so large file uploads and downloads can stream.
|
// are left unbounded so large file uploads and downloads can stream.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -17,6 +18,20 @@ type Config struct {
|
|||||||
JWTSecret string
|
JWTSecret string
|
||||||
JWTAccessTTL time.Duration
|
JWTAccessTTL time.Duration
|
||||||
JWTRefreshTTL 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)
|
// Initial admin bootstrap (applied on startup if the user does not exist)
|
||||||
AdminUsername string
|
AdminUsername string
|
||||||
@@ -77,6 +92,10 @@ func Load() (*Config, error) {
|
|||||||
return def
|
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 {
|
parseDuration := func(key, def string) time.Duration {
|
||||||
raw := defaultStr(key, def)
|
raw := defaultStr(key, def)
|
||||||
d, err := time.ParseDuration(raw)
|
d, err := time.ParseDuration(raw)
|
||||||
@@ -84,6 +103,10 @@ func Load() (*Config, error) {
|
|||||||
errs = append(errs, fmt.Errorf("%s: invalid duration %q: %w", key, raw, err))
|
errs = append(errs, fmt.Errorf("%s: invalid duration %q: %w", key, raw, err))
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
if d <= 0 {
|
||||||
|
errs = append(errs, fmt.Errorf("%s must be positive, got %q", key, raw))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +123,18 @@ func Load() (*Config, error) {
|
|||||||
return n
|
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 {
|
parseInt64 := func(key string, def int64) int64 {
|
||||||
raw := os.Getenv(key)
|
raw := os.Getenv(key)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -119,6 +154,10 @@ func Load() (*Config, error) {
|
|||||||
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
|
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
|
||||||
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
|
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"),
|
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
|
||||||
AdminPassword: requireStr("ADMIN_PASSWORD"),
|
AdminPassword: requireStr("ADMIN_PASSWORD"),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setValidEnv sets every required variable to a valid dummy value, so a test can
|
||||||
|
// then override one var to exercise a single validation path.
|
||||||
|
func setValidEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
t.Setenv("ADMIN_PASSWORD", "test-password")
|
||||||
|
t.Setenv("DATABASE_URL", "postgres://u:p@localhost:5432/db?sslmode=disable")
|
||||||
|
t.Setenv("FILES_PATH", "/tmp/files")
|
||||||
|
t.Setenv("THUMBS_CACHE_PATH", "/tmp/thumbs")
|
||||||
|
t.Setenv("IMPORT_PATH", "/tmp/import")
|
||||||
|
// Pin the TTLs to valid values so an ambient env var can't perturb the case
|
||||||
|
// under test; individual tests override the one they exercise.
|
||||||
|
t.Setenv("JWT_ACCESS_TTL", "15m")
|
||||||
|
t.Setenv("JWT_REFRESH_TTL", "720h")
|
||||||
|
t.Setenv("CONTENT_TOKEN_TTL", "6h")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadValid(t *testing.T) {
|
||||||
|
setValidEnv(t)
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.JWTAccessTTL <= 0 || cfg.JWTRefreshTTL <= 0 || cfg.ContentTokenTTL <= 0 {
|
||||||
|
t.Fatalf("TTLs should be positive: access=%v refresh=%v content=%v",
|
||||||
|
cfg.JWTAccessTTL, cfg.JWTRefreshTTL, cfg.ContentTokenTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsNonPositiveTTL(t *testing.T) {
|
||||||
|
cases := []struct{ key, val string }{
|
||||||
|
{"JWT_ACCESS_TTL", "0"},
|
||||||
|
{"JWT_REFRESH_TTL", "-1h"},
|
||||||
|
{"CONTENT_TOKEN_TTL", "0s"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.key, func(t *testing.T) {
|
||||||
|
setValidEnv(t)
|
||||||
|
t.Setenv(tc.key, tc.val)
|
||||||
|
|
||||||
|
_, err := Load()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for %s=%q", tc.key, tc.val)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.key) || !strings.Contains(err.Error(), "must be positive") {
|
||||||
|
t.Fatalf("error should name %s and mention positivity, got: %v", tc.key, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ type fileRow struct {
|
|||||||
CreatorName string `db:"creator_name"`
|
CreatorName string `db:"creator_name"`
|
||||||
IsPublic bool `db:"is_public"`
|
IsPublic bool `db:"is_public"`
|
||||||
IsDeleted bool `db:"is_deleted"`
|
IsDeleted bool `db:"is_deleted"`
|
||||||
|
NeedsReview bool `db:"needs_review"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileTagRow is used for both single-file and batch tag loading.
|
// fileTagRow is used for both single-file and batch tag loading.
|
||||||
@@ -81,6 +82,7 @@ func toFile(r fileRow) domain.File {
|
|||||||
CreatorName: r.CreatorName,
|
CreatorName: r.CreatorName,
|
||||||
IsPublic: r.IsPublic,
|
IsPublic: r.IsPublic,
|
||||||
IsDeleted: r.IsDeleted,
|
IsDeleted: r.IsDeleted,
|
||||||
|
NeedsReview: r.NeedsReview,
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,7 +295,7 @@ const fileSelectCTE = `
|
|||||||
mt.name AS mime_type, mt.extension AS mime_extension,
|
mt.name AS mime_type, mt.extension AS mime_extension,
|
||||||
r.content_datetime, r.notes, r.metadata, r.exif, r.phash,
|
r.content_datetime, r.notes, r.metadata, r.exif, r.phash,
|
||||||
r.creator_id, u.name AS creator_name,
|
r.creator_id, u.name AS creator_name,
|
||||||
r.is_public, r.is_deleted
|
r.is_public, r.is_deleted, r.needs_review
|
||||||
FROM r
|
FROM r
|
||||||
JOIN core.mime_types mt ON mt.id = r.mime_id
|
JOIN core.mime_types mt ON mt.id = r.mime_id
|
||||||
JOIN core.users u ON u.id = r.creator_id`
|
JOIN core.users u ON u.id = r.creator_id`
|
||||||
@@ -316,7 +318,8 @@ func (r *FileRepo) Create(ctx context.Context, f *domain.File) (*domain.File, er
|
|||||||
$4, $5, $6, $7, $8, $9, $10
|
$4, $5, $6, $7, $8, $9, $10
|
||||||
)
|
)
|
||||||
RETURNING id, original_name, mime_id, content_datetime, notes,
|
RETURNING id, original_name, mime_id, content_datetime, notes,
|
||||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
metadata, exif, phash, creator_id, is_public, is_deleted,
|
||||||
|
needs_review
|
||||||
)` + fileSelectCTE
|
)` + fileSelectCTE
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
q := connOrTx(ctx, r.pool)
|
||||||
@@ -346,7 +349,7 @@ func (r *FileRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.File, err
|
|||||||
mt.name AS mime_type, mt.extension AS mime_extension,
|
mt.name AS mime_type, mt.extension AS mime_extension,
|
||||||
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
|
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
|
||||||
f.creator_id, u.name AS creator_name,
|
f.creator_id, u.name AS creator_name,
|
||||||
f.is_public, f.is_deleted
|
f.is_public, f.is_deleted, f.needs_review
|
||||||
FROM data.files f
|
FROM data.files f
|
||||||
JOIN core.mime_types mt ON mt.id = f.mime_id
|
JOIN core.mime_types mt ON mt.id = f.mime_id
|
||||||
JOIN core.users u ON u.id = f.creator_id
|
JOIN core.users u ON u.id = f.creator_id
|
||||||
@@ -389,7 +392,8 @@ func (r *FileRepo) Update(ctx context.Context, id uuid.UUID, f *domain.File) (*d
|
|||||||
is_public = $6
|
is_public = $6
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, original_name, mime_id, content_datetime, notes,
|
RETURNING id, original_name, mime_id, content_datetime, notes,
|
||||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
metadata, exif, phash, creator_id, is_public, is_deleted,
|
||||||
|
needs_review
|
||||||
)` + fileSelectCTE
|
)` + fileSelectCTE
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
q := connOrTx(ctx, r.pool)
|
||||||
@@ -416,6 +420,20 @@ func (r *FileRepo) Update(ctx context.Context, id uuid.UUID, f *domain.File) (*d
|
|||||||
return &updated, nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SoftDelete / Restore / DeletePermanent
|
// SoftDelete / Restore / DeletePermanent
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -444,7 +462,8 @@ func (r *FileRepo) Restore(ctx context.Context, id uuid.UUID) (*domain.File, err
|
|||||||
SET is_deleted = false
|
SET is_deleted = false
|
||||||
WHERE id = $1 AND is_deleted = true
|
WHERE id = $1 AND is_deleted = true
|
||||||
RETURNING id, original_name, mime_id, content_datetime, notes,
|
RETURNING id, original_name, mime_id, content_datetime, notes,
|
||||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
metadata, exif, phash, creator_id, is_public, is_deleted,
|
||||||
|
needs_review
|
||||||
)` + fileSelectCTE
|
)` + fileSelectCTE
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
q := connOrTx(ctx, r.pool)
|
||||||
@@ -638,7 +657,7 @@ func (r *FileRepo) List(ctx context.Context, params domain.FileListParams) (*dom
|
|||||||
mt.name AS mime_type, mt.extension AS mime_extension,
|
mt.name AS mime_type, mt.extension AS mime_extension,
|
||||||
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
|
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
|
||||||
f.creator_id, u.name AS creator_name,
|
f.creator_id, u.name AS creator_name,
|
||||||
f.is_public, f.is_deleted
|
f.is_public, f.is_deleted, f.needs_review
|
||||||
FROM data.files f
|
FROM data.files f
|
||||||
JOIN core.mime_types mt ON mt.id = f.mime_id
|
JOIN core.mime_types mt ON mt.id = f.mime_id
|
||||||
JOIN core.users u ON u.id = f.creator_id
|
JOIN core.users u ON u.id = f.creator_id
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
ftkTag // t=<uuid>
|
ftkTag // t=<uuid>
|
||||||
ftkMimeExact // m=<int>
|
ftkMimeExact // m=<int>
|
||||||
ftkMimeLike // m~<pattern>
|
ftkMimeLike // m~<pattern>
|
||||||
|
ftkReview // r=<0|1>
|
||||||
)
|
)
|
||||||
|
|
||||||
type filterToken struct {
|
type filterToken struct {
|
||||||
@@ -31,6 +32,7 @@ type filterToken struct {
|
|||||||
untagged bool // ftkTag with zero UUID → "file has no tags"
|
untagged bool // ftkTag with zero UUID → "file has no tags"
|
||||||
mimeID int16 // ftkMimeExact
|
mimeID int16 // ftkMimeExact
|
||||||
pattern string // ftkMimeLike
|
pattern string // ftkMimeLike
|
||||||
|
review bool // ftkReview → needs_review value
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -80,6 +82,8 @@ func (l *leafNode) toSQL(n int, args []any) (string, int, []any) {
|
|||||||
case ftkMimeLike:
|
case ftkMimeLike:
|
||||||
// mt alias comes from the JOIN in the main file query (always present).
|
// 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)
|
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")
|
panic("filterNode.toSQL: unknown leaf kind")
|
||||||
}
|
}
|
||||||
@@ -130,6 +134,15 @@ func lexFilter(dsl string) ([]filterToken, error) {
|
|||||||
case strings.HasPrefix(p, "m~"):
|
case strings.HasPrefix(p, "m~"):
|
||||||
// The pattern value is passed as a query parameter, so no SQL injection risk.
|
// The pattern value is passed as a query parameter, so no SQL injection risk.
|
||||||
tokens = append(tokens, filterToken{kind: ftkMimeLike, pattern: p[2:]})
|
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:
|
default:
|
||||||
return nil, fmt.Errorf("filter: unknown token %q", p)
|
return nil, fmt.Errorf("filter: unknown token %q", p)
|
||||||
}
|
}
|
||||||
@@ -241,7 +254,7 @@ func (p *filterParser) parseAtom() (filterNode, error) {
|
|||||||
return expr, nil
|
return expr, nil
|
||||||
}
|
}
|
||||||
switch t.kind {
|
switch t.kind {
|
||||||
case ftkTag, ftkMimeExact, ftkMimeLike:
|
case ftkTag, ftkMimeExact, ftkMimeLike, ftkReview:
|
||||||
p.next()
|
p.next()
|
||||||
return &leafNode{t}, nil
|
return &leafNode{t}, nil
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -6,6 +6,50 @@ import (
|
|||||||
"github.com/google/uuid"
|
"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) {
|
func TestFilterTagUses(t *testing.T) {
|
||||||
a := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
a := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||||
b := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
b := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||||
|
|||||||
@@ -604,10 +604,14 @@ WHERE when_tag_id = $1 AND then_tag_id = $2`
|
|||||||
if !active || !applyToExisting {
|
if !active || !applyToExisting {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return r.ApplyToExisting(ctx, whenTagID, thenTagID)
|
||||||
|
}
|
||||||
|
|
||||||
// Retroactively apply the full transitive expansion of thenTagID to all
|
// ApplyToExisting retroactively applies the full transitive expansion of
|
||||||
// files that already carry whenTagID. The recursive CTE walks active rules
|
// thenTagID to all files that already carry whenTagID. The recursive CTE walks
|
||||||
// starting from thenTagID (mirrors the Go expandTagSet BFS).
|
// 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 = `
|
const retroQuery = `
|
||||||
WITH RECURSIVE expansion(tag_id) AS (
|
WITH RECURSIVE expansion(tag_id) AS (
|
||||||
SELECT $2::uuid
|
SELECT $2::uuid
|
||||||
@@ -624,8 +628,9 @@ CROSS JOIN expansion e
|
|||||||
WHERE ft.tag_id = $1
|
WHERE ft.tag_id = $1
|
||||||
ON CONFLICT DO NOTHING`
|
ON CONFLICT DO NOTHING`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
if _, err := q.Exec(ctx, retroQuery, whenTagID, thenTagID); err != nil {
|
if _, err := q.Exec(ctx, retroQuery, whenTagID, thenTagID); err != nil {
|
||||||
return fmt.Errorf("TagRuleRepo.SetActive retroactive apply: %w", err)
|
return fmt.Errorf("TagRuleRepo.ApplyToExisting: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type File struct {
|
|||||||
CreatorName string // denormalized from core.users
|
CreatorName string // denormalized from core.users
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
IsDeleted 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
|
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
||||||
Tags []Tag // loaded with the file
|
Tags []Tag // loaded with the file
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ import (
|
|||||||
type FileHandler struct {
|
type FileHandler struct {
|
||||||
fileSvc *service.FileService
|
fileSvc *service.FileService
|
||||||
tagSvc *service.TagService
|
tagSvc *service.TagService
|
||||||
|
authSvc *service.AuthService
|
||||||
maxUploadBytes int64
|
maxUploadBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFileHandler creates a FileHandler. maxUploadBytes caps the size of an
|
// NewFileHandler creates a FileHandler. maxUploadBytes caps the size of an
|
||||||
// uploaded or replacement file.
|
// uploaded or replacement file. authSvc mints content tokens for media URLs.
|
||||||
func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService, maxUploadBytes int64) *FileHandler {
|
func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService, authSvc *service.AuthService, maxUploadBytes int64) *FileHandler {
|
||||||
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc, maxUploadBytes: maxUploadBytes}
|
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc, authSvc: authSvc, maxUploadBytes: maxUploadBytes}
|
||||||
}
|
}
|
||||||
|
|
||||||
// formFileLimited reads the "file" multipart field while bounding how many bytes
|
// formFileLimited reads the "file" multipart field while bounding how many bytes
|
||||||
@@ -83,6 +84,7 @@ type fileJSON struct {
|
|||||||
CreatorName string `json:"creator_name"`
|
CreatorName string `json:"creator_name"`
|
||||||
IsPublic bool `json:"is_public"`
|
IsPublic bool `json:"is_public"`
|
||||||
IsDeleted bool `json:"is_deleted"`
|
IsDeleted bool `json:"is_deleted"`
|
||||||
|
NeedsReview bool `json:"needs_review"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
Tags []tagJSON `json:"tags"`
|
Tags []tagJSON `json:"tags"`
|
||||||
}
|
}
|
||||||
@@ -130,6 +132,7 @@ func toFileJSON(f domain.File) fileJSON {
|
|||||||
CreatorName: f.CreatorName,
|
CreatorName: f.CreatorName,
|
||||||
IsPublic: f.IsPublic,
|
IsPublic: f.IsPublic,
|
||||||
IsDeleted: f.IsDeleted,
|
IsDeleted: f.IsDeleted,
|
||||||
|
NeedsReview: f.NeedsReview,
|
||||||
CreatedAt: f.CreatedAt.Format(time.RFC3339),
|
CreatedAt: f.CreatedAt.Format(time.RFC3339),
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
}
|
}
|
||||||
@@ -383,6 +386,38 @@ func (h *FileHandler) SoftDelete(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
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
|
// GET /files/:id/content
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -628,6 +663,33 @@ func (h *FileHandler) BulkDelete(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
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
|
// POST /files/bulk/common-tags
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
"tanabata/backend/internal/domain"
|
||||||
"tanabata/backend/internal/service"
|
"tanabata/backend/internal/service"
|
||||||
@@ -50,6 +51,55 @@ func (m *AuthMiddleware) Handle() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// bearerToken extracts the access token from the Authorization header. As a
|
||||||
// fallback it accepts an ?access_token= query parameter, but only for GET
|
// 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
|
// requests — this lets the browser open media (e.g. /files/{id}/content) via a
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,10 +33,20 @@ func NewRouter(
|
|||||||
aclHandler *ACLHandler,
|
aclHandler *ACLHandler,
|
||||||
auditHandler *AuditHandler,
|
auditHandler *AuditHandler,
|
||||||
staticDir string,
|
staticDir string,
|
||||||
) *gin.Engine {
|
trustedProxies []string,
|
||||||
|
) (*gin.Engine, error) {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery(), securityHeaders())
|
r.Use(gin.Logger(), gin.Recovery(), securityHeaders())
|
||||||
|
|
||||||
|
// Behind a reverse proxy the client's real IP arrives in X-Forwarded-For.
|
||||||
|
// Trust only the proxy hop(s) so c.ClientIP() — used by the auth rate
|
||||||
|
// limiter — reflects the real client and can't be spoofed by a forged
|
||||||
|
// header from a direct caller. An empty list trusts no proxy (ClientIP is
|
||||||
|
// the immediate peer).
|
||||||
|
if err := r.SetTrustedProxies(trustedProxies); err != nil {
|
||||||
|
return nil, fmt.Errorf("configure trusted proxies: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Health check — no auth required.
|
// Health check — no auth required.
|
||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
@@ -72,6 +83,7 @@ func NewRouter(
|
|||||||
// Bulk + import routes registered before /:id to prevent param collision.
|
// Bulk + import routes registered before /:id to prevent param collision.
|
||||||
files.POST("/bulk/tags", fileHandler.BulkSetTags)
|
files.POST("/bulk/tags", fileHandler.BulkSetTags)
|
||||||
files.POST("/bulk/delete", fileHandler.BulkDelete)
|
files.POST("/bulk/delete", fileHandler.BulkDelete)
|
||||||
|
files.POST("/bulk/review", fileHandler.BulkReview)
|
||||||
files.POST("/bulk/common-tags", fileHandler.CommonTags)
|
files.POST("/bulk/common-tags", fileHandler.CommonTags)
|
||||||
files.POST("/import", fileHandler.Import)
|
files.POST("/import", fileHandler.Import)
|
||||||
|
|
||||||
@@ -80,8 +92,9 @@ func NewRouter(
|
|||||||
files.PATCH("/:id", fileHandler.UpdateMeta)
|
files.PATCH("/:id", fileHandler.UpdateMeta)
|
||||||
files.DELETE("/:id", fileHandler.SoftDelete)
|
files.DELETE("/:id", fileHandler.SoftDelete)
|
||||||
|
|
||||||
files.GET("/:id/content", fileHandler.GetContent)
|
|
||||||
files.PUT("/:id/content", fileHandler.ReplaceContent)
|
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/thumbnail", fileHandler.GetThumbnail)
|
||||||
files.GET("/:id/preview", fileHandler.GetPreview)
|
files.GET("/:id/preview", fileHandler.GetPreview)
|
||||||
files.POST("/:id/views", fileHandler.RecordView)
|
files.POST("/:id/views", fileHandler.RecordView)
|
||||||
@@ -95,6 +108,15 @@ func NewRouter(
|
|||||||
files.DELETE("/:id/tags/:tag_id", tagHandler.FileRemoveTag)
|
files.DELETE("/:id/tags/:tag_id", tagHandler.FileRemoveTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serving an original is the one read that can outlive a 15-minute access
|
||||||
|
// token — a long video streams via repeated Range requests over many minutes.
|
||||||
|
// So this route alone also accepts a file-scoped content token (see
|
||||||
|
// HandleContent), letting the media URL stay valid for the whole playback.
|
||||||
|
media := v1.Group("/files", auth.HandleContent())
|
||||||
|
{
|
||||||
|
media.GET("/:id/content", fileHandler.GetContent)
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Tags (all require auth)
|
// Tags (all require auth)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -189,5 +211,5 @@ func NewRouter(
|
|||||||
r.NoRoute(spaHandler(staticDir))
|
r.NoRoute(spaHandler(staticDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestNewRouterRegisters builds the router with typed-nil dependencies to assert
|
||||||
|
// route registration itself succeeds. Gin panics on a route conflict (e.g. a
|
||||||
|
// duplicated method+path or an inconsistent wildcard name) during registration,
|
||||||
|
// before any handler runs — so this catches such mistakes without a database.
|
||||||
|
// Handlers are never invoked here; method values on nil pointers are fine.
|
||||||
|
func TestNewRouterRegisters(t *testing.T) {
|
||||||
|
r, err := NewRouter(
|
||||||
|
(*AuthMiddleware)(nil), (*AuthHandler)(nil),
|
||||||
|
(*FileHandler)(nil), (*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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -127,7 +128,7 @@ func setupSuite(t *testing.T) *harness {
|
|||||||
transactor := postgres.NewTransactor(pool)
|
transactor := postgres.NewTransactor(pool)
|
||||||
|
|
||||||
// --- Services ------------------------------------------------------------
|
// --- Services ------------------------------------------------------------
|
||||||
authSvc := service.NewAuthService(userRepo, sessionRepo, "test-secret", 15*time.Minute, 720*time.Hour)
|
authSvc := service.NewAuthService(userRepo, sessionRepo, "test-secret", 15*time.Minute, 720*time.Hour, 6*time.Hour)
|
||||||
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
||||||
auditSvc := service.NewAuditService(auditRepo)
|
auditSvc := service.NewAuditService(auditRepo)
|
||||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
||||||
@@ -143,7 +144,7 @@ func setupSuite(t *testing.T) *harness {
|
|||||||
// --- Handlers ------------------------------------------------------------
|
// --- Handlers ------------------------------------------------------------
|
||||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
||||||
authHandler := handler.NewAuthHandler(authSvc)
|
authHandler := handler.NewAuthHandler(authSvc)
|
||||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, 500<<20)
|
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, authSvc, 500<<20)
|
||||||
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
||||||
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
||||||
poolHandler := handler.NewPoolHandler(poolSvc)
|
poolHandler := handler.NewPoolHandler(poolSvc)
|
||||||
@@ -151,12 +152,14 @@ func setupSuite(t *testing.T) *harness {
|
|||||||
aclHandler := handler.NewACLHandler(aclSvc)
|
aclHandler := handler.NewACLHandler(aclSvc)
|
||||||
auditHandler := handler.NewAuditHandler(auditSvc)
|
auditHandler := handler.NewAuditHandler(auditSvc)
|
||||||
|
|
||||||
r := handler.NewRouter(
|
r, err := handler.NewRouter(
|
||||||
authMiddleware, authHandler,
|
authMiddleware, authHandler,
|
||||||
fileHandler, tagHandler, categoryHandler, poolHandler,
|
fileHandler, tagHandler, categoryHandler, poolHandler,
|
||||||
userHandler, aclHandler, auditHandler,
|
userHandler, aclHandler, auditHandler,
|
||||||
"",
|
"",
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
@@ -672,6 +675,94 @@ func TestTagRuleActivateApplyToExisting(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, []string{"animal", "living-thing", "organism"}, tagNames())
|
assert.ElementsMatch(t, []string{"animal", "living-thing", "organism"}, tagNames())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTagRuleCreateApplyToExisting verifies that *creating* a rule with
|
||||||
|
// apply_to_existing=true retroactively tags existing files — the same contract
|
||||||
|
// as activating one (TestTagRuleActivateApplyToExisting), exercised through the
|
||||||
|
// POST path. Also checks that apply_to_existing=false and an inactive rule both
|
||||||
|
// leave existing files untouched.
|
||||||
|
func TestTagRuleCreateApplyToExisting(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
h := setupSuite(t)
|
||||||
|
tok := h.login("admin", "admin")
|
||||||
|
|
||||||
|
mkTag := func(name string) string {
|
||||||
|
resp := h.doJSON("POST", "/tags", map[string]any{"name": name}, tok)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
|
var obj map[string]any
|
||||||
|
resp.decode(t, &obj)
|
||||||
|
return obj["id"].(string)
|
||||||
|
}
|
||||||
|
tagA := mkTag("animal")
|
||||||
|
tagB := mkTag("living-thing")
|
||||||
|
tagC := mkTag("organism")
|
||||||
|
|
||||||
|
// Rule B→C: active, so it fires transitively once B lands on the file.
|
||||||
|
resp := h.doJSON("POST", "/tags/"+tagB+"/rules", map[string]any{
|
||||||
|
"then_tag_id": tagC,
|
||||||
|
"is_active": true,
|
||||||
|
}, tok)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
|
// A file carrying only tag A — no A→B rule exists yet.
|
||||||
|
file := h.uploadJPEG(tok, "cat.jpg")
|
||||||
|
fileID := file["id"].(string)
|
||||||
|
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
|
||||||
|
"tag_ids": []string{tagA},
|
||||||
|
}, tok)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
|
tagNames := func() []string {
|
||||||
|
r := h.doJSON("GET", "/files/"+fileID+"/tags", nil, tok)
|
||||||
|
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||||
|
var items []any
|
||||||
|
r.decode(t, &items)
|
||||||
|
names := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
names = append(names, it.(map[string]any)["name"].(string))
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
assert.ElementsMatch(t, []string{"animal"}, tagNames())
|
||||||
|
|
||||||
|
// Creating an INACTIVE rule, even with apply_to_existing=true, must not tag.
|
||||||
|
resp = h.doJSON("POST", "/tags/"+tagA+"/rules", map[string]any{
|
||||||
|
"then_tag_id": tagB,
|
||||||
|
"is_active": false,
|
||||||
|
"apply_to_existing": true,
|
||||||
|
}, tok)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
|
assert.ElementsMatch(t, []string{"animal"}, tagNames(), "inactive rule must not tag existing files")
|
||||||
|
|
||||||
|
// Drop it so we can recreate as active.
|
||||||
|
resp = h.doJSON("DELETE", "/tags/"+tagA+"/rules/"+tagB, nil, tok)
|
||||||
|
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
|
// Creating an ACTIVE rule with apply_to_existing=false leaves the file alone.
|
||||||
|
resp = h.doJSON("POST", "/tags/"+tagA+"/rules", map[string]any{
|
||||||
|
"then_tag_id": tagB,
|
||||||
|
"is_active": true,
|
||||||
|
"apply_to_existing": false,
|
||||||
|
}, tok)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
|
assert.ElementsMatch(t, []string{"animal"}, tagNames(), "apply_to_existing=false must not touch existing files")
|
||||||
|
|
||||||
|
resp = h.doJSON("DELETE", "/tags/"+tagA+"/rules/"+tagB, nil, tok)
|
||||||
|
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
|
// Creating A→B active WITH apply_to_existing=true: the file gets B directly
|
||||||
|
// and C transitively via the already-active B→C rule.
|
||||||
|
resp = h.doJSON("POST", "/tags/"+tagA+"/rules", map[string]any{
|
||||||
|
"then_tag_id": tagB,
|
||||||
|
"is_active": true,
|
||||||
|
"apply_to_existing": true,
|
||||||
|
}, tok)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
|
assert.ElementsMatch(t, []string{"animal", "living-thing", "organism"}, tagNames())
|
||||||
|
}
|
||||||
|
|
||||||
// TestTagAutoRule verifies that adding a tag automatically applies then_tags.
|
// TestTagAutoRule verifies that adding a tag automatically applies then_tags.
|
||||||
func TestTagAutoRule(t *testing.T) {
|
func TestTagAutoRule(t *testing.T) {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
@@ -1205,6 +1296,102 @@ func TestImportFromFolder(t *testing.T) {
|
|||||||
assert.True(t, ct.Equal(mtime), "content_datetime %v should equal mtime %v", ct, mtime)
|
assert.True(t, ct.Equal(mtime), "content_datetime %v should equal mtime %v", ct, mtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestImportOrdersByMtime verifies that a folder import processes files in
|
||||||
|
// ascending mtime order, regardless of filename order. The three files are named
|
||||||
|
// so that alphabetical order (ReadDir's default) is the reverse of mtime order;
|
||||||
|
// the progress-event indices must follow mtime, oldest first.
|
||||||
|
func TestImportOrdersByMtime(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
h := setupSuite(t)
|
||||||
|
adminToken := h.login("admin", "admin")
|
||||||
|
|
||||||
|
// name → mtime; alphabetical order (a,b,c) is the reverse of chronological.
|
||||||
|
files := []struct {
|
||||||
|
name string
|
||||||
|
mtime time.Time
|
||||||
|
}{
|
||||||
|
{"a_newest.jpg", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||||
|
{"b_middle.jpg", time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||||
|
{"c_oldest.jpg", time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
p := filepath.Join(h.importDir, f.name)
|
||||||
|
require.NoError(t, os.WriteFile(p, minimalJPEG(), 0o644))
|
||||||
|
require.NoError(t, os.Chtimes(p, f.mtime, f.mtime))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := h.doJSON("POST", "/files/import", map[string]any{}, adminToken)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
|
events := parseImportEvents(t, resp)
|
||||||
|
idx := map[string]int{}
|
||||||
|
for _, ev := range events {
|
||||||
|
if ev.Type == "file" {
|
||||||
|
require.Equal(t, "imported", ev.Status, "%s: %s", ev.Filename, ev.Reason)
|
||||||
|
idx[ev.Filename] = ev.Index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Len(t, idx, 3, resp.String())
|
||||||
|
|
||||||
|
// Oldest first: c (2019) → b (2021) → a (2022).
|
||||||
|
assert.Less(t, idx["c_oldest.jpg"], idx["b_middle.jpg"], "oldest should be processed before middle")
|
||||||
|
assert.Less(t, idx["b_middle.jpg"], idx["a_newest.jpg"], "middle should be processed before newest")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileReviewStatus verifies the per-file "needs review" flag: new uploads
|
||||||
|
// start as needs_review=true, POST /files/bulk/review clears it, and the DSL
|
||||||
|
// filter r=0/r=1 selects reviewed/unreviewed files (also combined with others).
|
||||||
|
func TestFileReviewStatus(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
h := setupSuite(t)
|
||||||
|
tok := h.login("admin", "admin")
|
||||||
|
|
||||||
|
file := h.uploadJPEG(tok, "review-me.jpg")
|
||||||
|
fileID := file["id"].(string)
|
||||||
|
assert.Equal(t, true, file["needs_review"], "new upload should need review")
|
||||||
|
|
||||||
|
getReview := func() bool {
|
||||||
|
r := h.doJSON("GET", "/files/"+fileID, nil, tok)
|
||||||
|
require.Equal(t, http.StatusOK, r.StatusCode, r.String())
|
||||||
|
var obj map[string]any
|
||||||
|
r.decode(t, &obj)
|
||||||
|
return obj["needs_review"].(bool)
|
||||||
|
}
|
||||||
|
listIDs := func(dsl string) []string {
|
||||||
|
r := h.doJSON("GET", "/files?filter="+url.QueryEscape(dsl), nil, tok)
|
||||||
|
require.Equal(t, http.StatusOK, r.StatusCode, r.String())
|
||||||
|
var page map[string]any
|
||||||
|
r.decode(t, &page)
|
||||||
|
ids := []string{}
|
||||||
|
for _, it := range page["items"].([]any) {
|
||||||
|
ids = append(ids, it.(map[string]any)["id"].(string))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before marking done: appears under r=1 (needs review), not under r=0.
|
||||||
|
assert.True(t, getReview())
|
||||||
|
assert.Contains(t, listIDs("{r=1}"), fileID)
|
||||||
|
assert.NotContains(t, listIDs("{r=0}"), fileID)
|
||||||
|
|
||||||
|
// Mark as reviewed (done) via the bulk endpoint (single-element list).
|
||||||
|
resp := h.doJSON("POST", "/files/bulk/review", map[string]any{
|
||||||
|
"file_ids": []string{fileID},
|
||||||
|
"needs_review": false,
|
||||||
|
}, tok)
|
||||||
|
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
|
// Now reviewed: appears under r=0, not r=1; combines with a MIME predicate.
|
||||||
|
assert.False(t, getReview(), "file should no longer need review")
|
||||||
|
assert.Contains(t, listIDs("{r=0}"), fileID)
|
||||||
|
assert.NotContains(t, listIDs("{r=1}"), fileID)
|
||||||
|
assert.Contains(t, listIDs("{r=0,&,m~image/%}"), fileID)
|
||||||
|
}
|
||||||
|
|
||||||
// TestContentRangeRequests verifies the original-content endpoint answers a
|
// TestContentRangeRequests verifies the original-content endpoint answers a
|
||||||
// byte-range request with 206 Partial Content (so the browser can seek within
|
// byte-range request with 206 Partial Content (so the browser can seek within
|
||||||
// audio/video) rather than streaming the whole body.
|
// audio/video) rather than streaming the whole body.
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ type FileRepo interface {
|
|||||||
Create(ctx context.Context, f *domain.File) (*domain.File, error)
|
Create(ctx context.Context, f *domain.File) (*domain.File, error)
|
||||||
// Update applies partial metadata changes and returns the updated record.
|
// Update applies partial metadata changes and returns the updated record.
|
||||||
Update(ctx context.Context, id uuid.UUID, f *domain.File) (*domain.File, error)
|
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
|
||||||
// SoftDelete moves a file to trash (sets is_deleted = true).
|
// SoftDelete moves a file to trash (sets is_deleted = true).
|
||||||
SoftDelete(ctx context.Context, id uuid.UUID) error
|
SoftDelete(ctx context.Context, id uuid.UUID) error
|
||||||
// Restore moves a file out of trash (sets is_deleted = false).
|
// Restore moves a file out of trash (sets is_deleted = false).
|
||||||
@@ -101,6 +103,10 @@ type TagRuleRepo interface {
|
|||||||
// are both true, the full transitive expansion of thenTagID is retroactively
|
// are both true, the full transitive expansion of thenTagID is retroactively
|
||||||
// applied to all files that already carry whenTagID.
|
// applied to all files that already carry whenTagID.
|
||||||
SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active, applyToExisting bool) error
|
SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active, applyToExisting bool) error
|
||||||
|
// ApplyToExisting retroactively applies the full transitive expansion of
|
||||||
|
// thenTagID (following active rules) to every file that already carries
|
||||||
|
// whenTagID. Used when a rule is created or activated with apply_to_existing.
|
||||||
|
ApplyToExisting(ctx context.Context, whenTagID, thenTagID uuid.UUID) error
|
||||||
Delete(ctx context.Context, whenTagID, thenTagID uuid.UUID) error
|
Delete(ctx context.Context, whenTagID, thenTagID uuid.UUID) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ import (
|
|||||||
const (
|
const (
|
||||||
tokenTypeAccess = "access"
|
tokenTypeAccess = "access"
|
||||||
tokenTypeRefresh = "refresh"
|
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
|
// dummyPasswordHash is a valid bcrypt hash used to equalise the cost of a login
|
||||||
@@ -34,6 +39,8 @@ type Claims struct {
|
|||||||
IsAdmin bool `json:"adm"`
|
IsAdmin bool `json:"adm"`
|
||||||
SessionID int `json:"sid"`
|
SessionID int `json:"sid"`
|
||||||
TokenType string `json:"typ"`
|
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.
|
// TokenPair holds an issued access/refresh token pair with the access TTL.
|
||||||
@@ -50,6 +57,7 @@ type AuthService struct {
|
|||||||
secret []byte
|
secret []byte
|
||||||
accessTTL time.Duration
|
accessTTL time.Duration
|
||||||
refreshTTL time.Duration
|
refreshTTL time.Duration
|
||||||
|
contentTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthService creates an AuthService.
|
// NewAuthService creates an AuthService.
|
||||||
@@ -59,6 +67,7 @@ func NewAuthService(
|
|||||||
jwtSecret string,
|
jwtSecret string,
|
||||||
accessTTL time.Duration,
|
accessTTL time.Duration,
|
||||||
refreshTTL time.Duration,
|
refreshTTL time.Duration,
|
||||||
|
contentTTL time.Duration,
|
||||||
) *AuthService {
|
) *AuthService {
|
||||||
return &AuthService{
|
return &AuthService{
|
||||||
users: users,
|
users: users,
|
||||||
@@ -66,6 +75,7 @@ func NewAuthService(
|
|||||||
secret: []byte(jwtSecret),
|
secret: []byte(jwtSecret),
|
||||||
accessTTL: accessTTL,
|
accessTTL: accessTTL,
|
||||||
refreshTTL: refreshTTL,
|
refreshTTL: refreshTTL,
|
||||||
|
contentTTL: contentTTL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +243,53 @@ func (s *AuthService) ValidateAccessToken(ctx context.Context, tokenStr string)
|
|||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateContentToken issues a file-scoped capability token authorizing reads of
|
||||||
|
// one file's content (originals / media streaming) by URL. Unlike the access
|
||||||
|
// token it carries no session and is not validated against one, so it survives
|
||||||
|
// refresh rotation and outlives the short access TTL — which is what lets a long
|
||||||
|
// video keep playing. It is a bearer credential for that single file until
|
||||||
|
// ContentTokenTTL elapses. Returns the signed token and its lifetime in seconds.
|
||||||
|
func (s *AuthService) GenerateContentToken(fileID string, userID int16, isAdmin bool) (string, int, error) {
|
||||||
|
jti, err := randomJTI()
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
claims := Claims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ID: jti,
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(s.contentTTL)),
|
||||||
|
},
|
||||||
|
UserID: userID,
|
||||||
|
IsAdmin: isAdmin,
|
||||||
|
TokenType: tokenTypeContent,
|
||||||
|
FileID: fileID,
|
||||||
|
}
|
||||||
|
signed, err := s.signClaims(claims)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
return signed, int(s.contentTTL.Seconds()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateContentToken parses a content token and checks it authorizes fileID.
|
||||||
|
// It verifies the signature and expiry (via parseToken), the content token type,
|
||||||
|
// and that the embedded file ID matches the requested file — so a token minted
|
||||||
|
// for one file cannot read another. It is intentionally session-independent (no
|
||||||
|
// session lookup), which is what lets it outlive access-token/session rotation.
|
||||||
|
// Per-file view permission is still enforced downstream against the token's user.
|
||||||
|
func (s *AuthService) ValidateContentToken(tokenStr, fileID string) (*Claims, error) {
|
||||||
|
claims, err := s.parseToken(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, domain.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if claims.TokenType != tokenTypeContent || claims.FileID != fileID {
|
||||||
|
return nil, domain.ErrUnauthorized
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
// issueToken signs a JWT with the given parameters. A random JWT ID guarantees
|
// issueToken signs a JWT with the given parameters. A random JWT ID guarantees
|
||||||
// uniqueness even for tokens minted within the same second.
|
// 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) {
|
func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl time.Duration, tokenType string) (string, error) {
|
||||||
@@ -252,6 +309,11 @@ func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl
|
|||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
TokenType: tokenType,
|
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)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
signed, err := token.SignedString(s.secret)
|
signed, err := token.SignedString(s.secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newContentTokenService builds an AuthService for content-token tests. The
|
||||||
|
// content-token methods never touch the user/session repos, so nil is fine.
|
||||||
|
func newContentTokenService(contentTTL time.Duration) *AuthService {
|
||||||
|
return NewAuthService(nil, nil, "test-secret", 15*time.Minute, 720*time.Hour, contentTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentTokenRoundTrip(t *testing.T) {
|
||||||
|
s := newContentTokenService(time.Hour)
|
||||||
|
const fid = "11111111-1111-1111-1111-111111111111"
|
||||||
|
|
||||||
|
tok, expiresIn, err := s.GenerateContentToken(fid, 7, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateContentToken: %v", err)
|
||||||
|
}
|
||||||
|
if expiresIn != int(time.Hour.Seconds()) {
|
||||||
|
t.Fatalf("expires_in = %d, want %d", expiresIn, int(time.Hour.Seconds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := s.ValidateContentToken(tok, fid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateContentToken: %v", err)
|
||||||
|
}
|
||||||
|
if claims.UserID != 7 || !claims.IsAdmin {
|
||||||
|
t.Fatalf("claims user mismatch: uid=%d adm=%v", claims.UserID, claims.IsAdmin)
|
||||||
|
}
|
||||||
|
if claims.FileID != fid || claims.TokenType != tokenTypeContent {
|
||||||
|
t.Fatalf("claims scope mismatch: fid=%q typ=%q", claims.FileID, claims.TokenType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentTokenRejectsOtherFile(t *testing.T) {
|
||||||
|
s := newContentTokenService(time.Hour)
|
||||||
|
tok, _, err := s.GenerateContentToken("11111111-1111-1111-1111-111111111111", 7, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A token minted for one file must not authorize another.
|
||||||
|
if _, err := s.ValidateContentToken(tok, "22222222-2222-2222-2222-222222222222"); err == nil {
|
||||||
|
t.Fatal("expected rejection for a different file id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentTokenRejectsAccessToken(t *testing.T) {
|
||||||
|
s := newContentTokenService(time.Hour)
|
||||||
|
// An ordinary access token must not pass as a content token (wrong type).
|
||||||
|
access, err := s.issueToken(7, false, 1, 15*time.Minute, tokenTypeAccess)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.ValidateContentToken(access, ""); err == nil {
|
||||||
|
t.Fatal("expected rejection of an access token as a content token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentTokenRejectsExpired(t *testing.T) {
|
||||||
|
// Negative TTL → the token is already expired when minted.
|
||||||
|
s := newContentTokenService(-time.Minute)
|
||||||
|
const fid = "11111111-1111-1111-1111-111111111111"
|
||||||
|
tok, _, err := s.GenerateContentToken(fid, 7, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.ValidateContentToken(tok, fid); err == nil {
|
||||||
|
t.Fatal("expected rejection of an expired content token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentTokenRejectsGarbage(t *testing.T) {
|
||||||
|
s := newContentTokenService(time.Hour)
|
||||||
|
if _, err := s.ValidateContentToken("not-a-jwt", "11111111-1111-1111-1111-111111111111"); err == nil {
|
||||||
|
t.Fatal("expected rejection of a malformed token")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -557,6 +558,47 @@ func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error
|
|||||||
return nil
|
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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -589,6 +631,23 @@ func (s *FileService) Import(ctx context.Context, path string, onProgress func(I
|
|||||||
return nil, fmt.Errorf("FileService.Import: read dir %q: %w", dir, err)
|
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) {
|
emit := func(ev ImportEvent) {
|
||||||
if onProgress != nil {
|
if onProgress != nil {
|
||||||
onProgress(ev)
|
onProgress(ev)
|
||||||
@@ -646,10 +705,9 @@ func (s *FileService) Import(ctx context.Context, path string, onProgress func(I
|
|||||||
|
|
||||||
// Preserve the file's mtime as a content_datetime fallback (used only when
|
// 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
|
// the file has no EXIF date) — once the source is removed below it's the
|
||||||
// only date left for non-photo files.
|
// only date left for non-photo files. Reuses the mtime cached for sorting.
|
||||||
var mtime *time.Time
|
var mtime *time.Time
|
||||||
if info, statErr := entry.Info(); statErr == nil {
|
if t, ok := modTimes[name]; ok {
|
||||||
t := info.ModTime()
|
|
||||||
mtime = &t
|
mtime = &t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,16 +192,32 @@ func (s *TagService) ListRules(ctx context.Context, tagID uuid.UUID) ([]domain.T
|
|||||||
return s.rules.ListByTag(ctx, tagID)
|
return s.rules.ListByTag(ctx, tagID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateRule adds a tag rule. If applyToExisting is true, the then_tag is
|
// CreateRule adds a tag rule. When the rule is active and applyToExisting is
|
||||||
// retroactively applied to all files that already carry the when_tag.
|
// true, the full transitive expansion of thenTagID is retroactively applied to
|
||||||
// Retroactive application requires a FileRepo; it is deferred until wired
|
// every file already carrying whenTagID — same semantics as activating an
|
||||||
// in a future iteration (see port.FileRepo.ListByTag).
|
// existing rule via SetRuleActive. The insert and retroactive apply run in one
|
||||||
func (s *TagService) CreateRule(ctx context.Context, whenTagID, thenTagID uuid.UUID, isActive, _ bool) (*domain.TagRule, error) {
|
// transaction so a file is never left half-tagged.
|
||||||
return s.rules.Create(ctx, domain.TagRule{
|
func (s *TagService) CreateRule(ctx context.Context, whenTagID, thenTagID uuid.UUID, isActive, applyToExisting bool) (*domain.TagRule, error) {
|
||||||
|
var created *domain.TagRule
|
||||||
|
err := s.tx.WithTx(ctx, func(ctx context.Context) error {
|
||||||
|
rule, err := s.rules.Create(ctx, domain.TagRule{
|
||||||
WhenTagID: whenTagID,
|
WhenTagID: whenTagID,
|
||||||
ThenTagID: thenTagID,
|
ThenTagID: thenTagID,
|
||||||
IsActive: isActive,
|
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.
|
// SetRuleActive toggles a rule's is_active flag and returns the updated rule.
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ CREATE TABLE data.files (
|
|||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
creator_id smallint NOT NULL REFERENCES core.users(id)
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
ON UPDATE CASCADE ON DELETE RESTRICT,
|
||||||
is_public boolean NOT NULL DEFAULT false,
|
is_public boolean NOT NULL DEFAULT false,
|
||||||
is_deleted boolean NOT NULL DEFAULT false -- soft delete (trash)
|
is_deleted boolean NOT NULL DEFAULT false, -- soft delete (trash)
|
||||||
|
needs_review boolean NOT NULL DEFAULT true -- tagging not yet marked done; cleared explicitly
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE data.file_tag (
|
CREATE TABLE data.file_tag (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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__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__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__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
|
-- data.file_tag
|
||||||
CREATE INDEX idx__file_tag__tag_id ON data.file_tag USING hash (tag_id);
|
CREATE INDEX idx__file_tag__tag_id ON data.file_tag USING hash (tag_id);
|
||||||
@@ -74,6 +75,7 @@ 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__pools__creator_id;
|
||||||
DROP INDEX IF EXISTS data.idx__file_tag__file_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__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__phash;
|
||||||
DROP INDEX IF EXISTS data.idx__files__is_deleted;
|
DROP INDEX IF EXISTS data.idx__files__is_deleted;
|
||||||
DROP INDEX IF EXISTS data.idx__files__content_datetime;
|
DROP INDEX IF EXISTS data.idx__files__content_datetime;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ INSERT INTO activity.action_types (name) VALUES
|
|||||||
('user_login'), ('user_logout'),
|
('user_login'), ('user_logout'),
|
||||||
-- Files
|
-- Files
|
||||||
('file_create'), ('file_edit'), ('file_delete'), ('file_restore'),
|
('file_create'), ('file_edit'), ('file_delete'), ('file_restore'),
|
||||||
('file_permanent_delete'), ('file_replace'),
|
('file_permanent_delete'), ('file_replace'), ('file_review'),
|
||||||
-- Tags
|
-- Tags
|
||||||
('tag_create'), ('tag_edit'), ('tag_delete'),
|
('tag_create'), ('tag_edit'), ('tag_delete'),
|
||||||
-- Categories
|
-- Categories
|
||||||
|
|||||||
+35
-3
@@ -35,10 +35,23 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
STATIC_DIR: /app/static
|
STATIC_DIR: /app/static
|
||||||
|
|
||||||
# The container always listens on 42776 (Dockerfile default); APP_PORT only
|
# Published on loopback only: a reverse proxy on the host (e.g. nginx) fronts
|
||||||
# changes the host-published port.
|
# 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:
|
ports:
|
||||||
- "${APP_PORT:-42776}:42776"
|
- "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
|
# 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
|
# host Postgres the db service is disabled, and required:false keeps this
|
||||||
@@ -76,6 +89,10 @@ services:
|
|||||||
# the app at a Postgres running on the host instead.
|
# the app at a Postgres running on the host instead.
|
||||||
profiles: ["with-db"]
|
profiles: ["with-db"]
|
||||||
|
|
||||||
|
# Private back-end tier only — never on `web`, never published.
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-tanabata}
|
POSTGRES_DB: ${POSTGRES_DB:-tanabata}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-tanabata}
|
POSTGRES_USER: ${POSTGRES_USER:-tanabata}
|
||||||
@@ -97,6 +114,21 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
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:
|
volumes:
|
||||||
app_files:
|
app_files:
|
||||||
app_thumbs:
|
app_thumbs:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
--color-danger: #db6060;
|
--color-danger: #db6060;
|
||||||
--color-info: #4dc7ed;
|
--color-info: #4dc7ed;
|
||||||
--color-warning: #f5e872;
|
--color-warning: #f5e872;
|
||||||
|
--color-success: #5fb87a;
|
||||||
--color-tag-default: #444455;
|
--color-tag-default: #444455;
|
||||||
--color-nav-bg: rgba(0, 0, 0, 0.45);
|
--color-nav-bg: rgba(0, 0, 0, 0.45);
|
||||||
--color-nav-active: rgba(52, 50, 73, 0.72);
|
--color-nav-active: rgba(52, 50, 73, 0.72);
|
||||||
|
|||||||
@@ -132,6 +132,9 @@
|
|||||||
<div class="placeholder loading" aria-label="Loading"></div>
|
<div class="placeholder loading" aria-label="Loading"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="overlay"></div>
|
<div class="overlay"></div>
|
||||||
|
{#if file.needs_review}
|
||||||
|
<div class="review-dot" title="Needs review" aria-label="Needs review"></div>
|
||||||
|
{/if}
|
||||||
{#if selected}
|
{#if selected}
|
||||||
<div class="check" aria-hidden="true">
|
<div class="check" aria-hidden="true">
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
@@ -236,6 +239,19 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "Needs review" marker — top-left so it never overlaps the selection check. */
|
||||||
|
.review-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.45);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% {
|
0% {
|
||||||
background-position: 200% 0;
|
background-position: 200% 0;
|
||||||
|
|||||||
@@ -17,13 +17,20 @@
|
|||||||
onNavigate: (id: string) => void;
|
onNavigate: (id: string) => void;
|
||||||
/** Close the viewer. */
|
/** Close the viewer. */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** Notify the parent when this file's review status is toggled here. */
|
||||||
|
onReviewChange?: (id: string, needsReview: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { fileId, prevId = null, nextId = null, onNavigate, onClose }: Props = $props();
|
let { fileId, prevId = null, nextId = null, onNavigate, onClose, onReviewChange }: Props =
|
||||||
|
$props();
|
||||||
|
|
||||||
let file = $state<File | null>(null);
|
let file = $state<File | null>(null);
|
||||||
let fileTags = $state<Tag[]>([]);
|
let fileTags = $state<Tag[]>([]);
|
||||||
let previewSrc = $state<string | null>(null);
|
let previewSrc = $state<string | null>(null);
|
||||||
|
// Capability token for the original-content URL, minted per file (see
|
||||||
|
// fetchContentToken). Outlives the 15-minute access token so a long video
|
||||||
|
// opened in a new tab keeps streaming.
|
||||||
|
let contentToken = $state<string | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -68,6 +75,8 @@
|
|||||||
error = '';
|
error = '';
|
||||||
// Drop the previous file's tags; they reload lazily when scrolled to.
|
// Drop the previous file's tags; they reload lazily when scrolled to.
|
||||||
fileTags = [];
|
fileTags = [];
|
||||||
|
// Invalidate the previous file's content token before re-minting.
|
||||||
|
contentToken = null;
|
||||||
try {
|
try {
|
||||||
const fileData = await api.get<File>(`/files/${id}`);
|
const fileData = await api.get<File>(`/files/${id}`);
|
||||||
if (fileId !== id) return; // paged on; ignore
|
if (fileId !== id) return; // paged on; ignore
|
||||||
@@ -79,6 +88,7 @@
|
|||||||
isPublic = fileData.is_public ?? false;
|
isPublic = fileData.is_public ?? false;
|
||||||
dirty = false;
|
dirty = false;
|
||||||
void fetchPreview(id);
|
void fetchPreview(id);
|
||||||
|
void fetchContentToken(id);
|
||||||
// Log the view (activity.file_views). Fire-and-forget — never block or
|
// Log the view (activity.file_views). Fire-and-forget — never block or
|
||||||
// fail the viewer over view tracking.
|
// fail the viewer over view tracking.
|
||||||
void api.post(`/files/${id}/views`).catch(() => {});
|
void api.post(`/files/${id}/views`).catch(() => {});
|
||||||
@@ -103,13 +113,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mint a content token for this file so the "open original" link survives the
|
||||||
|
// 15-minute access-token expiry — a long video opened in a new tab keeps
|
||||||
|
// streaming, since the token is file-scoped and outlives session rotation.
|
||||||
|
// Fire-and-forget; the link falls back to the access token until it arrives.
|
||||||
|
async function fetchContentToken(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ token: string; expires_in: number }>(
|
||||||
|
`/files/${id}/content-token`
|
||||||
|
);
|
||||||
|
if (fileId === id) contentToken = res.token;
|
||||||
|
} catch {
|
||||||
|
// non-critical — originalUrl falls back to the access token below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Direct link to the full-resolution original, opened in a new tab. A
|
// Direct link to the full-resolution original, opened in a new tab. A
|
||||||
// navigation can't send the auth header, so the token rides in the query —
|
// navigation can't send the auth header, so the token rides in the query —
|
||||||
// the server accepts ?access_token= for GET media. Reactive on the token so a
|
// the server accepts ?access_token= for GET media. Prefer the long-lived
|
||||||
// silent refresh keeps the link valid.
|
// content token; fall back to the access token until it's minted.
|
||||||
let originalUrl = $derived(
|
let originalUrl = $derived(
|
||||||
fileId
|
fileId
|
||||||
? `/api/v1/files/${fileId}/content?inline=1&access_token=${encodeURIComponent($authStore.accessToken ?? '')}`
|
? `/api/v1/files/${fileId}/content?inline=1&access_token=${encodeURIComponent(contentToken ?? $authStore.accessToken ?? '')}`
|
||||||
: '#'
|
: '#'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -165,6 +190,20 @@
|
|||||||
fileTags = fileTags.filter((t) => t.id !== tagId);
|
fileTags = fileTags.filter((t) => t.id !== tagId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Review status ----
|
||||||
|
async function toggleReview() {
|
||||||
|
const id = file?.id;
|
||||||
|
if (!id) return;
|
||||||
|
const target = !file!.needs_review;
|
||||||
|
try {
|
||||||
|
await api.post('/files/bulk/review', { file_ids: [id], needs_review: target });
|
||||||
|
file = { ...file!, needs_review: target };
|
||||||
|
onReviewChange?.(id, target);
|
||||||
|
} catch {
|
||||||
|
// best-effort; leave the displayed state unchanged on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Save ----
|
// ---- Save ----
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!file || saving) return;
|
if (!file || saving) return;
|
||||||
@@ -279,6 +318,24 @@
|
|||||||
</button>
|
</button>
|
||||||
<span class="filename">{file?.original_name ?? ''}</span>
|
<span class="filename">{file?.original_name ?? ''}</span>
|
||||||
{#if file}
|
{#if file}
|
||||||
|
<button
|
||||||
|
class="review-btn"
|
||||||
|
class:needs={file.needs_review}
|
||||||
|
onclick={toggleReview}
|
||||||
|
aria-label={file.needs_review ? 'Mark as reviewed' : 'Mark as needs review'}
|
||||||
|
title={file.needs_review ? 'Tagging not done — mark reviewed' : 'Reviewed — mark as needs review'}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.6" />
|
||||||
|
<path
|
||||||
|
d="M6.5 10l2.2 2.2L13.5 7.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="pool-btn"
|
class="pool-btn"
|
||||||
onclick={() => (poolPickerOpen = true)}
|
onclick={() => (poolPickerOpen = true)}
|
||||||
@@ -500,8 +557,32 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pool-btn {
|
/* First of the trailing header buttons carries the auto margin that pushes the
|
||||||
|
review + pool group to the right edge. */
|
||||||
|
.review-btn {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-success); /* reviewed: solid green check */
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-btn.needs {
|
||||||
|
color: var(--color-text-muted); /* not yet reviewed: dim check */
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-btn:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -43,6 +43,14 @@
|
|||||||
tokens = tokens.filter((_, idx) => idx !== i);
|
tokens = tokens.filter((_, idx) => idx !== i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Review status is a single, mutually-exclusive r=1 / r=0 token; null = "any".
|
||||||
|
let reviewToken = $derived(tokens.find((t) => t === 'r=1' || t === 'r=0') ?? null);
|
||||||
|
|
||||||
|
function setReview(value: 'r=1' | 'r=0' | null) {
|
||||||
|
const rest = tokens.filter((t) => t !== 'r=1' && t !== 'r=0');
|
||||||
|
tokens = value ? [...rest, value] : rest;
|
||||||
|
}
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
onApply(buildDslFilter(tokens));
|
onApply(buildDslFilter(tokens));
|
||||||
}
|
}
|
||||||
@@ -189,6 +197,17 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Review status (mutually-exclusive r=1 / r=0) -->
|
||||||
|
<div class="review-seg" role="group" aria-label="Review status">
|
||||||
|
<button class="seg" class:on={reviewToken === null} onclick={() => setReview(null)}>Any</button>
|
||||||
|
<button class="seg" class:on={reviewToken === 'r=1'} onclick={() => setReview('r=1')}>
|
||||||
|
Needs review
|
||||||
|
</button>
|
||||||
|
<button class="seg" class:on={reviewToken === 'r=0'} onclick={() => setReview('r=0')}>
|
||||||
|
Reviewed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tag search -->
|
<!-- Tag search -->
|
||||||
<input
|
<input
|
||||||
class="search"
|
class="search"
|
||||||
@@ -262,6 +281,37 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-seg {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg.on {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.token {
|
.token {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
onEditTags: () => void;
|
onEditTags: () => void;
|
||||||
onAddToPool: () => void;
|
onAddToPool: () => void;
|
||||||
|
onMarkReviewed: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onEditTags, onAddToPool, onDelete }: Props = $props();
|
let { onEditTags, onAddToPool, onMarkReviewed, onDelete }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bar" role="toolbar" aria-label="Selection actions">
|
<div class="bar" role="toolbar" aria-label="Selection actions">
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
|
|
||||||
<button class="action edit-tags" onclick={onEditTags}>Edit tags</button>
|
<button class="action edit-tags" onclick={onEditTags}>Edit tags</button>
|
||||||
<button class="action add-pool" onclick={onAddToPool}>Add to pool</button>
|
<button class="action add-pool" onclick={onAddToPool}>Add to pool</button>
|
||||||
|
<button class="action mark-reviewed" onclick={onMarkReviewed}>Mark reviewed</button>
|
||||||
<button class="action delete" onclick={onDelete}>Delete</button>
|
<button class="action delete" onclick={onDelete}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,6 +142,14 @@
|
|||||||
background-color: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
background-color: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mark-reviewed {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-reviewed:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.delete {
|
.delete {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* t=<uuid> — has tag
|
* t=<uuid> — has tag
|
||||||
* m=<mime> — exact MIME
|
* m=<mime> — exact MIME
|
||||||
* m~<pattern> — MIME LIKE pattern
|
* m~<pattern> — MIME LIKE pattern
|
||||||
|
* r=1 / r=0 — needs review / review done
|
||||||
* ( ) & | ! — grouping / boolean operators
|
* ( ) & | ! — grouping / boolean operators
|
||||||
*
|
*
|
||||||
* Example: {t=uuid1,&,!,t=uuid2} → has tag1 AND NOT tag2
|
* Example: {t=uuid1,&,!,t=uuid2} → has tag1 AND NOT tag2
|
||||||
@@ -31,6 +32,8 @@ export function tokenLabel(token: string, tagNames: Map<string, string>): string
|
|||||||
if (token === '!') return 'NOT';
|
if (token === '!') return 'NOT';
|
||||||
if (token === '(') return '(';
|
if (token === '(') return '(';
|
||||||
if (token === ')') return ')';
|
if (token === ')') return ')';
|
||||||
|
if (token === 'r=1') return 'Needs review';
|
||||||
|
if (token === 'r=0') return 'Reviewed';
|
||||||
if (token.startsWith('t=')) {
|
if (token.startsWith('t=')) {
|
||||||
const id = token.slice(2);
|
const id = token.slice(2);
|
||||||
return tagNames.get(id) ?? token;
|
return tagNames.get(id) ?? token;
|
||||||
|
|||||||
@@ -114,6 +114,22 @@
|
|||||||
void tick().then(() => document.querySelector<HTMLInputElement>('.tag-sheet input')?.focus());
|
void tick().then(() => document.querySelector<HTMLInputElement>('.tag-sheet input')?.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark the current selection as review-done (tagging finished). Best-effort
|
||||||
|
// optimistic update of the local list so the "needs review" badges clear.
|
||||||
|
async function markSelectionReviewed() {
|
||||||
|
const ids = [...$selectionStore.ids];
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
selectionStore.exit();
|
||||||
|
try {
|
||||||
|
await api.post('/files/bulk/review', { file_ids: ids, needs_review: false });
|
||||||
|
files = files.map((f) =>
|
||||||
|
ids.includes(f.id ?? '') ? { ...f, needs_review: false } : f
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore — list already reflects the intended state optimistically
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openFilterAndFocus() {
|
function openFilterAndFocus() {
|
||||||
filterOpen = true;
|
filterOpen = true;
|
||||||
void tick().then(() => document.querySelector<HTMLInputElement>('.bar .search')?.focus());
|
void tick().then(() => document.querySelector<HTMLInputElement>('.bar .search')?.focus());
|
||||||
@@ -835,6 +851,8 @@
|
|||||||
nextId={viewerNextId}
|
nextId={viewerNextId}
|
||||||
onNavigate={pageTo}
|
onNavigate={pageTo}
|
||||||
onClose={closeViewer}
|
onClose={closeViewer}
|
||||||
|
onReviewChange={(id, nr) =>
|
||||||
|
(files = files.map((f) => (f.id === id ? { ...f, needs_review: nr } : f)))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -843,6 +861,7 @@
|
|||||||
<SelectionBar
|
<SelectionBar
|
||||||
onEditTags={openTagEditor}
|
onEditTags={openTagEditor}
|
||||||
onAddToPool={openPoolPicker}
|
onAddToPool={openPoolPicker}
|
||||||
|
onMarkReviewed={markSelectionReviewed}
|
||||||
onDelete={() => (confirmDeleteFiles = true)}
|
onDelete={() => (confirmDeleteFiles = true)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+74
-2
@@ -27,9 +27,11 @@ info:
|
|||||||
|
|
||||||
Operators: `(`, `)`, `&` (AND), `|` (OR), `!` (NOT).
|
Operators: `(`, `)`, `&` (AND), `|` (OR), `!` (NOT).
|
||||||
Conditions: `t=<tag_uuid>` (has tag), `t=00000000-0000-0000-0000-000000000000` (untagged),
|
Conditions: `t=<tag_uuid>` (has tag), `t=00000000-0000-0000-0000-000000000000` (untagged),
|
||||||
`m=<mime_id>` (exact MIME), `m~<pattern>` (MIME LIKE pattern, e.g. `m~image%`).
|
`m=<mime_id>` (exact MIME), `m~<pattern>` (MIME LIKE pattern, e.g. `m~image%`),
|
||||||
|
`r=1` (needs review / not yet tagged-done), `r=0` (review done).
|
||||||
|
|
||||||
Example: `{t=uuid1,&,!,t=uuid2}` → has tag1 AND NOT tag2.
|
Example: `{t=uuid1,&,!,t=uuid2}` → has tag1 AND NOT tag2.
|
||||||
|
Example: `{r=1,&,m~image%}` → needs review AND is an image.
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
license:
|
license:
|
||||||
name: Proprietary
|
name: Proprietary
|
||||||
@@ -350,7 +352,11 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
description: Access token, as an alternative to the Authorization header (GET only).
|
description: >
|
||||||
|
Access token or a file-scoped content token (obtained from POST
|
||||||
|
/files/{file_id}/content-token), as an alternative to the
|
||||||
|
Authorization header (GET only). A content token outlives the
|
||||||
|
access token, so long media keeps streaming past access-token expiry.
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: File binary
|
description: File binary
|
||||||
@@ -392,6 +398,39 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
/files/{file_id}/content-token:
|
||||||
|
post:
|
||||||
|
tags: [Files]
|
||||||
|
summary: Mint a content token for opening/streaming the original by URL
|
||||||
|
description: >
|
||||||
|
Returns a short-lived, single-file capability token to place in the
|
||||||
|
access_token query parameter of GET /files/{file_id}/content. Unlike the
|
||||||
|
access token it is scoped to this one file and is session-independent, so
|
||||||
|
it survives access-token expiry and refresh rotation — letting a long
|
||||||
|
video opened in a new tab keep streaming. Requires view permission on the
|
||||||
|
file. The token is a bearer credential for that file until it expires.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/file_id'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Content token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [token, expires_in]
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: Capability token for the access_token query parameter.
|
||||||
|
expires_in:
|
||||||
|
type: integer
|
||||||
|
description: Token lifetime in seconds.
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
/files/{file_id}/thumbnail:
|
/files/{file_id}/thumbnail:
|
||||||
get:
|
get:
|
||||||
tags: [Files]
|
tags: [Files]
|
||||||
@@ -605,6 +644,33 @@ paths:
|
|||||||
'204':
|
'204':
|
||||||
description: Files moved to trash
|
description: Files moved to trash
|
||||||
|
|
||||||
|
/files/bulk/review:
|
||||||
|
post:
|
||||||
|
tags: [Files]
|
||||||
|
summary: Set the review status on one or more files
|
||||||
|
description: >-
|
||||||
|
Marks the given files as needing review (`needs_review=true`) or as
|
||||||
|
review-done (`false`). A single-file toggle is just a one-element list.
|
||||||
|
Files the caller cannot edit are silently skipped.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [file_ids, needs_review]
|
||||||
|
properties:
|
||||||
|
file_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
needs_review:
|
||||||
|
type: boolean
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Review status updated
|
||||||
|
|
||||||
/files/bulk/common-tags:
|
/files/bulk/common-tags:
|
||||||
post:
|
post:
|
||||||
tags: [Files, Tags]
|
tags: [Files, Tags]
|
||||||
@@ -1738,6 +1804,12 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
is_deleted:
|
is_deleted:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
needs_review:
|
||||||
|
type: boolean
|
||||||
|
description: >-
|
||||||
|
True until the file's tagging is explicitly marked done. New uploads
|
||||||
|
and imports start true; cleared via POST /files/bulk/review. Filter
|
||||||
|
with `r=1` (needs review) / `r=0` (done).
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|||||||
Reference in New Issue
Block a user