Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b774d2b3c9 | |||
| d124229308 | |||
| bf7a11076f | |||
| c7176fadf6 | |||
| bc4354bf7b | |||
| 00ab98072b | |||
| c39d82fafd | |||
| 4e0fc431e2 | |||
| 7384751d6b | |||
| 49952c62ef | |||
| 057ba22b18 | |||
| 35d41a46c0 | |||
| dbc34a8e0d | |||
| 912c7b80a3 | |||
| 900770ff36 | |||
| 7062cb630e | |||
| 436f164ab9 | |||
| d9ca913620 | |||
| 153020b9c7 | |||
| effcd2c073 | |||
| 78885b3656 | |||
| 828d611f4d | |||
| 82bd446a85 | |||
| 24075e5a76 | |||
| 27184bf17a | |||
| ec17dfb0ce | |||
| 6e3328ca83 | |||
| 164ea9a6c8 | |||
| 761babfa1a | |||
| 59eacd6bc5 | |||
| 5ac528be05 | |||
| ad3c77b40e | |||
| e807d61b05 | |||
| 429213f29c | |||
| ace4fa1c0a | |||
| d543101054 | |||
| b1587f05cc | |||
| 9230b9a5dc | |||
| be65d0623b | |||
| 4221abe905 | |||
| a27ae7d4ab | |||
| cc53b862e4 | |||
| 49fc1537b7 | |||
| 1c013183f0 | |||
| e8084bdeb0 | |||
| 6f7f38c9da |
@@ -1,36 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# Tanabata File Manager — environment variables
|
|
||||||
# Copy to .env and fill in the values.
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Server
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
LISTEN_ADDR=:8080
|
|
||||||
JWT_SECRET=change-me-to-a-random-32-byte-secret
|
|
||||||
JWT_ACCESS_TTL=15m
|
|
||||||
JWT_REFRESH_TTL=720h
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Database
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DATABASE_URL=postgres://tanabata:password@localhost:5432/tanabata?sslmode=disable
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Storage
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
FILES_PATH=/data/files
|
|
||||||
THUMBS_CACHE_PATH=/data/thumbs
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Thumbnails
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
THUMB_WIDTH=160
|
|
||||||
THUMB_HEIGHT=160
|
|
||||||
PREVIEW_WIDTH=1920
|
|
||||||
PREVIEW_HEIGHT=1080
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Import
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
IMPORT_PATH=/data/import
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# Tanabata File Manager — .gitattributes
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Line endings: normalize to LF in repo, native on checkout
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
* text=auto eol=lf
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Explicitly text
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
*.go text eol=lf
|
|
||||||
*.mod text eol=lf
|
|
||||||
*.sum text eol=lf
|
|
||||||
*.sql text eol=lf
|
|
||||||
*.ts text eol=lf
|
|
||||||
*.js text eol=lf
|
|
||||||
*.svelte text eol=lf
|
|
||||||
*.html text eol=lf
|
|
||||||
*.css text eol=lf
|
|
||||||
*.json text eol=lf
|
|
||||||
*.yaml text eol=lf
|
|
||||||
*.yml text eol=lf
|
|
||||||
*.md text eol=lf
|
|
||||||
*.txt text eol=lf
|
|
||||||
*.env* text eol=lf
|
|
||||||
*.sh text eol=lf
|
|
||||||
*.toml text eol=lf
|
|
||||||
*.xml text eol=lf
|
|
||||||
*.svg text eol=lf
|
|
||||||
Dockerfile text eol=lf
|
|
||||||
Makefile text eol=lf
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Explicitly binary
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.jpeg binary
|
|
||||||
*.gif binary
|
|
||||||
*.webp binary
|
|
||||||
*.ico binary
|
|
||||||
*.ttf binary
|
|
||||||
*.woff binary
|
|
||||||
*.woff2 binary
|
|
||||||
*.eot binary
|
|
||||||
*.otf binary
|
|
||||||
*.zip binary
|
|
||||||
*.gz binary
|
|
||||||
*.tar binary
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Diff behavior
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
*.sql diff=sql
|
|
||||||
*.go diff=golang
|
|
||||||
*.css diff=css
|
|
||||||
*.html diff=html
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Linguist: set repo language stats correctly
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
docs/reference/** linguist-documentation
|
|
||||||
frontend/static/** linguist-vendored
|
|
||||||
*.min.js linguist-vendored
|
|
||||||
*.min.css linguist-vendored
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# Tanabata File Manager — .gitignore
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Environment & secrets
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# OS
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
Desktop.ini
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# IDE
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
!.vscode/settings.json
|
|
||||||
.idea/
|
|
||||||
*.iml
|
|
||||||
*.sublime-project
|
|
||||||
*.sublime-workspace
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Backend (Go)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
backend/tmp/
|
|
||||||
backend/cmd/server/server
|
|
||||||
*.exe
|
|
||||||
*.exe~
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
*.test
|
|
||||||
*.out
|
|
||||||
*.prof
|
|
||||||
coverage.out
|
|
||||||
coverage.html
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Frontend (SvelteKit / Node)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
frontend/node_modules/
|
|
||||||
frontend/.svelte-kit/
|
|
||||||
frontend/build/
|
|
||||||
frontend/dist/
|
|
||||||
frontend/src/lib/api/schema.ts
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Docker
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
docker-compose.override.yml
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Data directories (runtime, not in repo)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
data/
|
|
||||||
*.sqlite
|
|
||||||
*.sqlite3
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Misc
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
*.log
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Reference: exclude vendored libs, keep design sources
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
docs/reference/**/bootstrap.min.css
|
|
||||||
docs/reference/**/bootstrap.min.css.map
|
|
||||||
docs/reference/**/jquery-*.min.js
|
|
||||||
docs/reference/**/__pycache__/
|
|
||||||
docs/reference/**/*.pyc
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Tanabata File Manager
|
|
||||||
|
|
||||||
Multi-user, tag-based web file manager for images and video.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Monorepo: `backend/` (Go) + `frontend/` (SvelteKit).
|
|
||||||
|
|
||||||
- Backend: Go + Gin + pgx v5 + goose migrations. Clean Architecture.
|
|
||||||
- Frontend: SvelteKit SPA + Tailwind CSS + CSS custom properties.
|
|
||||||
- DB: PostgreSQL 14+.
|
|
||||||
- Auth: JWT Bearer tokens.
|
|
||||||
|
|
||||||
## Key documents (read before coding)
|
|
||||||
|
|
||||||
- `openapi.yaml` — full REST API specification (36 paths, 58 operations)
|
|
||||||
- `docs/GO_PROJECT_STRUCTURE.md` — backend architecture, layer rules, DI pattern
|
|
||||||
- `docs/FRONTEND_STRUCTURE.md` — frontend architecture, CSS approach, API client
|
|
||||||
- `docs/Описание.md` — product requirements in Russian
|
|
||||||
- `backend/migrations/001_init.sql` — database schema (4 schemas, 16 tables)
|
|
||||||
|
|
||||||
## Design reference
|
|
||||||
|
|
||||||
The `docs/reference/` directory contains the previous Python/Flask version.
|
|
||||||
Use its visual design as the basis for the new frontend:
|
|
||||||
- Color palette: #312F45 (bg), #9592B5 (accent), #444455 (tag default), #111118 (elevated)
|
|
||||||
- Font: Epilogue (variable weight)
|
|
||||||
- Dark theme is primary
|
|
||||||
- Mobile-first layout with bottom navbar
|
|
||||||
- 160×160 thumbnail grid for files
|
|
||||||
- Colored tag pills
|
|
||||||
- Floating selection bar for multi-select
|
|
||||||
|
|
||||||
## Backend commands
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
go run ./cmd/server # run dev server
|
|
||||||
go test ./... # run all tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend commands
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm run dev # vite dev server
|
|
||||||
npm run build # production build
|
|
||||||
npm run generate:types # regenerate API types from openapi.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- Go: gofmt, no global state, context.Context as first param in all service methods
|
|
||||||
- TypeScript: strict mode, named exports
|
|
||||||
- SQL: snake_case, all migrations via goose
|
|
||||||
- API errors: { code, message, details? }
|
|
||||||
- Git: conventional commits (feat:, fix:, docs:, refactor:)
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/stdlib"
|
|
||||||
"github.com/pressly/goose/v3"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/config"
|
|
||||||
"tanabata/backend/internal/db/postgres"
|
|
||||||
"tanabata/backend/internal/handler"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
"tanabata/backend/internal/storage"
|
|
||||||
"tanabata/backend/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to load config", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := postgres.NewPool(context.Background(), cfg.DatabaseURL)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to connect to database", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
slog.Info("database connected")
|
|
||||||
|
|
||||||
migDB := stdlib.OpenDBFromPool(pool)
|
|
||||||
goose.SetBaseFS(migrations.FS)
|
|
||||||
if err := goose.SetDialect("postgres"); err != nil {
|
|
||||||
slog.Error("goose dialect error", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := goose.Up(migDB, "."); err != nil {
|
|
||||||
slog.Error("migrations failed", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
migDB.Close()
|
|
||||||
slog.Info("migrations applied")
|
|
||||||
|
|
||||||
// Storage
|
|
||||||
diskStorage, err := storage.NewDiskStorage(
|
|
||||||
cfg.FilesPath,
|
|
||||||
cfg.ThumbsCachePath,
|
|
||||||
cfg.ThumbWidth, cfg.ThumbHeight,
|
|
||||||
cfg.PreviewWidth, cfg.PreviewHeight,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to initialise storage", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Repositories
|
|
||||||
userRepo := postgres.NewUserRepo(pool)
|
|
||||||
sessionRepo := postgres.NewSessionRepo(pool)
|
|
||||||
fileRepo := postgres.NewFileRepo(pool)
|
|
||||||
mimeRepo := postgres.NewMimeRepo(pool)
|
|
||||||
aclRepo := postgres.NewACLRepo(pool)
|
|
||||||
auditRepo := postgres.NewAuditRepo(pool)
|
|
||||||
transactor := postgres.NewTransactor(pool)
|
|
||||||
|
|
||||||
// Services
|
|
||||||
authSvc := service.NewAuthService(
|
|
||||||
userRepo,
|
|
||||||
sessionRepo,
|
|
||||||
cfg.JWTSecret,
|
|
||||||
cfg.JWTAccessTTL,
|
|
||||||
cfg.JWTRefreshTTL,
|
|
||||||
)
|
|
||||||
aclSvc := service.NewACLService(aclRepo)
|
|
||||||
auditSvc := service.NewAuditService(auditRepo)
|
|
||||||
fileSvc := service.NewFileService(
|
|
||||||
fileRepo,
|
|
||||||
mimeRepo,
|
|
||||||
diskStorage,
|
|
||||||
aclSvc,
|
|
||||||
auditSvc,
|
|
||||||
transactor,
|
|
||||||
cfg.ImportPath,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
|
||||||
authHandler := handler.NewAuthHandler(authSvc)
|
|
||||||
fileHandler := handler.NewFileHandler(fileSvc)
|
|
||||||
|
|
||||||
r := handler.NewRouter(authMiddleware, authHandler, fileHandler)
|
|
||||||
|
|
||||||
slog.Info("starting server", "addr", cfg.ListenAddr)
|
|
||||||
if err := r.Run(cfg.ListenAddr); err != nil {
|
|
||||||
slog.Error("server error", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +1,19 @@
|
|||||||
module tanabata/backend
|
module tanabata
|
||||||
|
|
||||||
go 1.26
|
go 1.23.0
|
||||||
|
|
||||||
toolchain go1.26.1
|
toolchain go1.23.10
|
||||||
|
|
||||||
|
require github.com/jackc/pgx/v5 v5.7.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.9.1
|
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/jackc/pgx/v5 v5.5.5
|
|
||||||
github.com/joho/godotenv v1.5.1
|
|
||||||
github.com/pressly/goose/v3 v3.21.1
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
|
||||||
github.com/go-playground/validator/v10 v10.22.0 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/pgx v3.6.2+incompatible // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/stretchr/testify v1.9.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
golang.org/x/crypto v0.37.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
|
||||||
golang.org/x/crypto v0.39.0 // indirect
|
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
|
||||||
golang.org/x/net v0.33.0 // indirect
|
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
|
||||||
golang.org/x/text v0.34.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,141 +1,32 @@
|
|||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
||||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
|
||||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=
|
||||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
|
||||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pressly/goose/v3 v3.21.1 h1:5SSAKKWej8LVVzNLuT6KIvP1eFDuPvxa+B6H0w78buQ=
|
|
||||||
github.com/pressly/goose/v3 v3.21.1/go.mod h1:sqthmzV8PitchEkjecFJII//l43dLOCzfWh8pHEe+vE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
|
||||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
|
||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
|
||||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
|
||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
|
||||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
|
||||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
|
||||||
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
|
|
||||||
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
|
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
|
||||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
|
||||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
|
||||||
modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4=
|
|
||||||
modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
|
|
||||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
|
||||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds all application configuration loaded from environment variables.
|
|
||||||
type Config struct {
|
|
||||||
// Server
|
|
||||||
ListenAddr string
|
|
||||||
JWTSecret string
|
|
||||||
JWTAccessTTL time.Duration
|
|
||||||
JWTRefreshTTL time.Duration
|
|
||||||
|
|
||||||
// Database
|
|
||||||
DatabaseURL string
|
|
||||||
|
|
||||||
// Storage
|
|
||||||
FilesPath string
|
|
||||||
ThumbsCachePath string
|
|
||||||
|
|
||||||
// Thumbnails
|
|
||||||
ThumbWidth int
|
|
||||||
ThumbHeight int
|
|
||||||
PreviewWidth int
|
|
||||||
PreviewHeight int
|
|
||||||
|
|
||||||
// Import
|
|
||||||
ImportPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load reads a .env file (if present) then loads all configuration from
|
|
||||||
// environment variables. Returns an error listing every missing or invalid var.
|
|
||||||
func Load() (*Config, error) {
|
|
||||||
// Non-fatal: .env may not exist in production.
|
|
||||||
_ = godotenv.Load()
|
|
||||||
|
|
||||||
var errs []error
|
|
||||||
|
|
||||||
requireStr := func(key string) string {
|
|
||||||
v := os.Getenv(key)
|
|
||||||
if v == "" {
|
|
||||||
errs = append(errs, fmt.Errorf("%s is required", key))
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultStr := func(key, def string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
parseDuration := func(key, def string) time.Duration {
|
|
||||||
raw := defaultStr(key, def)
|
|
||||||
d, err := time.ParseDuration(raw)
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("%s: invalid duration %q: %w", key, raw, err))
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
parseInt := func(key string, def int) int {
|
|
||||||
raw := os.Getenv(key)
|
|
||||||
if raw == "" {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(raw)
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("%s: invalid integer %q: %w", key, raw, err))
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &Config{
|
|
||||||
ListenAddr: defaultStr("LISTEN_ADDR", ":8080"),
|
|
||||||
JWTSecret: requireStr("JWT_SECRET"),
|
|
||||||
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
|
|
||||||
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
|
|
||||||
|
|
||||||
DatabaseURL: requireStr("DATABASE_URL"),
|
|
||||||
|
|
||||||
FilesPath: requireStr("FILES_PATH"),
|
|
||||||
ThumbsCachePath: requireStr("THUMBS_CACHE_PATH"),
|
|
||||||
|
|
||||||
ThumbWidth: parseInt("THUMB_WIDTH", 160),
|
|
||||||
ThumbHeight: parseInt("THUMB_HEIGHT", 160),
|
|
||||||
PreviewWidth: parseInt("PREVIEW_WIDTH", 1920),
|
|
||||||
PreviewHeight: parseInt("PREVIEW_HEIGHT", 1080),
|
|
||||||
|
|
||||||
ImportPath: requireStr("IMPORT_PATH"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(errs) > 0 {
|
|
||||||
return nil, errors.Join(errs...)
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// Package db provides shared helpers used by all database adapters.
|
|
||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
)
|
|
||||||
|
|
||||||
// txKey is the context key used to store an active transaction.
|
|
||||||
type txKey struct{}
|
|
||||||
|
|
||||||
// TxFromContext returns the pgx.Tx stored in ctx by the Transactor, along
|
|
||||||
// with a boolean indicating whether a transaction is active.
|
|
||||||
func TxFromContext(ctx context.Context) (pgx.Tx, bool) {
|
|
||||||
tx, ok := ctx.Value(txKey{}).(pgx.Tx)
|
|
||||||
return tx, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContextWithTx returns a copy of ctx that carries tx.
|
|
||||||
// Called by the Transactor before invoking the user function.
|
|
||||||
func ContextWithTx(ctx context.Context, tx pgx.Tx) context.Context {
|
|
||||||
return context.WithValue(ctx, txKey{}, tx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Querier is the common query interface satisfied by both *pgxpool.Pool and
|
|
||||||
// pgx.Tx, allowing repo helpers to work with either.
|
|
||||||
type Querier interface {
|
|
||||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
|
||||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
|
||||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScanRow executes a single-row query against q and scans the result using
|
|
||||||
// scan. It wraps pgx.ErrNoRows so callers can detect missing rows without
|
|
||||||
// importing pgx directly.
|
|
||||||
func ScanRow[T any](ctx context.Context, q Querier, sql string, args []any, scan func(pgx.Row) (T, error)) (T, error) {
|
|
||||||
row := q.QueryRow(ctx, sql, args...)
|
|
||||||
val, err := scan(row)
|
|
||||||
if err != nil {
|
|
||||||
var zero T
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return zero, fmt.Errorf("%w", pgx.ErrNoRows)
|
|
||||||
}
|
|
||||||
return zero, fmt.Errorf("ScanRow: %w", err)
|
|
||||||
}
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClampLimit enforces the [1, max] range on limit, returning def when limit
|
|
||||||
// is zero or negative.
|
|
||||||
func ClampLimit(limit, def, max int) int {
|
|
||||||
if limit <= 0 {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
if limit > max {
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
return limit
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClampOffset returns 0 for negative offsets.
|
|
||||||
func ClampOffset(offset int) int {
|
|
||||||
if offset < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return offset
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
type permissionRow struct {
|
|
||||||
UserID int16 `db:"user_id"`
|
|
||||||
UserName string `db:"user_name"`
|
|
||||||
ObjectTypeID int16 `db:"object_type_id"`
|
|
||||||
ObjectID uuid.UUID `db:"object_id"`
|
|
||||||
CanView bool `db:"can_view"`
|
|
||||||
CanEdit bool `db:"can_edit"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPermission(r permissionRow) domain.Permission {
|
|
||||||
return domain.Permission{
|
|
||||||
UserID: r.UserID,
|
|
||||||
UserName: r.UserName,
|
|
||||||
ObjectTypeID: r.ObjectTypeID,
|
|
||||||
ObjectID: r.ObjectID,
|
|
||||||
CanView: r.CanView,
|
|
||||||
CanEdit: r.CanEdit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACLRepo implements port.ACLRepo using PostgreSQL.
|
|
||||||
type ACLRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewACLRepo creates an ACLRepo backed by pool.
|
|
||||||
func NewACLRepo(pool *pgxpool.Pool) *ACLRepo {
|
|
||||||
return &ACLRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.ACLRepo = (*ACLRepo)(nil)
|
|
||||||
|
|
||||||
func (r *ACLRepo) List(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT p.user_id, u.name AS user_name, p.object_type_id, p.object_id,
|
|
||||||
p.can_view, p.can_edit
|
|
||||||
FROM acl.permissions p
|
|
||||||
JOIN core.users u ON u.id = p.user_id
|
|
||||||
WHERE p.object_type_id = $1 AND p.object_id = $2
|
|
||||||
ORDER BY u.name`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, objectTypeID, objectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ACLRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[permissionRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ACLRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
perms := make([]domain.Permission, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
perms[i] = toPermission(row)
|
|
||||||
}
|
|
||||||
return perms, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ACLRepo) Get(ctx context.Context, userID int16, objectTypeID int16, objectID uuid.UUID) (*domain.Permission, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT p.user_id, u.name AS user_name, p.object_type_id, p.object_id,
|
|
||||||
p.can_view, p.can_edit
|
|
||||||
FROM acl.permissions p
|
|
||||||
JOIN core.users u ON u.id = p.user_id
|
|
||||||
WHERE p.user_id = $1 AND p.object_type_id = $2 AND p.object_id = $3`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, userID, objectTypeID, objectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ACLRepo.Get: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[permissionRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("ACLRepo.Get scan: %w", err)
|
|
||||||
}
|
|
||||||
p := toPermission(row)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ACLRepo) Set(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error {
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
|
|
||||||
const del = `DELETE FROM acl.permissions WHERE object_type_id = $1 AND object_id = $2`
|
|
||||||
if _, err := q.Exec(ctx, del, objectTypeID, objectID); err != nil {
|
|
||||||
return fmt.Errorf("ACLRepo.Set delete: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(perms) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const ins = `
|
|
||||||
INSERT INTO acl.permissions (user_id, object_type_id, object_id, can_view, can_edit)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)`
|
|
||||||
for _, p := range perms {
|
|
||||||
if _, err := q.Exec(ctx, ins, p.UserID, objectTypeID, objectID, p.CanView, p.CanEdit); err != nil {
|
|
||||||
return fmt.Errorf("ACLRepo.Set insert: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// auditRowWithTotal matches the columns returned by the audit log SELECT.
|
|
||||||
// object_type is nullable (LEFT JOIN), object_id and details are nullable columns.
|
|
||||||
type auditRowWithTotal struct {
|
|
||||||
ID int64 `db:"id"`
|
|
||||||
UserID int16 `db:"user_id"`
|
|
||||||
UserName string `db:"user_name"`
|
|
||||||
Action string `db:"action"`
|
|
||||||
ObjectType *string `db:"object_type"`
|
|
||||||
ObjectID *uuid.UUID `db:"object_id"`
|
|
||||||
Details json.RawMessage `db:"details"`
|
|
||||||
PerformedAt time.Time `db:"performed_at"`
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toAuditEntry(r auditRowWithTotal) domain.AuditEntry {
|
|
||||||
return domain.AuditEntry{
|
|
||||||
ID: r.ID,
|
|
||||||
UserID: r.UserID,
|
|
||||||
UserName: r.UserName,
|
|
||||||
Action: r.Action,
|
|
||||||
ObjectType: r.ObjectType,
|
|
||||||
ObjectID: r.ObjectID,
|
|
||||||
Details: r.Details,
|
|
||||||
PerformedAt: r.PerformedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditRepo implements port.AuditRepo using PostgreSQL.
|
|
||||||
type AuditRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuditRepo creates an AuditRepo backed by pool.
|
|
||||||
func NewAuditRepo(pool *pgxpool.Pool) *AuditRepo {
|
|
||||||
return &AuditRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.AuditRepo = (*AuditRepo)(nil)
|
|
||||||
|
|
||||||
// Log inserts one audit record. action_type_id and object_type_id are resolved
|
|
||||||
// from the reference tables inside the INSERT via subqueries.
|
|
||||||
func (r *AuditRepo) Log(ctx context.Context, entry domain.AuditEntry) error {
|
|
||||||
const sql = `
|
|
||||||
INSERT INTO activity.audit_log
|
|
||||||
(user_id, action_type_id, object_type_id, object_id, details)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
(SELECT id FROM activity.action_types WHERE name = $2),
|
|
||||||
CASE WHEN $3::text IS NOT NULL
|
|
||||||
THEN (SELECT id FROM core.object_types WHERE name = $3)
|
|
||||||
ELSE NULL END,
|
|
||||||
$4,
|
|
||||||
$5
|
|
||||||
)`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
_, err := q.Exec(ctx, sql,
|
|
||||||
entry.UserID,
|
|
||||||
entry.Action,
|
|
||||||
entry.ObjectType,
|
|
||||||
entry.ObjectID,
|
|
||||||
entry.Details,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("AuditRepo.Log: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns a filtered, offset-paginated page of audit log entries ordered
|
|
||||||
// newest-first.
|
|
||||||
func (r *AuditRepo) List(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error) {
|
|
||||||
var conds []string
|
|
||||||
args := make([]any, 0, 8)
|
|
||||||
n := 1
|
|
||||||
|
|
||||||
if filter.UserID != nil {
|
|
||||||
conds = append(conds, fmt.Sprintf("a.user_id = $%d", n))
|
|
||||||
args = append(args, *filter.UserID)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.Action != "" {
|
|
||||||
conds = append(conds, fmt.Sprintf("at.name = $%d", n))
|
|
||||||
args = append(args, filter.Action)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.ObjectType != "" {
|
|
||||||
conds = append(conds, fmt.Sprintf("ot.name = $%d", n))
|
|
||||||
args = append(args, filter.ObjectType)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.ObjectID != nil {
|
|
||||||
conds = append(conds, fmt.Sprintf("a.object_id = $%d", n))
|
|
||||||
args = append(args, *filter.ObjectID)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.From != nil {
|
|
||||||
conds = append(conds, fmt.Sprintf("a.performed_at >= $%d", n))
|
|
||||||
args = append(args, *filter.From)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.To != nil {
|
|
||||||
conds = append(conds, fmt.Sprintf("a.performed_at <= $%d", n))
|
|
||||||
args = append(args, *filter.To)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
|
|
||||||
where := ""
|
|
||||||
if len(conds) > 0 {
|
|
||||||
where = "WHERE " + strings.Join(conds, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := db.ClampLimit(filter.Limit, 50, 200)
|
|
||||||
offset := db.ClampOffset(filter.Offset)
|
|
||||||
args = append(args, limit, offset)
|
|
||||||
|
|
||||||
sql := fmt.Sprintf(`
|
|
||||||
SELECT a.id, a.user_id, u.name AS user_name,
|
|
||||||
at.name AS action,
|
|
||||||
ot.name AS object_type,
|
|
||||||
a.object_id, a.details,
|
|
||||||
a.performed_at,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM activity.audit_log a
|
|
||||||
JOIN core.users u ON u.id = a.user_id
|
|
||||||
JOIN activity.action_types at ON at.id = a.action_type_id
|
|
||||||
LEFT JOIN core.object_types ot ON ot.id = a.object_type_id
|
|
||||||
%s
|
|
||||||
ORDER BY a.performed_at DESC
|
|
||||||
LIMIT $%d OFFSET $%d`, where, n, n+1)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("AuditRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[auditRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("AuditRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
page := &domain.AuditPage{Offset: offset, Limit: limit}
|
|
||||||
if len(collected) > 0 {
|
|
||||||
page.Total = collected[0].Total
|
|
||||||
}
|
|
||||||
page.Items = make([]domain.AuditEntry, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
page.Items[i] = toAuditEntry(row)
|
|
||||||
}
|
|
||||||
return page, nil
|
|
||||||
}
|
|
||||||
@@ -1,795 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Row structs
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type fileRow struct {
|
|
||||||
ID uuid.UUID `db:"id"`
|
|
||||||
OriginalName *string `db:"original_name"`
|
|
||||||
MIMEType string `db:"mime_type"`
|
|
||||||
MIMEExtension string `db:"mime_extension"`
|
|
||||||
ContentDatetime time.Time `db:"content_datetime"`
|
|
||||||
Notes *string `db:"notes"`
|
|
||||||
Metadata json.RawMessage `db:"metadata"`
|
|
||||||
EXIF json.RawMessage `db:"exif"`
|
|
||||||
PHash *int64 `db:"phash"`
|
|
||||||
CreatorID int16 `db:"creator_id"`
|
|
||||||
CreatorName string `db:"creator_name"`
|
|
||||||
IsPublic bool `db:"is_public"`
|
|
||||||
IsDeleted bool `db:"is_deleted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileTagRow is used for both single-file and batch tag loading.
|
|
||||||
// file_id is always selected so the same struct works for both cases.
|
|
||||||
type fileTagRow struct {
|
|
||||||
FileID uuid.UUID `db:"file_id"`
|
|
||||||
ID uuid.UUID `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Notes *string `db:"notes"`
|
|
||||||
Color *string `db:"color"`
|
|
||||||
CategoryID *uuid.UUID `db:"category_id"`
|
|
||||||
CategoryName *string `db:"category_name"`
|
|
||||||
CategoryColor *string `db:"category_color"`
|
|
||||||
Metadata json.RawMessage `db:"metadata"`
|
|
||||||
CreatorID int16 `db:"creator_id"`
|
|
||||||
CreatorName string `db:"creator_name"`
|
|
||||||
IsPublic bool `db:"is_public"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// anchorValRow holds the sort-column values fetched for an anchor file.
|
|
||||||
type anchorValRow struct {
|
|
||||||
ContentDatetime time.Time `db:"content_datetime"`
|
|
||||||
OriginalName string `db:"original_name"` // COALESCE(original_name,'') applied in SQL
|
|
||||||
MIMEType string `db:"mime_type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Converters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func toFile(r fileRow) domain.File {
|
|
||||||
return domain.File{
|
|
||||||
ID: r.ID,
|
|
||||||
OriginalName: r.OriginalName,
|
|
||||||
MIMEType: r.MIMEType,
|
|
||||||
MIMEExtension: r.MIMEExtension,
|
|
||||||
ContentDatetime: r.ContentDatetime,
|
|
||||||
Notes: r.Notes,
|
|
||||||
Metadata: r.Metadata,
|
|
||||||
EXIF: r.EXIF,
|
|
||||||
PHash: r.PHash,
|
|
||||||
CreatorID: r.CreatorID,
|
|
||||||
CreatorName: r.CreatorName,
|
|
||||||
IsPublic: r.IsPublic,
|
|
||||||
IsDeleted: r.IsDeleted,
|
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toTagFromFileTag(r fileTagRow) domain.Tag {
|
|
||||||
return domain.Tag{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Notes: r.Notes,
|
|
||||||
Color: r.Color,
|
|
||||||
CategoryID: r.CategoryID,
|
|
||||||
CategoryName: r.CategoryName,
|
|
||||||
CategoryColor: r.CategoryColor,
|
|
||||||
Metadata: r.Metadata,
|
|
||||||
CreatorID: r.CreatorID,
|
|
||||||
CreatorName: r.CreatorName,
|
|
||||||
IsPublic: r.IsPublic,
|
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cursor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type fileCursor struct {
|
|
||||||
Sort string `json:"s"` // canonical sort name
|
|
||||||
Order string `json:"o"` // "ASC" or "DESC"
|
|
||||||
ID string `json:"id"` // UUID of the boundary file
|
|
||||||
Val string `json:"v"` // sort column value; empty for "created" (id IS the key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func encodeCursor(c fileCursor) string {
|
|
||||||
b, _ := json.Marshal(c)
|
|
||||||
return base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeCursor(s string) (fileCursor, error) {
|
|
||||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
|
||||||
if err != nil {
|
|
||||||
return fileCursor{}, fmt.Errorf("cursor: invalid encoding")
|
|
||||||
}
|
|
||||||
var c fileCursor
|
|
||||||
if err := json.Unmarshal(b, &c); err != nil {
|
|
||||||
return fileCursor{}, fmt.Errorf("cursor: invalid format")
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeCursor builds a fileCursor from a boundary row and the current sort/order.
|
|
||||||
func makeCursor(r fileRow, sort, order string) fileCursor {
|
|
||||||
var val string
|
|
||||||
switch sort {
|
|
||||||
case "content_datetime":
|
|
||||||
val = r.ContentDatetime.UTC().Format(time.RFC3339Nano)
|
|
||||||
case "original_name":
|
|
||||||
if r.OriginalName != nil {
|
|
||||||
val = *r.OriginalName
|
|
||||||
}
|
|
||||||
case "mime":
|
|
||||||
val = r.MIMEType
|
|
||||||
// "created": val is empty; f.id is the sort key.
|
|
||||||
}
|
|
||||||
return fileCursor{Sort: sort, Order: order, ID: r.ID.String(), Val: val}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sort helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func normSort(s string) string {
|
|
||||||
switch s {
|
|
||||||
case "content_datetime", "original_name", "mime":
|
|
||||||
return s
|
|
||||||
default:
|
|
||||||
return "created"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normOrder(o string) string {
|
|
||||||
if strings.EqualFold(o, "asc") {
|
|
||||||
return "ASC"
|
|
||||||
}
|
|
||||||
return "DESC"
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildKeysetCond returns a keyset WHERE fragment and an ORDER BY fragment.
|
|
||||||
//
|
|
||||||
// - forward=true: items after the cursor in the sort order (standard next-page)
|
|
||||||
// - forward=false: items before the cursor (previous-page); ORDER BY is reversed,
|
|
||||||
// caller must reverse the result slice after fetching
|
|
||||||
// - incl=true: include the cursor file itself (anchor case; uses ≤ / ≥)
|
|
||||||
//
|
|
||||||
// All user values are bound as parameters — no SQL injection possible.
|
|
||||||
func buildKeysetCond(
|
|
||||||
sort, order string,
|
|
||||||
forward, incl bool,
|
|
||||||
cursorID uuid.UUID, cursorVal string,
|
|
||||||
n int, args []any,
|
|
||||||
) (where, orderBy string, nextN int, outArgs []any) {
|
|
||||||
// goDown=true → want smaller values → primary comparison is "<".
|
|
||||||
// Applies for DESC+forward and ASC+backward.
|
|
||||||
goDown := (order == "DESC") == forward
|
|
||||||
|
|
||||||
var op, idOp string
|
|
||||||
if goDown {
|
|
||||||
op = "<"
|
|
||||||
if incl {
|
|
||||||
idOp = "<="
|
|
||||||
} else {
|
|
||||||
idOp = "<"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
op = ">"
|
|
||||||
if incl {
|
|
||||||
idOp = ">="
|
|
||||||
} else {
|
|
||||||
idOp = ">"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Effective ORDER BY direction: reversed for backward so the DB returns
|
|
||||||
// the closest items first (the ones we keep after trimming the extra).
|
|
||||||
dir := order
|
|
||||||
if !forward {
|
|
||||||
if order == "DESC" {
|
|
||||||
dir = "ASC"
|
|
||||||
} else {
|
|
||||||
dir = "DESC"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch sort {
|
|
||||||
case "created":
|
|
||||||
// Single-column keyset: f.id (UUID v7, so ordering = chronological).
|
|
||||||
where = fmt.Sprintf("f.id %s $%d", idOp, n)
|
|
||||||
orderBy = fmt.Sprintf("f.id %s", dir)
|
|
||||||
outArgs = append(args, cursorID)
|
|
||||||
n++
|
|
||||||
|
|
||||||
case "content_datetime":
|
|
||||||
// Two-column keyset: (content_datetime, id).
|
|
||||||
// $n is referenced twice in the SQL (< and =) but passed once in args —
|
|
||||||
// PostgreSQL extended protocol allows multiple references to $N.
|
|
||||||
t, _ := time.Parse(time.RFC3339Nano, cursorVal)
|
|
||||||
where = fmt.Sprintf(
|
|
||||||
"(f.content_datetime %s $%d OR (f.content_datetime = $%d AND f.id %s $%d))",
|
|
||||||
op, n, n, idOp, n+1)
|
|
||||||
orderBy = fmt.Sprintf("f.content_datetime %s, f.id %s", dir, dir)
|
|
||||||
outArgs = append(args, t, cursorID)
|
|
||||||
n += 2
|
|
||||||
|
|
||||||
case "original_name":
|
|
||||||
// COALESCE treats NULL names as '' for stable pagination.
|
|
||||||
where = fmt.Sprintf(
|
|
||||||
"(COALESCE(f.original_name,'') %s $%d OR (COALESCE(f.original_name,'') = $%d AND f.id %s $%d))",
|
|
||||||
op, n, n, idOp, n+1)
|
|
||||||
orderBy = fmt.Sprintf("COALESCE(f.original_name,'') %s, f.id %s", dir, dir)
|
|
||||||
outArgs = append(args, cursorVal, cursorID)
|
|
||||||
n += 2
|
|
||||||
|
|
||||||
default: // "mime"
|
|
||||||
where = fmt.Sprintf(
|
|
||||||
"(mt.name %s $%d OR (mt.name = $%d AND f.id %s $%d))",
|
|
||||||
op, n, n, idOp, n+1)
|
|
||||||
orderBy = fmt.Sprintf("mt.name %s, f.id %s", dir, dir)
|
|
||||||
outArgs = append(args, cursorVal, cursorID)
|
|
||||||
n += 2
|
|
||||||
}
|
|
||||||
|
|
||||||
nextN = n
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultOrderBy returns the natural ORDER BY for the first page (no cursor).
|
|
||||||
func defaultOrderBy(sort, order string) string {
|
|
||||||
switch sort {
|
|
||||||
case "created":
|
|
||||||
return fmt.Sprintf("f.id %s", order)
|
|
||||||
case "content_datetime":
|
|
||||||
return fmt.Sprintf("f.content_datetime %s, f.id %s", order, order)
|
|
||||||
case "original_name":
|
|
||||||
return fmt.Sprintf("COALESCE(f.original_name,'') %s, f.id %s", order, order)
|
|
||||||
default: // "mime"
|
|
||||||
return fmt.Sprintf("mt.name %s, f.id %s", order, order)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// FileRepo
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// FileRepo implements port.FileRepo using PostgreSQL.
|
|
||||||
type FileRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileRepo creates a FileRepo backed by pool.
|
|
||||||
func NewFileRepo(pool *pgxpool.Pool) *FileRepo {
|
|
||||||
return &FileRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.FileRepo = (*FileRepo)(nil)
|
|
||||||
|
|
||||||
// fileSelectCTE is the SELECT appended after a CTE named "r" that exposes
|
|
||||||
// all file columns (including mime_id). Used by Create, Update, and Restore
|
|
||||||
// to get the full denormalized record in a single round-trip.
|
|
||||||
const fileSelectCTE = `
|
|
||||||
SELECT r.id, r.original_name,
|
|
||||||
mt.name AS mime_type, mt.extension AS mime_extension,
|
|
||||||
r.content_datetime, r.notes, r.metadata, r.exif, r.phash,
|
|
||||||
r.creator_id, u.name AS creator_name,
|
|
||||||
r.is_public, r.is_deleted
|
|
||||||
FROM r
|
|
||||||
JOIN core.mime_types mt ON mt.id = r.mime_id
|
|
||||||
JOIN core.users u ON u.id = r.creator_id`
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Create
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Create inserts a new file record. The MIME type is resolved from
|
|
||||||
// f.MIMEType (name string) via a subquery; the DB generates the UUID v7 id.
|
|
||||||
func (r *FileRepo) Create(ctx context.Context, f *domain.File) (*domain.File, error) {
|
|
||||||
const sqlStr = `
|
|
||||||
WITH r AS (
|
|
||||||
INSERT INTO data.files
|
|
||||||
(original_name, mime_id, content_datetime, notes, metadata, exif, phash, creator_id, is_public)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
(SELECT id FROM core.mime_types WHERE name = $2),
|
|
||||||
$3, $4, $5, $6, $7, $8, $9
|
|
||||||
)
|
|
||||||
RETURNING id, original_name, mime_id, content_datetime, notes,
|
|
||||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
|
||||||
)` + fileSelectCTE
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr,
|
|
||||||
f.OriginalName, f.MIMEType, f.ContentDatetime,
|
|
||||||
f.Notes, f.Metadata, f.EXIF, f.PHash,
|
|
||||||
f.CreatorID, f.IsPublic,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toFile(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GetByID
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *FileRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
|
||||||
const sqlStr = `
|
|
||||||
SELECT f.id, f.original_name,
|
|
||||||
mt.name AS mime_type, mt.extension AS mime_extension,
|
|
||||||
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
|
|
||||||
f.creator_id, u.name AS creator_name,
|
|
||||||
f.is_public, f.is_deleted
|
|
||||||
FROM data.files f
|
|
||||||
JOIN core.mime_types mt ON mt.id = f.mime_id
|
|
||||||
JOIN core.users u ON u.id = f.creator_id
|
|
||||||
WHERE f.id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("FileRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
f := toFile(row)
|
|
||||||
tags, err := r.ListTags(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
f.Tags = tags
|
|
||||||
return &f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Update
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Update applies editable metadata fields. MIME type and EXIF are immutable.
|
|
||||||
func (r *FileRepo) Update(ctx context.Context, id uuid.UUID, f *domain.File) (*domain.File, error) {
|
|
||||||
const sqlStr = `
|
|
||||||
WITH r AS (
|
|
||||||
UPDATE data.files
|
|
||||||
SET original_name = $2,
|
|
||||||
content_datetime = $3,
|
|
||||||
notes = $4,
|
|
||||||
metadata = $5,
|
|
||||||
is_public = $6
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, original_name, mime_id, content_datetime, notes,
|
|
||||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
|
||||||
)` + fileSelectCTE
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr,
|
|
||||||
id, f.OriginalName, f.ContentDatetime,
|
|
||||||
f.Notes, f.Metadata, f.IsPublic,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.Update: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("FileRepo.Update scan: %w", err)
|
|
||||||
}
|
|
||||||
updated := toFile(row)
|
|
||||||
tags, err := r.ListTags(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
updated.Tags = tags
|
|
||||||
return &updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SoftDelete / Restore / DeletePermanent
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// SoftDelete moves a file to trash (is_deleted = true). Returns ErrNotFound
|
|
||||||
// if the file does not exist or is already in trash.
|
|
||||||
func (r *FileRepo) SoftDelete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
const sqlStr = `UPDATE data.files SET is_deleted = true WHERE id = $1 AND is_deleted = false`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sqlStr, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("FileRepo.SoftDelete: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore moves a file out of trash (is_deleted = false). Returns ErrNotFound
|
|
||||||
// if the file does not exist or is not in trash.
|
|
||||||
func (r *FileRepo) Restore(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
|
||||||
const sqlStr = `
|
|
||||||
WITH r AS (
|
|
||||||
UPDATE data.files
|
|
||||||
SET is_deleted = false
|
|
||||||
WHERE id = $1 AND is_deleted = true
|
|
||||||
RETURNING id, original_name, mime_id, content_datetime, notes,
|
|
||||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
|
||||||
)` + fileSelectCTE
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.Restore: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("FileRepo.Restore scan: %w", err)
|
|
||||||
}
|
|
||||||
restored := toFile(row)
|
|
||||||
tags, err := r.ListTags(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
restored.Tags = tags
|
|
||||||
return &restored, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeletePermanent removes a file record permanently. Only allowed when the
|
|
||||||
// file is already in trash (is_deleted = true).
|
|
||||||
func (r *FileRepo) DeletePermanent(ctx context.Context, id uuid.UUID) error {
|
|
||||||
const sqlStr = `DELETE FROM data.files WHERE id = $1 AND is_deleted = true`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sqlStr, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("FileRepo.DeletePermanent: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ListTags / SetTags
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ListTags returns all tags assigned to a file, ordered by tag name.
|
|
||||||
func (r *FileRepo) ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
|
|
||||||
m, err := r.loadTagsBatch(ctx, []uuid.UUID{fileID})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return m[fileID], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetTags replaces all tags on a file (full replace semantics).
|
|
||||||
func (r *FileRepo) SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error {
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
const del = `DELETE FROM data.file_tag WHERE file_id = $1`
|
|
||||||
if _, err := q.Exec(ctx, del, fileID); err != nil {
|
|
||||||
return fmt.Errorf("FileRepo.SetTags delete: %w", err)
|
|
||||||
}
|
|
||||||
if len(tagIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
const ins = `INSERT INTO data.file_tag (file_id, tag_id) VALUES ($1, $2)`
|
|
||||||
for _, tagID := range tagIDs {
|
|
||||||
if _, err := q.Exec(ctx, ins, fileID, tagID); err != nil {
|
|
||||||
return fmt.Errorf("FileRepo.SetTags insert: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// List
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// List returns a cursor-paginated page of files.
|
|
||||||
//
|
|
||||||
// Pagination is keyset-based for stable performance on large tables.
|
|
||||||
// Cursor encodes the sort position; the caller provides direction.
|
|
||||||
// Anchor mode centres the result around a specific file UUID.
|
|
||||||
func (r *FileRepo) List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error) {
|
|
||||||
sort := normSort(params.Sort)
|
|
||||||
order := normOrder(params.Order)
|
|
||||||
forward := params.Direction != "backward"
|
|
||||||
limit := db.ClampLimit(params.Limit, 50, 200)
|
|
||||||
|
|
||||||
// --- resolve cursor / anchor ---
|
|
||||||
var (
|
|
||||||
cursorID uuid.UUID
|
|
||||||
cursorVal string
|
|
||||||
hasCursor bool
|
|
||||||
isAnchor bool
|
|
||||||
)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case params.Cursor != "":
|
|
||||||
cur, err := decodeCursor(params.Cursor)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
|
|
||||||
}
|
|
||||||
id, err := uuid.Parse(cur.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrValidation
|
|
||||||
}
|
|
||||||
// Lock in the sort/order encoded in the cursor so changing query
|
|
||||||
// parameters mid-session doesn't corrupt pagination.
|
|
||||||
sort = normSort(cur.Sort)
|
|
||||||
order = normOrder(cur.Order)
|
|
||||||
cursorID = id
|
|
||||||
cursorVal = cur.Val
|
|
||||||
hasCursor = true
|
|
||||||
|
|
||||||
case params.Anchor != nil:
|
|
||||||
av, err := r.fetchAnchorVals(ctx, *params.Anchor)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cursorID = *params.Anchor
|
|
||||||
switch sort {
|
|
||||||
case "content_datetime":
|
|
||||||
cursorVal = av.ContentDatetime.UTC().Format(time.RFC3339Nano)
|
|
||||||
case "original_name":
|
|
||||||
cursorVal = av.OriginalName
|
|
||||||
case "mime":
|
|
||||||
cursorVal = av.MIMEType
|
|
||||||
// "created": cursorVal stays ""; cursorID is the sort key.
|
|
||||||
}
|
|
||||||
hasCursor = true
|
|
||||||
isAnchor = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without a cursor there is no meaningful "backward" direction.
|
|
||||||
if !hasCursor {
|
|
||||||
forward = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- build WHERE and ORDER BY ---
|
|
||||||
var conds []string
|
|
||||||
args := make([]any, 0, 8)
|
|
||||||
n := 1
|
|
||||||
|
|
||||||
conds = append(conds, fmt.Sprintf("f.is_deleted = $%d", n))
|
|
||||||
args = append(args, params.Trash)
|
|
||||||
n++
|
|
||||||
|
|
||||||
if params.Search != "" {
|
|
||||||
conds = append(conds, fmt.Sprintf("f.original_name ILIKE $%d", n))
|
|
||||||
args = append(args, "%"+params.Search+"%")
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
|
|
||||||
if params.Filter != "" {
|
|
||||||
filterSQL, nextN, filterArgs, err := ParseFilter(params.Filter, n)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
|
|
||||||
}
|
|
||||||
if filterSQL != "" {
|
|
||||||
conds = append(conds, filterSQL)
|
|
||||||
n = nextN
|
|
||||||
args = append(args, filterArgs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var orderBy string
|
|
||||||
if hasCursor {
|
|
||||||
ksWhere, ksOrder, nextN, ksArgs := buildKeysetCond(
|
|
||||||
sort, order, forward, isAnchor, cursorID, cursorVal, n, args)
|
|
||||||
conds = append(conds, ksWhere)
|
|
||||||
n = nextN
|
|
||||||
args = ksArgs
|
|
||||||
orderBy = ksOrder
|
|
||||||
} else {
|
|
||||||
orderBy = defaultOrderBy(sort, order)
|
|
||||||
}
|
|
||||||
|
|
||||||
where := ""
|
|
||||||
if len(conds) > 0 {
|
|
||||||
where = "WHERE " + strings.Join(conds, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch one extra row to detect whether more items exist beyond this page.
|
|
||||||
args = append(args, limit+1)
|
|
||||||
sqlStr := fmt.Sprintf(`
|
|
||||||
SELECT f.id, f.original_name,
|
|
||||||
mt.name AS mime_type, mt.extension AS mime_extension,
|
|
||||||
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
|
|
||||||
f.creator_id, u.name AS creator_name,
|
|
||||||
f.is_public, f.is_deleted
|
|
||||||
FROM data.files f
|
|
||||||
JOIN core.mime_types mt ON mt.id = f.mime_id
|
|
||||||
JOIN core.users u ON u.id = f.creator_id
|
|
||||||
%s
|
|
||||||
ORDER BY %s
|
|
||||||
LIMIT $%d`, where, orderBy, n)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- trim extra row and reverse for backward ---
|
|
||||||
hasMore := len(collected) > limit
|
|
||||||
if hasMore {
|
|
||||||
collected = collected[:limit]
|
|
||||||
}
|
|
||||||
if !forward {
|
|
||||||
// Results were fetched in reversed ORDER BY; invert to restore the
|
|
||||||
// natural sort order expected by the caller.
|
|
||||||
for i, j := 0, len(collected)-1; i < j; i, j = i+1, j-1 {
|
|
||||||
collected[i], collected[j] = collected[j], collected[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- assemble page ---
|
|
||||||
page := &domain.FilePage{
|
|
||||||
Items: make([]domain.File, len(collected)),
|
|
||||||
}
|
|
||||||
for i, row := range collected {
|
|
||||||
page.Items[i] = toFile(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- set next/prev cursors ---
|
|
||||||
// next_cursor: navigate further in the forward direction.
|
|
||||||
// prev_cursor: navigate further in the backward direction.
|
|
||||||
if len(collected) > 0 {
|
|
||||||
firstCur := encodeCursor(makeCursor(collected[0], sort, order))
|
|
||||||
lastCur := encodeCursor(makeCursor(collected[len(collected)-1], sort, order))
|
|
||||||
|
|
||||||
if forward {
|
|
||||||
// We only know a prev page exists if we arrived via cursor.
|
|
||||||
if hasCursor {
|
|
||||||
page.PrevCursor = &firstCur
|
|
||||||
}
|
|
||||||
if hasMore {
|
|
||||||
page.NextCursor = &lastCur
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Backward: last item (after reversal) is closest to original cursor.
|
|
||||||
if hasCursor {
|
|
||||||
page.NextCursor = &lastCur
|
|
||||||
}
|
|
||||||
if hasMore {
|
|
||||||
page.PrevCursor = &firstCur
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- batch-load tags ---
|
|
||||||
if len(page.Items) > 0 {
|
|
||||||
fileIDs := make([]uuid.UUID, len(page.Items))
|
|
||||||
for i, f := range page.Items {
|
|
||||||
fileIDs[i] = f.ID
|
|
||||||
}
|
|
||||||
tagMap, err := r.loadTagsBatch(ctx, fileIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for i, f := range page.Items {
|
|
||||||
page.Items[i].Tags = tagMap[f.ID] // nil becomes []domain.Tag{} via loadTagsBatch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return page, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// fetchAnchorVals returns the sort-column values for the given file.
|
|
||||||
// Used to set up a cursor when the caller provides an anchor UUID.
|
|
||||||
func (r *FileRepo) fetchAnchorVals(ctx context.Context, fileID uuid.UUID) (*anchorValRow, error) {
|
|
||||||
const sqlStr = `
|
|
||||||
SELECT f.content_datetime,
|
|
||||||
COALESCE(f.original_name, '') AS original_name,
|
|
||||||
mt.name AS mime_type
|
|
||||||
FROM data.files f
|
|
||||||
JOIN core.mime_types mt ON mt.id = f.mime_id
|
|
||||||
WHERE f.id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.fetchAnchorVals: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[anchorValRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("FileRepo.fetchAnchorVals scan: %w", err)
|
|
||||||
}
|
|
||||||
return &row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadTagsBatch fetches tags for multiple files in a single query and returns
|
|
||||||
// them as a map keyed by file ID. Every requested file ID appears as a key
|
|
||||||
// (with an empty slice if the file has no tags).
|
|
||||||
func (r *FileRepo) loadTagsBatch(ctx context.Context, fileIDs []uuid.UUID) (map[uuid.UUID][]domain.Tag, error) {
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a parameterised IN list. The max page size is 200, so at most 200
|
|
||||||
// placeholders — well within PostgreSQL's limits.
|
|
||||||
placeholders := make([]string, len(fileIDs))
|
|
||||||
args := make([]any, len(fileIDs))
|
|
||||||
for i, id := range fileIDs {
|
|
||||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
||||||
args[i] = id
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlStr := fmt.Sprintf(`
|
|
||||||
SELECT ft.file_id,
|
|
||||||
t.id, t.name, t.notes, t.color,
|
|
||||||
t.category_id,
|
|
||||||
c.name AS category_name,
|
|
||||||
c.color AS category_color,
|
|
||||||
t.metadata, t.creator_id, u.name AS creator_name, t.is_public
|
|
||||||
FROM data.file_tag ft
|
|
||||||
JOIN data.tags t ON t.id = ft.tag_id
|
|
||||||
JOIN core.users u ON u.id = t.creator_id
|
|
||||||
LEFT JOIN data.categories c ON c.id = t.category_id
|
|
||||||
WHERE ft.file_id IN (%s)
|
|
||||||
ORDER BY ft.file_id, t.name`, strings.Join(placeholders, ","))
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.loadTagsBatch: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileTagRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.loadTagsBatch scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make(map[uuid.UUID][]domain.Tag, len(fileIDs))
|
|
||||||
for _, fid := range fileIDs {
|
|
||||||
result[fid] = []domain.Tag{} // guarantee every key has a non-nil slice
|
|
||||||
}
|
|
||||||
for _, row := range collected {
|
|
||||||
result[row.FileID] = append(result[row.FileID], toTagFromFileTag(row))
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Token types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type filterTokenKind int
|
|
||||||
|
|
||||||
const (
|
|
||||||
ftkAnd filterTokenKind = iota
|
|
||||||
ftkOr
|
|
||||||
ftkNot
|
|
||||||
ftkLParen
|
|
||||||
ftkRParen
|
|
||||||
ftkTag // t=<uuid>
|
|
||||||
ftkMimeExact // m=<int>
|
|
||||||
ftkMimeLike // m~<pattern>
|
|
||||||
)
|
|
||||||
|
|
||||||
type filterToken struct {
|
|
||||||
kind filterTokenKind
|
|
||||||
tagID uuid.UUID // ftkTag
|
|
||||||
untagged bool // ftkTag with zero UUID → "file has no tags"
|
|
||||||
mimeID int16 // ftkMimeExact
|
|
||||||
pattern string // ftkMimeLike
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// AST nodes
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// filterNode produces a parameterized SQL fragment.
|
|
||||||
// n is the index of the next available positional parameter ($n).
|
|
||||||
// Returns the fragment, the updated n, and the extended args slice.
|
|
||||||
type filterNode interface {
|
|
||||||
toSQL(n int, args []any) (string, int, []any)
|
|
||||||
}
|
|
||||||
|
|
||||||
type andNode struct{ left, right filterNode }
|
|
||||||
type orNode struct{ left, right filterNode }
|
|
||||||
type notNode struct{ child filterNode }
|
|
||||||
type leafNode struct{ tok filterToken }
|
|
||||||
|
|
||||||
func (a *andNode) toSQL(n int, args []any) (string, int, []any) {
|
|
||||||
ls, n, args := a.left.toSQL(n, args)
|
|
||||||
rs, n, args := a.right.toSQL(n, args)
|
|
||||||
return "(" + ls + " AND " + rs + ")", n, args
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *orNode) toSQL(n int, args []any) (string, int, []any) {
|
|
||||||
ls, n, args := o.left.toSQL(n, args)
|
|
||||||
rs, n, args := o.right.toSQL(n, args)
|
|
||||||
return "(" + ls + " OR " + rs + ")", n, args
|
|
||||||
}
|
|
||||||
|
|
||||||
func (no *notNode) toSQL(n int, args []any) (string, int, []any) {
|
|
||||||
cs, n, args := no.child.toSQL(n, args)
|
|
||||||
return "(NOT " + cs + ")", n, args
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *leafNode) toSQL(n int, args []any) (string, int, []any) {
|
|
||||||
switch l.tok.kind {
|
|
||||||
case ftkTag:
|
|
||||||
if l.tok.untagged {
|
|
||||||
return "NOT EXISTS (SELECT 1 FROM data.file_tag ft WHERE ft.file_id = f.id)", n, args
|
|
||||||
}
|
|
||||||
s := fmt.Sprintf(
|
|
||||||
"EXISTS (SELECT 1 FROM data.file_tag ft WHERE ft.file_id = f.id AND ft.tag_id = $%d)", n)
|
|
||||||
return s, n + 1, append(args, l.tok.tagID)
|
|
||||||
case ftkMimeExact:
|
|
||||||
return fmt.Sprintf("f.mime_id = $%d", n), n + 1, append(args, l.tok.mimeID)
|
|
||||||
case ftkMimeLike:
|
|
||||||
// mt alias comes from the JOIN in the main file query (always present).
|
|
||||||
return fmt.Sprintf("mt.name LIKE $%d", n), n + 1, append(args, l.tok.pattern)
|
|
||||||
}
|
|
||||||
panic("filterNode.toSQL: unknown leaf kind")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Lexer
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// lexFilter tokenises the DSL string {a,b,c,...} into filterTokens.
|
|
||||||
func lexFilter(dsl string) ([]filterToken, error) {
|
|
||||||
dsl = strings.TrimSpace(dsl)
|
|
||||||
if !strings.HasPrefix(dsl, "{") || !strings.HasSuffix(dsl, "}") {
|
|
||||||
return nil, fmt.Errorf("filter DSL must be wrapped in braces: {…}")
|
|
||||||
}
|
|
||||||
inner := strings.TrimSpace(dsl[1 : len(dsl)-1])
|
|
||||||
if inner == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(inner, ",")
|
|
||||||
tokens := make([]filterToken, 0, len(parts))
|
|
||||||
|
|
||||||
for _, raw := range parts {
|
|
||||||
p := strings.TrimSpace(raw)
|
|
||||||
switch {
|
|
||||||
case p == "&":
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkAnd})
|
|
||||||
case p == "|":
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkOr})
|
|
||||||
case p == "!":
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkNot})
|
|
||||||
case p == "(":
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkLParen})
|
|
||||||
case p == ")":
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkRParen})
|
|
||||||
case strings.HasPrefix(p, "t="):
|
|
||||||
id, err := uuid.Parse(p[2:])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("filter: invalid tag UUID %q", p[2:])
|
|
||||||
}
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkTag, tagID: id, untagged: id == uuid.Nil})
|
|
||||||
case strings.HasPrefix(p, "m="):
|
|
||||||
v, err := strconv.ParseInt(p[2:], 10, 16)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("filter: invalid MIME ID %q", p[2:])
|
|
||||||
}
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkMimeExact, mimeID: int16(v)})
|
|
||||||
case strings.HasPrefix(p, "m~"):
|
|
||||||
// The pattern value is passed as a query parameter, so no SQL injection risk.
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkMimeLike, pattern: p[2:]})
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("filter: unknown token %q", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tokens, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Recursive-descent parser
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type filterParser struct {
|
|
||||||
tokens []filterToken
|
|
||||||
pos int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *filterParser) peek() (filterToken, bool) {
|
|
||||||
if p.pos >= len(p.tokens) {
|
|
||||||
return filterToken{}, false
|
|
||||||
}
|
|
||||||
return p.tokens[p.pos], true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *filterParser) next() filterToken {
|
|
||||||
t := p.tokens[p.pos]
|
|
||||||
p.pos++
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grammar (standard NOT > AND > OR precedence):
|
|
||||||
//
|
|
||||||
// expr := or_expr
|
|
||||||
// or_expr := and_expr ('|' and_expr)*
|
|
||||||
// and_expr := not_expr ('&' not_expr)*
|
|
||||||
// not_expr := '!' not_expr | atom
|
|
||||||
// atom := '(' expr ')' | leaf
|
|
||||||
|
|
||||||
func (p *filterParser) parseExpr() (filterNode, error) { return p.parseOr() }
|
|
||||||
|
|
||||||
func (p *filterParser) parseOr() (filterNode, error) {
|
|
||||||
left, err := p.parseAnd()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
t, ok := p.peek()
|
|
||||||
if !ok || t.kind != ftkOr {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
p.next()
|
|
||||||
right, err := p.parseAnd()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
left = &orNode{left, right}
|
|
||||||
}
|
|
||||||
return left, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *filterParser) parseAnd() (filterNode, error) {
|
|
||||||
left, err := p.parseNot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
t, ok := p.peek()
|
|
||||||
if !ok || t.kind != ftkAnd {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
p.next()
|
|
||||||
right, err := p.parseNot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
left = &andNode{left, right}
|
|
||||||
}
|
|
||||||
return left, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *filterParser) parseNot() (filterNode, error) {
|
|
||||||
t, ok := p.peek()
|
|
||||||
if ok && t.kind == ftkNot {
|
|
||||||
p.next()
|
|
||||||
child, err := p.parseNot() // right-recursive to allow !!x
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ¬Node{child}, nil
|
|
||||||
}
|
|
||||||
return p.parseAtom()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *filterParser) parseAtom() (filterNode, error) {
|
|
||||||
t, ok := p.peek()
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("filter: unexpected end of expression")
|
|
||||||
}
|
|
||||||
if t.kind == ftkLParen {
|
|
||||||
p.next()
|
|
||||||
expr, err := p.parseExpr()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rp, ok := p.peek()
|
|
||||||
if !ok || rp.kind != ftkRParen {
|
|
||||||
return nil, fmt.Errorf("filter: expected ')'")
|
|
||||||
}
|
|
||||||
p.next()
|
|
||||||
return expr, nil
|
|
||||||
}
|
|
||||||
switch t.kind {
|
|
||||||
case ftkTag, ftkMimeExact, ftkMimeLike:
|
|
||||||
p.next()
|
|
||||||
return &leafNode{t}, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("filter: unexpected token at position %d", p.pos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public entry point
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ParseFilter parses a filter DSL string into a parameterized SQL fragment.
|
|
||||||
//
|
|
||||||
// argStart is the 1-based index for the first $N placeholder; this lets the
|
|
||||||
// caller interleave filter parameters with other query parameters.
|
|
||||||
//
|
|
||||||
// Returns ("", argStart, nil, nil) for an empty or trivial DSL.
|
|
||||||
// SQL injection is structurally impossible: every user-supplied value is
|
|
||||||
// bound as a query parameter ($N), never interpolated into the SQL string.
|
|
||||||
func ParseFilter(dsl string, argStart int) (sql string, nextN int, args []any, err error) {
|
|
||||||
dsl = strings.TrimSpace(dsl)
|
|
||||||
if dsl == "" || dsl == "{}" {
|
|
||||||
return "", argStart, nil, nil
|
|
||||||
}
|
|
||||||
toks, err := lexFilter(dsl)
|
|
||||||
if err != nil {
|
|
||||||
return "", argStart, nil, err
|
|
||||||
}
|
|
||||||
if len(toks) == 0 {
|
|
||||||
return "", argStart, nil, nil
|
|
||||||
}
|
|
||||||
p := &filterParser{tokens: toks}
|
|
||||||
node, err := p.parseExpr()
|
|
||||||
if err != nil {
|
|
||||||
return "", argStart, nil, err
|
|
||||||
}
|
|
||||||
if p.pos != len(p.tokens) {
|
|
||||||
return "", argStart, nil, fmt.Errorf("filter: trailing tokens at position %d", p.pos)
|
|
||||||
}
|
|
||||||
sql, nextN, args = node.toSQL(argStart, nil)
|
|
||||||
return sql, nextN, args, nil
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mimeRow struct {
|
|
||||||
ID int16 `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Extension string `db:"extension"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toMIMEType(r mimeRow) domain.MIMEType {
|
|
||||||
return domain.MIMEType{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Extension: r.Extension,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MimeRepo implements port.MimeRepo using PostgreSQL.
|
|
||||||
type MimeRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMimeRepo creates a MimeRepo backed by pool.
|
|
||||||
func NewMimeRepo(pool *pgxpool.Pool) *MimeRepo {
|
|
||||||
return &MimeRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.MimeRepo = (*MimeRepo)(nil)
|
|
||||||
|
|
||||||
func (r *MimeRepo) List(ctx context.Context) ([]domain.MIMEType, error) {
|
|
||||||
const sql = `SELECT id, name, extension FROM core.mime_types ORDER BY name`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MimeRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[mimeRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MimeRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]domain.MIMEType, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
result[i] = toMIMEType(row)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *MimeRepo) GetByID(ctx context.Context, id int16) (*domain.MIMEType, error) {
|
|
||||||
const sql = `SELECT id, name, extension FROM core.mime_types WHERE id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MimeRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[mimeRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("MimeRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
m := toMIMEType(row)
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *MimeRepo) GetByName(ctx context.Context, name string) (*domain.MIMEType, error) {
|
|
||||||
const sql = `SELECT id, name, extension FROM core.mime_types WHERE name = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MimeRepo.GetByName: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[mimeRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrUnsupportedMIME
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("MimeRepo.GetByName scan: %w", err)
|
|
||||||
}
|
|
||||||
m := toMIMEType(row)
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
// Package postgres provides the PostgreSQL implementations of the port interfaces.
|
|
||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewPool creates and validates a *pgxpool.Pool from the given connection URL.
|
|
||||||
// The pool is ready to use; the caller is responsible for closing it.
|
|
||||||
func NewPool(ctx context.Context, url string) (*pgxpool.Pool, error) {
|
|
||||||
pool, err := pgxpool.New(ctx, url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("pgxpool.New: %w", err)
|
|
||||||
}
|
|
||||||
if err := pool.Ping(ctx); err != nil {
|
|
||||||
pool.Close()
|
|
||||||
return nil, fmt.Errorf("postgres ping: %w", err)
|
|
||||||
}
|
|
||||||
return pool, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transactor implements port.Transactor using a pgxpool.Pool.
|
|
||||||
type Transactor struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTransactor creates a Transactor backed by pool.
|
|
||||||
func NewTransactor(pool *pgxpool.Pool) *Transactor {
|
|
||||||
return &Transactor{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithTx begins a transaction, stores it in ctx, and calls fn. If fn returns
|
|
||||||
// an error the transaction is rolled back; otherwise it is committed.
|
|
||||||
func (t *Transactor) WithTx(ctx context.Context, fn func(ctx context.Context) error) error {
|
|
||||||
tx, err := t.pool.BeginTx(ctx, pgx.TxOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("begin tx: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
txCtx := db.ContextWithTx(ctx, tx)
|
|
||||||
|
|
||||||
if err := fn(txCtx); err != nil {
|
|
||||||
_ = tx.Rollback(ctx)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return fmt.Errorf("commit tx: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// connOrTx returns the pgx.Tx stored in ctx by WithTx, or the pool itself when
|
|
||||||
// no transaction is active. The returned value satisfies db.Querier and can be
|
|
||||||
// used directly for queries and commands.
|
|
||||||
func connOrTx(ctx context.Context, pool *pgxpool.Pool) db.Querier {
|
|
||||||
if tx, ok := db.TxFromContext(ctx); ok {
|
|
||||||
return tx
|
|
||||||
}
|
|
||||||
return pool
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// sessionRow matches the columns stored in activity.sessions.
|
|
||||||
// IsCurrent is a service-layer concern and is not stored in the database.
|
|
||||||
type sessionRow struct {
|
|
||||||
ID int `db:"id"`
|
|
||||||
TokenHash string `db:"token_hash"`
|
|
||||||
UserID int16 `db:"user_id"`
|
|
||||||
UserAgent string `db:"user_agent"`
|
|
||||||
StartedAt time.Time `db:"started_at"`
|
|
||||||
ExpiresAt *time.Time `db:"expires_at"`
|
|
||||||
LastActivity time.Time `db:"last_activity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionRowWithTotal extends sessionRow with a window-function count for ListByUser.
|
|
||||||
type sessionRowWithTotal struct {
|
|
||||||
sessionRow
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toSession(r sessionRow) domain.Session {
|
|
||||||
return domain.Session{
|
|
||||||
ID: r.ID,
|
|
||||||
TokenHash: r.TokenHash,
|
|
||||||
UserID: r.UserID,
|
|
||||||
UserAgent: r.UserAgent,
|
|
||||||
StartedAt: r.StartedAt,
|
|
||||||
ExpiresAt: r.ExpiresAt,
|
|
||||||
LastActivity: r.LastActivity,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionRepo implements port.SessionRepo using PostgreSQL.
|
|
||||||
type SessionRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSessionRepo creates a SessionRepo backed by pool.
|
|
||||||
func NewSessionRepo(pool *pgxpool.Pool) *SessionRepo {
|
|
||||||
return &SessionRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.SessionRepo = (*SessionRepo)(nil)
|
|
||||||
|
|
||||||
func (r *SessionRepo) Create(ctx context.Context, s *domain.Session) (*domain.Session, error) {
|
|
||||||
const sql = `
|
|
||||||
INSERT INTO activity.sessions (token_hash, user_id, user_agent, expires_at)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, token_hash, user_id, user_agent, started_at, expires_at, last_activity`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, s.TokenHash, s.UserID, s.UserAgent, s.ExpiresAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toSession(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity
|
|
||||||
FROM activity.sessions
|
|
||||||
WHERE token_hash = $1 AND is_active = true`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, hash)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.GetByTokenHash: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("SessionRepo.GetByTokenHash scan: %w", err)
|
|
||||||
}
|
|
||||||
s := toSession(row)
|
|
||||||
return &s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM activity.sessions
|
|
||||||
WHERE user_id = $1 AND is_active = true
|
|
||||||
ORDER BY started_at DESC`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.ListByUser: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[sessionRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.ListByUser scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
list := &domain.SessionList{}
|
|
||||||
if len(collected) > 0 {
|
|
||||||
list.Total = collected[0].Total
|
|
||||||
}
|
|
||||||
list.Items = make([]domain.Session, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
list.Items[i] = toSession(row.sessionRow)
|
|
||||||
}
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) UpdateLastActivity(ctx context.Context, id int, t time.Time) error {
|
|
||||||
const sql = `UPDATE activity.sessions SET last_activity = $2 WHERE id = $1`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sql, id, t)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("SessionRepo.UpdateLastActivity: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) Delete(ctx context.Context, id int) error {
|
|
||||||
const sql = `UPDATE activity.sessions SET is_active = false WHERE id = $1 AND is_active = true`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sql, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("SessionRepo.Delete: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) DeleteByUserID(ctx context.Context, userID int16) error {
|
|
||||||
const sql = `UPDATE activity.sessions SET is_active = false WHERE user_id = $1 AND is_active = true`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
_, err := q.Exec(ctx, sql, userID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("SessionRepo.DeleteByUserID: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// userRow matches the columns returned by every user SELECT.
|
|
||||||
type userRow struct {
|
|
||||||
ID int16 `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Password string `db:"password"`
|
|
||||||
IsAdmin bool `db:"is_admin"`
|
|
||||||
CanCreate bool `db:"can_create"`
|
|
||||||
IsBlocked bool `db:"is_blocked"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// userRowWithTotal extends userRow with a window-function total for List.
|
|
||||||
type userRowWithTotal struct {
|
|
||||||
userRow
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toUser(r userRow) domain.User {
|
|
||||||
return domain.User{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Password: r.Password,
|
|
||||||
IsAdmin: r.IsAdmin,
|
|
||||||
CanCreate: r.CanCreate,
|
|
||||||
IsBlocked: r.IsBlocked,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// userSortColumn whitelists valid sort keys to prevent SQL injection.
|
|
||||||
var userSortColumn = map[string]string{
|
|
||||||
"name": "name",
|
|
||||||
"id": "id",
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserRepo implements port.UserRepo using PostgreSQL.
|
|
||||||
type UserRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUserRepo creates a UserRepo backed by pool.
|
|
||||||
func NewUserRepo(pool *pgxpool.Pool) *UserRepo {
|
|
||||||
return &UserRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.UserRepo = (*UserRepo)(nil)
|
|
||||||
|
|
||||||
func (r *UserRepo) List(ctx context.Context, params port.OffsetParams) (*domain.UserPage, error) {
|
|
||||||
col, ok := userSortColumn[params.Sort]
|
|
||||||
if !ok {
|
|
||||||
col = "id"
|
|
||||||
}
|
|
||||||
ord := "ASC"
|
|
||||||
if params.Order == "desc" {
|
|
||||||
ord = "DESC"
|
|
||||||
}
|
|
||||||
limit := db.ClampLimit(params.Limit, 50, 200)
|
|
||||||
offset := db.ClampOffset(params.Offset)
|
|
||||||
|
|
||||||
sql := fmt.Sprintf(`
|
|
||||||
SELECT id, name, password, is_admin, can_create, is_blocked,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM core.users
|
|
||||||
ORDER BY %s %s
|
|
||||||
LIMIT $1 OFFSET $2`, col, ord)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[userRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
page := &domain.UserPage{Offset: offset, Limit: limit}
|
|
||||||
if len(collected) > 0 {
|
|
||||||
page.Total = collected[0].Total
|
|
||||||
}
|
|
||||||
page.Items = make([]domain.User, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
page.Items[i] = toUser(row.userRow)
|
|
||||||
}
|
|
||||||
return page, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) GetByID(ctx context.Context, id int16) (*domain.User, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, name, password, is_admin, can_create, is_blocked
|
|
||||||
FROM core.users WHERE id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("UserRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
u := toUser(row)
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) GetByName(ctx context.Context, name string) (*domain.User, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, name, password, is_admin, can_create, is_blocked
|
|
||||||
FROM core.users WHERE name = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.GetByName: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("UserRepo.GetByName scan: %w", err)
|
|
||||||
}
|
|
||||||
u := toUser(row)
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) Create(ctx context.Context, u *domain.User) (*domain.User, error) {
|
|
||||||
const sql = `
|
|
||||||
INSERT INTO core.users (name, password, is_admin, can_create)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, name, password, is_admin, can_create, is_blocked`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, u.Name, u.Password, u.IsAdmin, u.CanCreate)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toUser(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) Update(ctx context.Context, id int16, u *domain.User) (*domain.User, error) {
|
|
||||||
const sql = `
|
|
||||||
UPDATE core.users
|
|
||||||
SET name = $2, password = $3, is_admin = $4, can_create = $5
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, name, password, is_admin, can_create, is_blocked`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, id, u.Name, u.Password, u.IsAdmin, u.CanCreate)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.Update: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("UserRepo.Update scan: %w", err)
|
|
||||||
}
|
|
||||||
updated := toUser(row)
|
|
||||||
return &updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) Delete(ctx context.Context, id int16) error {
|
|
||||||
const sql = `DELETE FROM core.users WHERE id = $1`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sql, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("UserRepo.Delete: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "github.com/google/uuid"
|
|
||||||
|
|
||||||
// ObjectType is a reference entity (file, tag, category, pool).
|
|
||||||
type ObjectType struct {
|
|
||||||
ID int16
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permission represents a per-object access entry for a user.
|
|
||||||
type Permission struct {
|
|
||||||
UserID int16
|
|
||||||
UserName string // denormalized
|
|
||||||
ObjectTypeID int16
|
|
||||||
ObjectID uuid.UUID
|
|
||||||
CanView bool
|
|
||||||
CanEdit bool
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ActionType is a reference entity for auditable user actions.
|
|
||||||
type ActionType struct {
|
|
||||||
ID int16
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditEntry is a single audit log record.
|
|
||||||
type AuditEntry struct {
|
|
||||||
ID int64
|
|
||||||
UserID int16
|
|
||||||
UserName string // denormalized
|
|
||||||
Action string // action type name, e.g. "file_create"
|
|
||||||
ObjectType *string
|
|
||||||
ObjectID *uuid.UUID
|
|
||||||
Details json.RawMessage
|
|
||||||
PerformedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditPage is an offset-based page of audit log entries.
|
|
||||||
type AuditPage struct {
|
|
||||||
Items []AuditEntry
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditFilter holds filter parameters for querying the audit log.
|
|
||||||
type AuditFilter struct {
|
|
||||||
UserID *int16
|
|
||||||
Action string
|
|
||||||
ObjectType string
|
|
||||||
ObjectID *uuid.UUID
|
|
||||||
From *time.Time
|
|
||||||
To *time.Time
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Category is a logical grouping of tags.
|
|
||||||
type Category struct {
|
|
||||||
ID uuid.UUID
|
|
||||||
Name string
|
|
||||||
Notes *string
|
|
||||||
Color *string // 6-char hex
|
|
||||||
Metadata json.RawMessage
|
|
||||||
CreatorID int16
|
|
||||||
CreatorName string // denormalized
|
|
||||||
IsPublic bool
|
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// CategoryOffsetPage is an offset-based page of categories.
|
|
||||||
type CategoryOffsetPage struct {
|
|
||||||
Items []Category
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
type ctxKey int
|
|
||||||
|
|
||||||
const userKey ctxKey = iota
|
|
||||||
|
|
||||||
type contextUser struct {
|
|
||||||
ID int16
|
|
||||||
IsAdmin bool
|
|
||||||
SessionID int
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithUser stores user identity and current session ID in ctx.
|
|
||||||
func WithUser(ctx context.Context, userID int16, isAdmin bool, sessionID int) context.Context {
|
|
||||||
return context.WithValue(ctx, userKey, contextUser{
|
|
||||||
ID: userID,
|
|
||||||
IsAdmin: isAdmin,
|
|
||||||
SessionID: sessionID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserFromContext retrieves user identity from ctx.
|
|
||||||
// Returns zero values if no user is stored.
|
|
||||||
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool, sessionID int) {
|
|
||||||
u, ok := ctx.Value(userKey).(contextUser)
|
|
||||||
if !ok {
|
|
||||||
return 0, false, 0
|
|
||||||
}
|
|
||||||
return u.ID, u.IsAdmin, u.SessionID
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,6 @@ package domain
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
@@ -20,9 +18,9 @@ type MIME struct {
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
CategoryCore struct {
|
CategoryCore struct {
|
||||||
ID string `json:"id"`
|
ID *string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Color pgtype.Text `json:"color"`
|
Color *string `json:"color"`
|
||||||
}
|
}
|
||||||
CategoryItem struct {
|
CategoryItem struct {
|
||||||
CategoryCore
|
CategoryCore
|
||||||
@@ -31,14 +29,14 @@ type (
|
|||||||
CategoryCore
|
CategoryCore
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Creator User `json:"creator"`
|
Creator User `json:"creator"`
|
||||||
Notes pgtype.Text `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
FileCore struct {
|
FileCore struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name pgtype.Text `json:"name"`
|
Name *string `json:"name"`
|
||||||
MIME MIME `json:"mime"`
|
MIME MIME `json:"mime"`
|
||||||
}
|
}
|
||||||
FileItem struct {
|
FileItem struct {
|
||||||
@@ -50,9 +48,8 @@ type (
|
|||||||
FileCore
|
FileCore
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Creator User `json:"creator"`
|
Creator User `json:"creator"`
|
||||||
Notes pgtype.Text `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
Metadata json.RawMessage `json:"metadata"`
|
Metadata json.RawMessage `json:"metadata"`
|
||||||
Tags []TagCore `json:"tags"`
|
|
||||||
Viewed int `json:"viewed"`
|
Viewed int `json:"viewed"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -61,7 +58,7 @@ type (
|
|||||||
TagCore struct {
|
TagCore struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Color pgtype.Text `json:"color"`
|
Color *string `json:"color"`
|
||||||
}
|
}
|
||||||
TagItem struct {
|
TagItem struct {
|
||||||
TagCore
|
TagCore
|
||||||
@@ -72,7 +69,7 @@ type (
|
|||||||
Category CategoryCore `json:"category"`
|
Category CategoryCore `json:"category"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Creator User `json:"creator"`
|
Creator User `json:"creator"`
|
||||||
Notes pgtype.Text `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
UsedIncl int `json:"usedIncl"`
|
UsedIncl int `json:"usedIncl"`
|
||||||
UsedExcl int `json:"usedExcl"`
|
UsedExcl int `json:"usedExcl"`
|
||||||
}
|
}
|
||||||
@@ -96,7 +93,7 @@ type (
|
|||||||
PoolCore
|
PoolCore
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Creator User `json:"creator"`
|
Creator User `json:"creator"`
|
||||||
Notes pgtype.Text `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
Viewed int `json:"viewed"`
|
Viewed int `json:"viewed"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1,21 +1,65 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
// DomainError is a typed domain error with a stable machine-readable code.
|
import "fmt"
|
||||||
// Handlers map these codes to HTTP status codes.
|
|
||||||
|
type ErrorCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// File errors
|
||||||
|
ErrCodeFileNotFound ErrorCode = "FILE_NOT_FOUND"
|
||||||
|
ErrCodeMIMENotSupported ErrorCode = "MIME_NOT_SUPPORTED"
|
||||||
|
|
||||||
|
// Tag errors
|
||||||
|
ErrCodeTagNotFound ErrorCode = "TAG_NOT_FOUND"
|
||||||
|
|
||||||
|
// General errors
|
||||||
|
ErrCodeBadRequest ErrorCode = "BAD_REQUEST"
|
||||||
|
ErrCodeInternal ErrorCode = "INTERNAL_SERVER_ERROR"
|
||||||
|
)
|
||||||
|
|
||||||
type DomainError struct {
|
type DomainError struct {
|
||||||
code string
|
Err error `json:"-"`
|
||||||
message string
|
Code ErrorCode `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details []any `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *DomainError) Error() string { return e.message }
|
func (e *DomainError) Wrap(err error) *DomainError {
|
||||||
func (e *DomainError) Code() string { return e.code }
|
e.Err = err
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
// Sentinel domain errors. Use errors.Is(err, domain.ErrNotFound) for matching.
|
func NewErrorFileNotFound(file_id string) *DomainError {
|
||||||
var (
|
return &DomainError{
|
||||||
ErrNotFound = &DomainError{"not_found", "not found"}
|
Code: ErrCodeFileNotFound,
|
||||||
ErrForbidden = &DomainError{"forbidden", "forbidden"}
|
Message: fmt.Sprintf("File not found: %q", file_id),
|
||||||
ErrUnauthorized = &DomainError{"unauthorized", "unauthorized"}
|
}
|
||||||
ErrConflict = &DomainError{"conflict", "conflict"}
|
}
|
||||||
ErrValidation = &DomainError{"validation_error", "validation error"}
|
|
||||||
ErrUnsupportedMIME = &DomainError{"unsupported_mime", "unsupported MIME type"}
|
func NewErrorMIMENotSupported(mime string) *DomainError {
|
||||||
)
|
return &DomainError{
|
||||||
|
Code: ErrCodeMIMENotSupported,
|
||||||
|
Message: fmt.Sprintf("MIME not supported: %q", mime),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewErrorTagNotFound(tag_id string) *DomainError {
|
||||||
|
return &DomainError{
|
||||||
|
Code: ErrCodeTagNotFound,
|
||||||
|
Message: fmt.Sprintf("Tag not found: %q", tag_id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewErrorBadRequest(message string) *DomainError {
|
||||||
|
return &DomainError{
|
||||||
|
Code: ErrCodeBadRequest,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewErrorUnexpected() *DomainError {
|
||||||
|
return &DomainError{
|
||||||
|
Code: ErrCodeInternal,
|
||||||
|
Message: "An unexpected error occured",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MIMEType holds MIME whitelist data.
|
|
||||||
type MIMEType struct {
|
|
||||||
ID int16
|
|
||||||
Name string
|
|
||||||
Extension string
|
|
||||||
}
|
|
||||||
|
|
||||||
// File represents a managed file record.
|
|
||||||
type File struct {
|
|
||||||
ID uuid.UUID
|
|
||||||
OriginalName *string
|
|
||||||
MIMEType string // denormalized from core.mime_types
|
|
||||||
MIMEExtension string // denormalized from core.mime_types
|
|
||||||
ContentDatetime time.Time
|
|
||||||
Notes *string
|
|
||||||
Metadata json.RawMessage
|
|
||||||
EXIF json.RawMessage
|
|
||||||
PHash *int64
|
|
||||||
CreatorID int16
|
|
||||||
CreatorName string // denormalized from core.users
|
|
||||||
IsPublic bool
|
|
||||||
IsDeleted bool
|
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
|
||||||
Tags []Tag // loaded with the file
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileListParams holds all parameters for listing/filtering files.
|
|
||||||
type FileListParams struct {
|
|
||||||
// Pagination
|
|
||||||
Cursor string
|
|
||||||
Direction string // "forward" or "backward"
|
|
||||||
Anchor *uuid.UUID
|
|
||||||
Limit int
|
|
||||||
|
|
||||||
// Sorting
|
|
||||||
Sort string // "content_datetime" | "created" | "original_name" | "mime"
|
|
||||||
Order string // "asc" | "desc"
|
|
||||||
|
|
||||||
// Filtering
|
|
||||||
Filter string // filter DSL expression
|
|
||||||
Search string // substring match on original_name
|
|
||||||
Trash bool // if true, return only soft-deleted files
|
|
||||||
}
|
|
||||||
|
|
||||||
// FilePage is the result of a cursor-based file listing.
|
|
||||||
type FilePage struct {
|
|
||||||
Items []File
|
|
||||||
NextCursor *string
|
|
||||||
PrevCursor *string
|
|
||||||
}
|
|
||||||
|
|
||||||
// UUIDCreatedAt extracts the creation timestamp embedded in a UUID v7.
|
|
||||||
// UUID v7 stores Unix milliseconds in the most-significant 48 bits.
|
|
||||||
func UUIDCreatedAt(id uuid.UUID) time.Time {
|
|
||||||
ms := int64(id[0])<<40 | int64(id[1])<<32 | int64(id[2])<<24 |
|
|
||||||
int64(id[3])<<16 | int64(id[4])<<8 | int64(id[5])
|
|
||||||
return time.Unix(ms/1000, (ms%1000)*int64(time.Millisecond)).UTC()
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pool is an ordered collection of files.
|
|
||||||
type Pool struct {
|
|
||||||
ID uuid.UUID
|
|
||||||
Name string
|
|
||||||
Notes *string
|
|
||||||
Metadata json.RawMessage
|
|
||||||
CreatorID int16
|
|
||||||
CreatorName string // denormalized
|
|
||||||
IsPublic bool
|
|
||||||
FileCount int
|
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolFile is a File with its ordering position within a pool.
|
|
||||||
type PoolFile struct {
|
|
||||||
File
|
|
||||||
Position int
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolFilePage is the result of a cursor-based pool file listing.
|
|
||||||
type PoolFilePage struct {
|
|
||||||
Items []PoolFile
|
|
||||||
NextCursor *string
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolOffsetPage is an offset-based page of pools.
|
|
||||||
type PoolOffsetPage struct {
|
|
||||||
Items []Pool
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileRepository interface {
|
||||||
|
GetAccess(ctx context.Context, user_id int, file_id string) (canView, canEdit bool, domainErr *DomainError)
|
||||||
|
GetSlice(ctx context.Context, user_id int, filter, sort string, limit, offset int) (files Slice[FileItem], domainErr *DomainError)
|
||||||
|
Get(ctx context.Context, user_id int, file_id string) (file FileFull, domainErr *DomainError)
|
||||||
|
Add(ctx context.Context, user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file FileCore, domainErr *DomainError)
|
||||||
|
Update(ctx context.Context, file_id string, updates map[string]interface{}) (domainErr *DomainError)
|
||||||
|
Delete(ctx context.Context, file_id string) (domainErr *DomainError)
|
||||||
|
GetTags(ctx context.Context, user_id int, file_id string) (tags []TagItem, domainErr *DomainError)
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tag represents a file label.
|
|
||||||
type Tag struct {
|
|
||||||
ID uuid.UUID
|
|
||||||
Name string
|
|
||||||
Notes *string
|
|
||||||
Color *string // 6-char hex, e.g. "5DCAA5"
|
|
||||||
CategoryID *uuid.UUID
|
|
||||||
CategoryName *string // denormalized
|
|
||||||
CategoryColor *string // denormalized
|
|
||||||
Metadata json.RawMessage
|
|
||||||
CreatorID int16
|
|
||||||
CreatorName string // denormalized
|
|
||||||
IsPublic bool
|
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagRule defines an auto-tagging rule: when WhenTagID is applied,
|
|
||||||
// ThenTagID is automatically applied as well.
|
|
||||||
type TagRule struct {
|
|
||||||
WhenTagID uuid.UUID
|
|
||||||
ThenTagID uuid.UUID
|
|
||||||
ThenTagName string // denormalized
|
|
||||||
IsActive bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagOffsetPage is an offset-based page of tags.
|
|
||||||
type TagOffsetPage struct {
|
|
||||||
Items []Tag
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// User is an application user.
|
|
||||||
type User struct {
|
|
||||||
ID int16
|
|
||||||
Name string
|
|
||||||
Password string // bcrypt hash; only populated when needed for auth
|
|
||||||
IsAdmin bool
|
|
||||||
CanCreate bool
|
|
||||||
IsBlocked bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session is an active user session.
|
|
||||||
type Session struct {
|
|
||||||
ID int
|
|
||||||
TokenHash string
|
|
||||||
UserID int16
|
|
||||||
UserAgent string
|
|
||||||
StartedAt time.Time
|
|
||||||
ExpiresAt *time.Time
|
|
||||||
LastActivity time.Time
|
|
||||||
IsCurrent bool // true when this session matches the caller's token
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserPage is an offset-based page of users.
|
|
||||||
type UserPage struct {
|
|
||||||
Items []User
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionList is a list of sessions with a total count.
|
|
||||||
type SessionList struct {
|
|
||||||
Items []Session
|
|
||||||
Total int
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuthHandler handles all /auth endpoints.
|
|
||||||
type AuthHandler struct {
|
|
||||||
authSvc *service.AuthService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthHandler creates an AuthHandler backed by authSvc.
|
|
||||||
func NewAuthHandler(authSvc *service.AuthService) *AuthHandler {
|
|
||||||
return &AuthHandler{authSvc: authSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login handles POST /auth/login.
|
|
||||||
func (h *AuthHandler) Login(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Password string `json:"password" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pair, err := h.authSvc.Login(c.Request.Context(), req.Name, req.Password, c.GetHeader("User-Agent"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"access_token": pair.AccessToken,
|
|
||||||
"refresh_token": pair.RefreshToken,
|
|
||||||
"expires_in": pair.ExpiresIn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh handles POST /auth/refresh.
|
|
||||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pair, err := h.authSvc.Refresh(c.Request.Context(), req.RefreshToken, c.GetHeader("User-Agent"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"access_token": pair.AccessToken,
|
|
||||||
"refresh_token": pair.RefreshToken,
|
|
||||||
"expires_in": pair.ExpiresIn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout handles POST /auth/logout. Requires authentication.
|
|
||||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
|
||||||
_, _, sessionID := domain.UserFromContext(c.Request.Context())
|
|
||||||
|
|
||||||
if err := h.authSvc.Logout(c.Request.Context(), sessionID); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListSessions handles GET /auth/sessions. Requires authentication.
|
|
||||||
func (h *AuthHandler) ListSessions(c *gin.Context) {
|
|
||||||
userID, _, sessionID := domain.UserFromContext(c.Request.Context())
|
|
||||||
|
|
||||||
list, err := h.authSvc.ListSessions(c.Request.Context(), userID, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type sessionItem struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
UserAgent string `json:"user_agent"`
|
|
||||||
StartedAt string `json:"started_at"`
|
|
||||||
ExpiresAt any `json:"expires_at"`
|
|
||||||
LastActivity string `json:"last_activity"`
|
|
||||||
IsCurrent bool `json:"is_current"`
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]sessionItem, len(list.Items))
|
|
||||||
for i, s := range list.Items {
|
|
||||||
var expiresAt any
|
|
||||||
if s.ExpiresAt != nil {
|
|
||||||
expiresAt = s.ExpiresAt.Format("2006-01-02T15:04:05Z07:00")
|
|
||||||
}
|
|
||||||
items[i] = sessionItem{
|
|
||||||
ID: s.ID,
|
|
||||||
UserAgent: s.UserAgent,
|
|
||||||
StartedAt: s.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
LastActivity: s.LastActivity.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
IsCurrent: s.IsCurrent,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": list.Total,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TerminateSession handles DELETE /auth/sessions/:id. Requires authentication.
|
|
||||||
func (h *AuthHandler) TerminateSession(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := strconv.Atoi(idStr)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
|
|
||||||
|
|
||||||
if err := h.authSvc.TerminateSession(c.Request.Context(), userID, isAdmin, id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
@@ -1,755 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileHandler handles all /files endpoints.
|
|
||||||
type FileHandler struct {
|
|
||||||
fileSvc *service.FileService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileHandler creates a FileHandler.
|
|
||||||
func NewFileHandler(fileSvc *service.FileService) *FileHandler {
|
|
||||||
return &FileHandler{fileSvc: fileSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type tagJSON struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
Color *string `json:"color"`
|
|
||||||
CategoryID *string `json:"category_id"`
|
|
||||||
CategoryName *string `json:"category_name"`
|
|
||||||
CategoryColor *string `json:"category_color"`
|
|
||||||
CreatorID int16 `json:"creator_id"`
|
|
||||||
CreatorName string `json:"creator_name"`
|
|
||||||
IsPublic bool `json:"is_public"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type fileJSON struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
OriginalName *string `json:"original_name"`
|
|
||||||
MIMEType string `json:"mime_type"`
|
|
||||||
MIMEExtension string `json:"mime_extension"`
|
|
||||||
ContentDatetime string `json:"content_datetime"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
Metadata json.RawMessage `json:"metadata"`
|
|
||||||
EXIF json.RawMessage `json:"exif"`
|
|
||||||
PHash *int64 `json:"phash"`
|
|
||||||
CreatorID int16 `json:"creator_id"`
|
|
||||||
CreatorName string `json:"creator_name"`
|
|
||||||
IsPublic bool `json:"is_public"`
|
|
||||||
IsDeleted bool `json:"is_deleted"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
Tags []tagJSON `json:"tags"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toTagJSON(t domain.Tag) tagJSON {
|
|
||||||
j := tagJSON{
|
|
||||||
ID: t.ID.String(),
|
|
||||||
Name: t.Name,
|
|
||||||
Notes: t.Notes,
|
|
||||||
Color: t.Color,
|
|
||||||
CategoryName: t.CategoryName,
|
|
||||||
CategoryColor: t.CategoryColor,
|
|
||||||
CreatorID: t.CreatorID,
|
|
||||||
CreatorName: t.CreatorName,
|
|
||||||
IsPublic: t.IsPublic,
|
|
||||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
if t.CategoryID != nil {
|
|
||||||
s := t.CategoryID.String()
|
|
||||||
j.CategoryID = &s
|
|
||||||
}
|
|
||||||
return j
|
|
||||||
}
|
|
||||||
|
|
||||||
func toFileJSON(f domain.File) fileJSON {
|
|
||||||
tags := make([]tagJSON, len(f.Tags))
|
|
||||||
for i, t := range f.Tags {
|
|
||||||
tags[i] = toTagJSON(t)
|
|
||||||
}
|
|
||||||
exif := f.EXIF
|
|
||||||
if exif == nil {
|
|
||||||
exif = json.RawMessage("{}")
|
|
||||||
}
|
|
||||||
return fileJSON{
|
|
||||||
ID: f.ID.String(),
|
|
||||||
OriginalName: f.OriginalName,
|
|
||||||
MIMEType: f.MIMEType,
|
|
||||||
MIMEExtension: f.MIMEExtension,
|
|
||||||
ContentDatetime: f.ContentDatetime.Format(time.RFC3339),
|
|
||||||
Notes: f.Notes,
|
|
||||||
Metadata: f.Metadata,
|
|
||||||
EXIF: exif,
|
|
||||||
PHash: f.PHash,
|
|
||||||
CreatorID: f.CreatorID,
|
|
||||||
CreatorName: f.CreatorName,
|
|
||||||
IsPublic: f.IsPublic,
|
|
||||||
IsDeleted: f.IsDeleted,
|
|
||||||
CreatedAt: f.CreatedAt.Format(time.RFC3339),
|
|
||||||
Tags: tags,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parseFileID(c *gin.Context) (uuid.UUID, bool) {
|
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return uuid.UUID{}, false
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /files
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) List(c *gin.Context) {
|
|
||||||
params := domain.FileListParams{
|
|
||||||
Cursor: c.Query("cursor"),
|
|
||||||
Direction: c.DefaultQuery("direction", "forward"),
|
|
||||||
Sort: c.DefaultQuery("sort", "created"),
|
|
||||||
Order: c.DefaultQuery("order", "desc"),
|
|
||||||
Filter: c.Query("filter"),
|
|
||||||
Search: c.Query("search"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if limitStr := c.Query("limit"); limitStr != "" {
|
|
||||||
n, err := strconv.Atoi(limitStr)
|
|
||||||
if err != nil || n < 1 || n > 200 {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.Limit = n
|
|
||||||
} else {
|
|
||||||
params.Limit = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
if anchorStr := c.Query("anchor"); anchorStr != "" {
|
|
||||||
id, err := uuid.Parse(anchorStr)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.Anchor = &id
|
|
||||||
}
|
|
||||||
|
|
||||||
if trashStr := c.Query("trash"); trashStr == "true" || trashStr == "1" {
|
|
||||||
params.Trash = true
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := h.fileSvc.List(c.Request.Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]fileJSON, len(page.Items))
|
|
||||||
for i, f := range page.Items {
|
|
||||||
items[i] = toFileJSON(f)
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"next_cursor": page.NextCursor,
|
|
||||||
"prev_cursor": page.PrevCursor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /files (multipart upload)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) Upload(c *gin.Context) {
|
|
||||||
fh, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := fh.Open()
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
// Detect MIME from actual bytes (ignore client-supplied Content-Type).
|
|
||||||
mt, err := mimetype.DetectReader(src)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Rewind by reopening — FormFile gives a multipart.File which supports Seek.
|
|
||||||
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mimeStr := strings.SplitN(mt.String(), ";", 2)[0]
|
|
||||||
|
|
||||||
params := service.UploadParams{
|
|
||||||
Reader: src,
|
|
||||||
MIMEType: mimeStr,
|
|
||||||
IsPublic: c.PostForm("is_public") == "true",
|
|
||||||
}
|
|
||||||
|
|
||||||
if name := fh.Filename; name != "" {
|
|
||||||
params.OriginalName = &name
|
|
||||||
}
|
|
||||||
if notes := c.PostForm("notes"); notes != "" {
|
|
||||||
params.Notes = ¬es
|
|
||||||
}
|
|
||||||
if metaStr := c.PostForm("metadata"); metaStr != "" {
|
|
||||||
params.Metadata = json.RawMessage(metaStr)
|
|
||||||
}
|
|
||||||
if dtStr := c.PostForm("content_datetime"); dtStr != "" {
|
|
||||||
t, err := time.Parse(time.RFC3339, dtStr)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.ContentDatetime = &t
|
|
||||||
}
|
|
||||||
if tagIDsStr := c.PostForm("tag_ids"); tagIDsStr != "" {
|
|
||||||
for _, raw := range strings.Split(tagIDsStr, ",") {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id, err := uuid.Parse(raw)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.TagIDs = append(params.TagIDs, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := h.fileSvc.Upload(c.Request.Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusCreated, toFileJSON(*f))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /files/:id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) GetMeta(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := h.fileSvc.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PATCH /files/:id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) UpdateMeta(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
OriginalName *string `json:"original_name"`
|
|
||||||
ContentDatetime *string `json:"content_datetime"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
Metadata json.RawMessage `json:"metadata"`
|
|
||||||
IsPublic *bool `json:"is_public"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := service.UpdateParams{
|
|
||||||
OriginalName: body.OriginalName,
|
|
||||||
Notes: body.Notes,
|
|
||||||
Metadata: body.Metadata,
|
|
||||||
IsPublic: body.IsPublic,
|
|
||||||
}
|
|
||||||
if body.ContentDatetime != nil {
|
|
||||||
t, err := time.Parse(time.RFC3339, *body.ContentDatetime)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.ContentDatetime = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := h.fileSvc.Update(c.Request.Context(), id, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DELETE /files/:id (soft-delete)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) SoftDelete(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.fileSvc.Delete(c.Request.Context(), id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /files/:id/content
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) GetContent(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := h.fileSvc.GetContent(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
c.Header("Content-Type", res.MIMEType)
|
|
||||||
if res.OriginalName != nil {
|
|
||||||
c.Header("Content-Disposition",
|
|
||||||
fmt.Sprintf("attachment; filename=%q", *res.OriginalName))
|
|
||||||
}
|
|
||||||
c.Status(http.StatusOK)
|
|
||||||
io.Copy(c.Writer, res.Body) //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PUT /files/:id/content (replace)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) ReplaceContent(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fh, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := fh.Open()
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
mt, err := mimetype.DetectReader(src)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mimeStr := strings.SplitN(mt.String(), ";", 2)[0]
|
|
||||||
|
|
||||||
name := fh.Filename
|
|
||||||
params := service.UploadParams{
|
|
||||||
Reader: src,
|
|
||||||
MIMEType: mimeStr,
|
|
||||||
OriginalName: &name,
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := h.fileSvc.Replace(c.Request.Context(), id, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /files/:id/thumbnail
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) GetThumbnail(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rc, err := h.fileSvc.GetThumbnail(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
|
|
||||||
c.Header("Content-Type", "image/jpeg")
|
|
||||||
c.Status(http.StatusOK)
|
|
||||||
io.Copy(c.Writer, rc) //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /files/:id/preview
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) GetPreview(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rc, err := h.fileSvc.GetPreview(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
|
|
||||||
c.Header("Content-Type", "image/jpeg")
|
|
||||||
c.Status(http.StatusOK)
|
|
||||||
io.Copy(c.Writer, rc) //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /files/:id/restore
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) Restore(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := h.fileSvc.Restore(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DELETE /files/:id/permanent
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) PermanentDelete(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.fileSvc.PermanentDelete(c.Request.Context(), id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /files/:id/tags
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) ListTags(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tags, err := h.fileSvc.ListFileTags(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]tagJSON, len(tags))
|
|
||||||
for i, t := range tags {
|
|
||||||
items[i] = toTagJSON(t)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PUT /files/:id/tags (replace all)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) SetTags(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
TagIDs []string `json:"tag_ids" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tagIDs, err := parseUUIDs(body.TagIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tags, err := h.fileSvc.SetFileTags(c.Request.Context(), id, tagIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]tagJSON, len(tags))
|
|
||||||
for i, t := range tags {
|
|
||||||
items[i] = toTagJSON(t)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PUT /files/:id/tags/:tag_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) AddTag(c *gin.Context) {
|
|
||||||
fileID, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tagID, err := uuid.Parse(c.Param("tag_id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tags, err := h.fileSvc.AddTag(c.Request.Context(), fileID, tagID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]tagJSON, len(tags))
|
|
||||||
for i, t := range tags {
|
|
||||||
items[i] = toTagJSON(t)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DELETE /files/:id/tags/:tag_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) RemoveTag(c *gin.Context) {
|
|
||||||
fileID, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tagID, err := uuid.Parse(c.Param("tag_id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.fileSvc.RemoveTag(c.Request.Context(), fileID, tagID); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /files/bulk/tags
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) BulkSetTags(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
FileIDs []string `json:"file_ids" binding:"required"`
|
|
||||||
Action string `json:"action" binding:"required"`
|
|
||||||
TagIDs []string `json:"tag_ids" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if body.Action != "add" && body.Action != "remove" {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIDs, err := parseUUIDs(body.FileIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tagIDs, err := parseUUIDs(body.TagIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
applied, err := h.fileSvc.BulkSetTags(c.Request.Context(), fileIDs, body.Action, tagIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
strs := make([]string, len(applied))
|
|
||||||
for i, id := range applied {
|
|
||||||
strs[i] = id.String()
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{"applied_tag_ids": strs})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /files/bulk/delete
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) BulkDelete(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
FileIDs []string `json:"file_ids" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIDs, err := parseUUIDs(body.FileIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.fileSvc.BulkDelete(c.Request.Context(), fileIDs); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /files/bulk/common-tags
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) CommonTags(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
FileIDs []string `json:"file_ids" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIDs, err := parseUUIDs(body.FileIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common, partial, err := h.fileSvc.CommonTags(c.Request.Context(), fileIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toStrs := func(ids []uuid.UUID) []string {
|
|
||||||
s := make([]string, len(ids))
|
|
||||||
for i, id := range ids {
|
|
||||||
s[i] = id.String()
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"common_tag_ids": toStrs(common),
|
|
||||||
"partial_tag_ids": toStrs(partial),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /files/import
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) Import(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
// Body is optional; ignore bind errors.
|
|
||||||
_ = c.ShouldBindJSON(&body)
|
|
||||||
|
|
||||||
result, err := h.fileSvc.Import(c.Request.Context(), body.Path)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parseUUIDs(strs []string) ([]uuid.UUID, error) {
|
|
||||||
ids := make([]uuid.UUID, 0, len(strs))
|
|
||||||
for _, s := range strs {
|
|
||||||
id, err := uuid.Parse(s)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuthMiddleware validates Bearer JWTs and injects user identity into context.
|
|
||||||
type AuthMiddleware struct {
|
|
||||||
authSvc *service.AuthService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthMiddleware creates an AuthMiddleware backed by authSvc.
|
|
||||||
func NewAuthMiddleware(authSvc *service.AuthService) *AuthMiddleware {
|
|
||||||
return &AuthMiddleware{authSvc: authSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle returns a Gin handler function that enforces authentication.
|
|
||||||
// On success it calls c.Next(); on failure it aborts with 401 JSON.
|
|
||||||
func (m *AuthMiddleware) Handle() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
raw := c.GetHeader("Authorization")
|
|
||||||
if !strings.HasPrefix(raw, "Bearer ") {
|
|
||||||
c.JSON(http.StatusUnauthorized, errorBody{
|
|
||||||
Code: domain.ErrUnauthorized.Code(),
|
|
||||||
Message: "authorization header missing or malformed",
|
|
||||||
})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token := strings.TrimPrefix(raw, "Bearer ")
|
|
||||||
|
|
||||||
claims, err := m.authSvc.ParseAccessToken(token)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, errorBody{
|
|
||||||
Code: domain.ErrUnauthorized.Code(),
|
|
||||||
Message: "invalid or expired token",
|
|
||||||
})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
|
|
||||||
c.Request = c.Request.WithContext(ctx)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errorBody is the JSON shape returned for all error responses.
|
|
||||||
type errorBody struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func respondJSON(c *gin.Context, status int, data any) {
|
|
||||||
c.JSON(status, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// respondError maps a domain error to the appropriate HTTP status and writes
|
|
||||||
// a JSON error body. Unknown errors become 500.
|
|
||||||
func respondError(c *gin.Context, err error) {
|
|
||||||
var de *domain.DomainError
|
|
||||||
if errors.As(err, &de) {
|
|
||||||
c.JSON(domainStatus(de), errorBody{Code: de.Code(), Message: de.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, errorBody{
|
|
||||||
Code: "internal_error",
|
|
||||||
Message: "internal server error",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// domainStatus maps a DomainError sentinel to its HTTP status code per the
|
|
||||||
// error mapping table in docs/GO_PROJECT_STRUCTURE.md.
|
|
||||||
func domainStatus(de *domain.DomainError) int {
|
|
||||||
switch de {
|
|
||||||
case domain.ErrNotFound:
|
|
||||||
return http.StatusNotFound
|
|
||||||
case domain.ErrForbidden:
|
|
||||||
return http.StatusForbidden
|
|
||||||
case domain.ErrUnauthorized:
|
|
||||||
return http.StatusUnauthorized
|
|
||||||
case domain.ErrConflict:
|
|
||||||
return http.StatusConflict
|
|
||||||
case domain.ErrValidation:
|
|
||||||
return http.StatusBadRequest
|
|
||||||
case domain.ErrUnsupportedMIME:
|
|
||||||
return http.StatusUnsupportedMediaType
|
|
||||||
default:
|
|
||||||
return http.StatusInternalServerError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewRouter builds and returns a configured Gin engine.
|
|
||||||
func NewRouter(auth *AuthMiddleware, authHandler *AuthHandler, fileHandler *FileHandler) *gin.Engine {
|
|
||||||
r := gin.New()
|
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
|
||||||
|
|
||||||
// Health check — no auth required.
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
v1 := r.Group("/api/v1")
|
|
||||||
|
|
||||||
// Auth endpoints — login and refresh are public; others require a valid token.
|
|
||||||
authGroup := v1.Group("/auth")
|
|
||||||
{
|
|
||||||
authGroup.POST("/login", authHandler.Login)
|
|
||||||
authGroup.POST("/refresh", authHandler.Refresh)
|
|
||||||
|
|
||||||
protected := authGroup.Group("", auth.Handle())
|
|
||||||
{
|
|
||||||
protected.POST("/logout", authHandler.Logout)
|
|
||||||
protected.GET("/sessions", authHandler.ListSessions)
|
|
||||||
protected.DELETE("/sessions/:id", authHandler.TerminateSession)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// File endpoints — all require authentication.
|
|
||||||
files := v1.Group("/files", auth.Handle())
|
|
||||||
{
|
|
||||||
files.GET("", fileHandler.List)
|
|
||||||
files.POST("", fileHandler.Upload)
|
|
||||||
|
|
||||||
// Bulk routes must be registered before /:id to avoid ambiguity.
|
|
||||||
files.POST("/bulk/tags", fileHandler.BulkSetTags)
|
|
||||||
files.POST("/bulk/delete", fileHandler.BulkDelete)
|
|
||||||
files.POST("/bulk/common-tags", fileHandler.CommonTags)
|
|
||||||
files.POST("/import", fileHandler.Import)
|
|
||||||
|
|
||||||
// Per-file routes.
|
|
||||||
files.GET("/:id", fileHandler.GetMeta)
|
|
||||||
files.PATCH("/:id", fileHandler.UpdateMeta)
|
|
||||||
files.DELETE("/:id", fileHandler.SoftDelete)
|
|
||||||
|
|
||||||
files.GET("/:id/content", fileHandler.GetContent)
|
|
||||||
files.PUT("/:id/content", fileHandler.ReplaceContent)
|
|
||||||
files.GET("/:id/thumbnail", fileHandler.GetThumbnail)
|
|
||||||
files.GET("/:id/preview", fileHandler.GetPreview)
|
|
||||||
files.POST("/:id/restore", fileHandler.Restore)
|
|
||||||
files.DELETE("/:id/permanent", fileHandler.PermanentDelete)
|
|
||||||
|
|
||||||
files.GET("/:id/tags", fileHandler.ListTags)
|
|
||||||
files.PUT("/:id/tags", fileHandler.SetTags)
|
|
||||||
files.PUT("/:id/tags/:tag_id", fileHandler.AddTag)
|
|
||||||
files.DELETE("/:id/tags/:tag_id", fileHandler.RemoveTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"tanabata/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize PostgreSQL database driver
|
||||||
|
func New(dbURL string) (*pgxpool.Pool, error) {
|
||||||
|
poolConfig, err := pgxpool.ParseConfig(dbURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse connection string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
poolConfig.MaxConns = 100
|
||||||
|
poolConfig.MinConns = 0
|
||||||
|
poolConfig.MaxConnLifetime = time.Hour
|
||||||
|
poolConfig.HealthCheckPeriod = 30 * time.Second
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize DB connections pool: %w", err)
|
||||||
|
}
|
||||||
|
if err = db.Ping(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction wrapper
|
||||||
|
func transaction(ctx context.Context, db *pgxpool.Pool, handler func(context.Context, pgx.Tx) *domain.DomainError) (domainErr *domain.DomainError) {
|
||||||
|
tx, err := db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainErr = handler(ctx, tx)
|
||||||
|
if domainErr != nil {
|
||||||
|
tx.Rollback(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"tanabata/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileRepository struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileRepository(db *pgxpool.Pool) *FileRepository {
|
||||||
|
return &FileRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user permissions on file
|
||||||
|
func (s *FileRepository) GetAccess(ctx context.Context, user_id int, file_id string) (canView, canEdit bool, domainErr *domain.DomainError) {
|
||||||
|
row := s.db.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
COALESCE(a.view, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE),
|
||||||
|
COALESCE(a.edit, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE)
|
||||||
|
FROM data.files f
|
||||||
|
LEFT JOIN acl.files a ON a.file_id=f.id AND a.user_id=$1
|
||||||
|
LEFT JOIN system.users u ON u.id=$1
|
||||||
|
WHERE f.id=$2
|
||||||
|
`, user_id, file_id)
|
||||||
|
err := row.Scan(&canView, &canEdit)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
switch pgErr.Code {
|
||||||
|
case "22P02":
|
||||||
|
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid file id: %q", file_id)).Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a set of files
|
||||||
|
func (s *FileRepository) GetSlice(ctx context.Context, user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], domainErr *domain.DomainError) {
|
||||||
|
filterCond, err := filterToSQL(filter)
|
||||||
|
if err != nil {
|
||||||
|
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid filter string: %q", filter)).Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sortExpr, err := sortToSQL(sort)
|
||||||
|
if err != nil {
|
||||||
|
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid sorting parameter: %q", sort)).Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// prepare query
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
f.id,
|
||||||
|
f.name,
|
||||||
|
m.name,
|
||||||
|
m.extension,
|
||||||
|
uuid_extract_timestamp(f.id),
|
||||||
|
u.name,
|
||||||
|
u.is_admin
|
||||||
|
FROM data.files f
|
||||||
|
JOIN system.mime m ON m.id=f.mime_id
|
||||||
|
JOIN system.users u ON u.id=f.creator_id
|
||||||
|
WHERE f.is_deleted IS FALSE AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=f.id AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1)) AND
|
||||||
|
`
|
||||||
|
query += filterCond
|
||||||
|
queryCount := query
|
||||||
|
query += sortExpr
|
||||||
|
if limit >= 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||||
|
}
|
||||||
|
if offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||||
|
}
|
||||||
|
// execute query
|
||||||
|
domainErr = transaction(ctx, s.db, func(ctx context.Context, tx pgx.Tx) (domainErr *domain.DomainError) {
|
||||||
|
rows, err := tx.Query(ctx, query, user_id)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
switch pgErr.Code {
|
||||||
|
case "42P10":
|
||||||
|
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid sorting field: %q", sort[1:])).Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
count := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var file domain.FileItem
|
||||||
|
err = rows.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin)
|
||||||
|
if err != nil {
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files.Data = append(files.Data, file)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files.Pagination.Limit = limit
|
||||||
|
files.Pagination.Offset = offset
|
||||||
|
files.Pagination.Count = count
|
||||||
|
row := tx.QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM (%s) tmp", queryCount), user_id)
|
||||||
|
err = row.Scan(&files.Pagination.Total)
|
||||||
|
if err != nil {
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file
|
||||||
|
func (s *FileRepository) Get(ctx context.Context, user_id int, file_id string) (file domain.FileFull, domainErr *domain.DomainError) {
|
||||||
|
row := s.db.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
f.id,
|
||||||
|
f.name,
|
||||||
|
m.name,
|
||||||
|
m.extension,
|
||||||
|
uuid_extract_timestamp(f.id),
|
||||||
|
u.name,
|
||||||
|
u.is_admin,
|
||||||
|
f.notes,
|
||||||
|
f.metadata,
|
||||||
|
(SELECT COUNT(*) FROM activity.file_views fv WHERE fv.file_id=$2 AND fv.user_id=$1)
|
||||||
|
FROM data.files f
|
||||||
|
JOIN system.mime m ON m.id=f.mime_id
|
||||||
|
JOIN system.users u ON u.id=f.creator_id
|
||||||
|
WHERE f.is_deleted IS FALSE
|
||||||
|
`, user_id, file_id)
|
||||||
|
err := row.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin, &file.Notes, &file.Metadata, &file.Viewed)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
switch pgErr.Code {
|
||||||
|
case "22P02":
|
||||||
|
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid file id: %q", file_id)).Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add file
|
||||||
|
func (s *FileRepository) Add(ctx context.Context, user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, domainErr *domain.DomainError) {
|
||||||
|
var mime_id int
|
||||||
|
var extension string
|
||||||
|
row := s.db.QueryRow(ctx, "SELECT id, extension FROM system.mime WHERE name=$1", mime)
|
||||||
|
err := row.Scan(&mime_id, &extension)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
domainErr = domain.NewErrorMIMENotSupported(mime).Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row = s.db.QueryRow(ctx, `
|
||||||
|
INSERT INTO data.files (name, mime_id, datetime, creator_id, notes, metadata)
|
||||||
|
VALUES (NULLIF($1, ''), $2, $3, $4, NULLIF($5 ,''), $6)
|
||||||
|
RETURNING id
|
||||||
|
`, name, mime_id, datetime, user_id, notes, metadata)
|
||||||
|
err = row.Scan(&file.ID)
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
switch pgErr.Code {
|
||||||
|
case "22007":
|
||||||
|
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid datetime: %q", datetime)).Wrap(err)
|
||||||
|
return
|
||||||
|
case "23502":
|
||||||
|
domainErr = domain.NewErrorBadRequest("Unable to set NULL to some fields").Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file.Name = &name
|
||||||
|
file.MIME.Name = mime
|
||||||
|
file.MIME.Extension = extension
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update file
|
||||||
|
func (s *FileRepository) Update(ctx context.Context, file_id string, updates map[string]interface{}) (domainErr *domain.DomainError) {
|
||||||
|
if len(updates) == 0 {
|
||||||
|
// domainErr = domain.NewErrorBadRequest(nil, "No fields provided for update")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := "UPDATE data.files SET"
|
||||||
|
newValues := []interface{}{file_id}
|
||||||
|
count := 2
|
||||||
|
for field, value := range updates {
|
||||||
|
switch field {
|
||||||
|
case "name", "notes":
|
||||||
|
query += fmt.Sprintf(" %s=NULLIF($%d, '')", field, count)
|
||||||
|
case "datetime":
|
||||||
|
query += fmt.Sprintf(" %s=NULLIF($%d, '')::timestamptz", field, count)
|
||||||
|
case "metadata":
|
||||||
|
query += fmt.Sprintf(" %s=NULLIF($%d, '')::jsonb", field, count)
|
||||||
|
default:
|
||||||
|
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Unknown field: %q", field))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newValues = append(newValues, value)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
query += fmt.Sprintf(" WHERE id=$1 AND is_deleted IS FALSE")
|
||||||
|
commandTag, err := s.db.Exec(ctx, query, newValues...)
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
switch pgErr.Code {
|
||||||
|
case "22P02":
|
||||||
|
domainErr = domain.NewErrorBadRequest("Invalid format of some values").Wrap(err)
|
||||||
|
return
|
||||||
|
case "22007":
|
||||||
|
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid datetime: %q", updates["datetime"])).Wrap(err)
|
||||||
|
return
|
||||||
|
case "23502":
|
||||||
|
domainErr = domain.NewErrorBadRequest("Some fields cannot be empty").Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if commandTag.RowsAffected() == 0 {
|
||||||
|
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
func (s *FileRepository) Delete(ctx context.Context, file_id string) (domainErr *domain.DomainError) {
|
||||||
|
commandTag, err := s.db.Exec(ctx,
|
||||||
|
"UPDATE data.files SET is_deleted=true WHERE id=$1 AND is_deleted IS FALSE",
|
||||||
|
file_id)
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
switch pgErr.Code {
|
||||||
|
case "22P02":
|
||||||
|
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid file id: %q", file_id)).Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if commandTag.RowsAffected() == 0 {
|
||||||
|
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of tags of file
|
||||||
|
func (s *FileRepository) GetTags(ctx context.Context, user_id int, file_id string) (tags []domain.TagItem, domainErr *domain.DomainError) {
|
||||||
|
rows, err := s.db.Query(ctx, `
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.name,
|
||||||
|
t.color,
|
||||||
|
c.id,
|
||||||
|
c.name,
|
||||||
|
c.color
|
||||||
|
FROM data.tags t
|
||||||
|
LEFT JOIN data.categories c ON c.id=t.category_id
|
||||||
|
JOIN data.file_tag ft ON ft.tag_id=t.id AND ft.file_id=$2
|
||||||
|
JOIN data.files f ON f.id=$2
|
||||||
|
WHERE NOT f.is_deleted AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))
|
||||||
|
`, user_id, file_id)
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && (pgErr.Code == "22P02" || pgErr.Code == "22007") {
|
||||||
|
domainErr = domain.NewErrorBadRequest(pgErr.Message).Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var tag domain.TagItem
|
||||||
|
err = rows.Scan(&tag.ID, &tag.Name, &tag.Color, &tag.Category.ID, &tag.Category.Name, &tag.Category.Color)
|
||||||
|
if err != nil {
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -1,21 +1,52 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Handle database error
|
||||||
|
func handleDBError(errIn error) (statusCode int, err error) {
|
||||||
|
if errIn == nil {
|
||||||
|
statusCode = http.StatusOK
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(errIn, pgx.ErrNoRows) {
|
||||||
|
err = fmt.Errorf("not found")
|
||||||
|
statusCode = http.StatusNotFound
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(errIn, &pgErr) {
|
||||||
|
switch pgErr.Code {
|
||||||
|
case "22P02", "22007": // Invalid data format
|
||||||
|
err = fmt.Errorf("%s", pgErr.Message)
|
||||||
|
statusCode = http.StatusBadRequest
|
||||||
|
return
|
||||||
|
case "23505": // Unique constraint violation
|
||||||
|
err = fmt.Errorf("already exists")
|
||||||
|
statusCode = http.StatusConflict
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return http.StatusInternalServerError, errIn
|
||||||
|
}
|
||||||
|
|
||||||
// Convert "filter" URL param to SQL "WHERE" condition
|
// Convert "filter" URL param to SQL "WHERE" condition
|
||||||
func filterToSQL(filter string) (sql string, statusCode int, err error) {
|
func filterToSQL(filter string) (sql string, err error) {
|
||||||
// filterTokens := strings.Split(string(filter), ";")
|
// filterTokens := strings.Split(string(filter), ";")
|
||||||
sql = "(true)"
|
sql = "(true)"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert "sort" URL param to SQL "ORDER BY"
|
// Convert "sort" URL param to SQL "ORDER BY"
|
||||||
func sortToSQL(sort string) (sql string, statusCode int, err error) {
|
func sortToSQL(sort string) (sql string, err error) {
|
||||||
if sort == "" {
|
if sort == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -32,7 +63,6 @@ func sortToSQL(sort string) (sql string, statusCode int, err error) {
|
|||||||
sortOrder = "DESC"
|
sortOrder = "DESC"
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("invalid sorting order mark: %q", sortOrder)
|
err = fmt.Errorf("invalid sorting order mark: %q", sortOrder)
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// validate sorting column
|
// validate sorting column
|
||||||
@@ -40,7 +70,6 @@ func sortToSQL(sort string) (sql string, statusCode int, err error) {
|
|||||||
n, err = strconv.Atoi(sortColumn)
|
n, err = strconv.Atoi(sortColumn)
|
||||||
if err != nil || n < 0 {
|
if err != nil || n < 0 {
|
||||||
err = fmt.Errorf("invalid sorting column: %q", sortColumn)
|
err = fmt.Errorf("invalid sorting column: %q", sortColumn)
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// add sorting option to query
|
// add sorting option to query
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"tanabata/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorMapper struct{}
|
||||||
|
|
||||||
|
func (m *ErrorMapper) MapError(err domain.DomainError) (int, ErrorResponse) {
|
||||||
|
switch err.Code {
|
||||||
|
case domain.ErrCodeFileNotFound:
|
||||||
|
return http.StatusNotFound, ErrorResponse{
|
||||||
|
Error: "Not Found",
|
||||||
|
Code: string(err.Code),
|
||||||
|
Message: err.Message,
|
||||||
|
}
|
||||||
|
case domain.ErrCodeMIMENotSupported:
|
||||||
|
return http.StatusNotFound, ErrorResponse{
|
||||||
|
Error: "MIME not supported",
|
||||||
|
Code: string(err.Code),
|
||||||
|
Message: err.Message,
|
||||||
|
}
|
||||||
|
case domain.ErrCodeBadRequest:
|
||||||
|
return http.StatusNotFound, ErrorResponse{
|
||||||
|
Error: "Bad Request",
|
||||||
|
Code: string(err.Code),
|
||||||
|
Message: err.Message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return http.StatusInternalServerError, ErrorResponse{
|
||||||
|
Error: "Internal Server Error",
|
||||||
|
Code: string(err.Code),
|
||||||
|
Message: err.Message,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
package port
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Transactor executes fn inside a single database transaction.
|
|
||||||
// All repository calls made within fn receive the transaction via context.
|
|
||||||
type Transactor interface {
|
|
||||||
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// OffsetParams holds common offset-pagination and sort parameters.
|
|
||||||
type OffsetParams struct {
|
|
||||||
Sort string
|
|
||||||
Order string // "asc" | "desc"
|
|
||||||
Search string
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolFileListParams holds parameters for listing files inside a pool.
|
|
||||||
type PoolFileListParams struct {
|
|
||||||
Cursor string
|
|
||||||
Limit int
|
|
||||||
Filter string // filter DSL expression
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileRepo is the persistence interface for file records.
|
|
||||||
type FileRepo interface {
|
|
||||||
// List returns a cursor-based page of files.
|
|
||||||
List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error)
|
|
||||||
// GetByID returns the file with its tags loaded.
|
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.File, error)
|
|
||||||
// Create inserts a new file record and returns it.
|
|
||||||
Create(ctx context.Context, f *domain.File) (*domain.File, error)
|
|
||||||
// Update applies partial metadata changes and returns the updated record.
|
|
||||||
Update(ctx context.Context, id uuid.UUID, f *domain.File) (*domain.File, error)
|
|
||||||
// SoftDelete moves a file to trash (sets is_deleted = true).
|
|
||||||
SoftDelete(ctx context.Context, id uuid.UUID) error
|
|
||||||
// Restore moves a file out of trash (sets is_deleted = false).
|
|
||||||
Restore(ctx context.Context, id uuid.UUID) (*domain.File, error)
|
|
||||||
// DeletePermanent removes a file record. Only allowed when is_deleted = true.
|
|
||||||
DeletePermanent(ctx context.Context, id uuid.UUID) error
|
|
||||||
|
|
||||||
// ListTags returns all tags assigned to a file.
|
|
||||||
ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error)
|
|
||||||
// SetTags replaces all tags on a file (full replace semantics).
|
|
||||||
SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagRepo is the persistence interface for tags.
|
|
||||||
type TagRepo interface {
|
|
||||||
List(ctx context.Context, params OffsetParams) (*domain.TagOffsetPage, error)
|
|
||||||
// ListByCategory returns tags belonging to a specific category.
|
|
||||||
ListByCategory(ctx context.Context, categoryID uuid.UUID, params OffsetParams) (*domain.TagOffsetPage, error)
|
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.Tag, error)
|
|
||||||
Create(ctx context.Context, t *domain.Tag) (*domain.Tag, error)
|
|
||||||
Update(ctx context.Context, id uuid.UUID, t *domain.Tag) (*domain.Tag, error)
|
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagRuleRepo is the persistence interface for auto-tag rules.
|
|
||||||
type TagRuleRepo interface {
|
|
||||||
// ListByTag returns all rules where WhenTagID == tagID.
|
|
||||||
ListByTag(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error)
|
|
||||||
Create(ctx context.Context, r domain.TagRule) (*domain.TagRule, error)
|
|
||||||
// SetActive toggles a rule's is_active flag.
|
|
||||||
SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active bool) error
|
|
||||||
Delete(ctx context.Context, whenTagID, thenTagID uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// CategoryRepo is the persistence interface for categories.
|
|
||||||
type CategoryRepo interface {
|
|
||||||
List(ctx context.Context, params OffsetParams) (*domain.CategoryOffsetPage, error)
|
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.Category, error)
|
|
||||||
Create(ctx context.Context, c *domain.Category) (*domain.Category, error)
|
|
||||||
Update(ctx context.Context, id uuid.UUID, c *domain.Category) (*domain.Category, error)
|
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolRepo is the persistence interface for pools and pool–file membership.
|
|
||||||
type PoolRepo interface {
|
|
||||||
List(ctx context.Context, params OffsetParams) (*domain.PoolOffsetPage, error)
|
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.Pool, error)
|
|
||||||
Create(ctx context.Context, p *domain.Pool) (*domain.Pool, error)
|
|
||||||
Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*domain.Pool, error)
|
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
|
||||||
|
|
||||||
// ListFiles returns pool files ordered by position (cursor-based).
|
|
||||||
ListFiles(ctx context.Context, poolID uuid.UUID, params PoolFileListParams) (*domain.PoolFilePage, error)
|
|
||||||
// AddFiles appends files starting at position; nil position means append at end.
|
|
||||||
AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error
|
|
||||||
// RemoveFiles removes files from the pool.
|
|
||||||
RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
|
||||||
// Reorder sets the full ordered sequence of file IDs in the pool.
|
|
||||||
Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserRepo is the persistence interface for users.
|
|
||||||
type UserRepo interface {
|
|
||||||
List(ctx context.Context, params OffsetParams) (*domain.UserPage, error)
|
|
||||||
GetByID(ctx context.Context, id int16) (*domain.User, error)
|
|
||||||
// GetByName is used during login to look up credentials.
|
|
||||||
GetByName(ctx context.Context, name string) (*domain.User, error)
|
|
||||||
Create(ctx context.Context, u *domain.User) (*domain.User, error)
|
|
||||||
Update(ctx context.Context, id int16, u *domain.User) (*domain.User, error)
|
|
||||||
Delete(ctx context.Context, id int16) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionRepo is the persistence interface for auth sessions.
|
|
||||||
type SessionRepo interface {
|
|
||||||
// ListByUser returns all active sessions for a user.
|
|
||||||
ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error)
|
|
||||||
// GetByTokenHash looks up a session by the hashed refresh token.
|
|
||||||
GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error)
|
|
||||||
Create(ctx context.Context, s *domain.Session) (*domain.Session, error)
|
|
||||||
// UpdateLastActivity refreshes the last_activity timestamp.
|
|
||||||
UpdateLastActivity(ctx context.Context, id int, t time.Time) error
|
|
||||||
// Delete terminates a single session.
|
|
||||||
Delete(ctx context.Context, id int) error
|
|
||||||
// DeleteByUserID terminates all sessions for a user (logout everywhere).
|
|
||||||
DeleteByUserID(ctx context.Context, userID int16) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACLRepo is the persistence interface for per-object permissions.
|
|
||||||
type ACLRepo interface {
|
|
||||||
// List returns all permission entries for a given object.
|
|
||||||
List(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error)
|
|
||||||
// Get returns the permission entry for a specific user and object; returns
|
|
||||||
// ErrNotFound if no entry exists.
|
|
||||||
Get(ctx context.Context, userID int16, objectTypeID int16, objectID uuid.UUID) (*domain.Permission, error)
|
|
||||||
// Set replaces all permissions for an object (full replace semantics).
|
|
||||||
Set(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditRepo is the persistence interface for the audit log.
|
|
||||||
type AuditRepo interface {
|
|
||||||
Log(ctx context.Context, entry domain.AuditEntry) error
|
|
||||||
List(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MimeRepo is the persistence interface for the MIME type whitelist.
|
|
||||||
type MimeRepo interface {
|
|
||||||
// List returns all supported MIME types.
|
|
||||||
List(ctx context.Context) ([]domain.MIMEType, error)
|
|
||||||
// GetByName returns the MIME type record for a given MIME name (e.g. "image/jpeg").
|
|
||||||
// Returns ErrUnsupportedMIME if not in the whitelist.
|
|
||||||
GetByName(ctx context.Context, name string) (*domain.MIMEType, error)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package port
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileStorage abstracts disk (or object-store) operations for file content,
|
|
||||||
// thumbnails, and previews.
|
|
||||||
type FileStorage interface {
|
|
||||||
// Save writes the reader's content to storage and returns the number of
|
|
||||||
// bytes written.
|
|
||||||
Save(ctx context.Context, id uuid.UUID, r io.Reader) (int64, error)
|
|
||||||
|
|
||||||
// Read opens the file content for reading. The caller must close the returned
|
|
||||||
// ReadCloser.
|
|
||||||
Read(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
|
|
||||||
|
|
||||||
// Delete removes the file content from storage.
|
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
|
||||||
|
|
||||||
// Thumbnail opens the pre-generated thumbnail (JPEG). Returns ErrNotFound
|
|
||||||
// if the thumbnail has not been generated yet.
|
|
||||||
Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
|
|
||||||
|
|
||||||
// Preview opens the pre-generated preview image (JPEG). Returns ErrNotFound
|
|
||||||
// if the preview has not been generated yet.
|
|
||||||
Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ACLService handles access control checks and permission management.
|
|
||||||
type ACLService struct {
|
|
||||||
aclRepo port.ACLRepo
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewACLService(aclRepo port.ACLRepo) *ACLService {
|
|
||||||
return &ACLService{aclRepo: aclRepo}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanView returns true if the user may view the object.
|
|
||||||
// isAdmin, creatorID, isPublic must be populated from the object record by the caller.
|
|
||||||
func (s *ACLService) CanView(
|
|
||||||
ctx context.Context,
|
|
||||||
userID int16, isAdmin bool,
|
|
||||||
creatorID int16, isPublic bool,
|
|
||||||
objectTypeID int16, objectID uuid.UUID,
|
|
||||||
) (bool, error) {
|
|
||||||
if isAdmin {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
if isPublic {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
if userID == creatorID {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
perm, err := s.aclRepo.Get(ctx, userID, objectTypeID, objectID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, domain.ErrNotFound) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return perm.CanView, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanEdit returns true if the user may edit the object.
|
|
||||||
// is_public does not grant edit access; only admins, creators, and explicit grants.
|
|
||||||
func (s *ACLService) CanEdit(
|
|
||||||
ctx context.Context,
|
|
||||||
userID int16, isAdmin bool,
|
|
||||||
creatorID int16,
|
|
||||||
objectTypeID int16, objectID uuid.UUID,
|
|
||||||
) (bool, error) {
|
|
||||||
if isAdmin {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
if userID == creatorID {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
perm, err := s.aclRepo.Get(ctx, userID, objectTypeID, objectID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, domain.ErrNotFound) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return perm.CanEdit, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPermissions returns all explicit ACL entries for an object.
|
|
||||||
func (s *ACLService) GetPermissions(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error) {
|
|
||||||
return s.aclRepo.List(ctx, objectTypeID, objectID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPermissions replaces all ACL entries for an object (full replace semantics).
|
|
||||||
func (s *ACLService) SetPermissions(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error {
|
|
||||||
return s.aclRepo.Set(ctx, objectTypeID, objectID, perms)
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditService records user actions to the audit trail.
|
|
||||||
type AuditService struct {
|
|
||||||
repo port.AuditRepo
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAuditService(repo port.AuditRepo) *AuditService {
|
|
||||||
return &AuditService{repo: repo}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log records an action performed by the user in ctx.
|
|
||||||
// objectType and objectID are optional — pass nil when the action has no target object.
|
|
||||||
// details can be any JSON-serializable value, or nil.
|
|
||||||
func (s *AuditService) Log(
|
|
||||||
ctx context.Context,
|
|
||||||
action string,
|
|
||||||
objectType *string,
|
|
||||||
objectID *uuid.UUID,
|
|
||||||
details any,
|
|
||||||
) error {
|
|
||||||
userID, _, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
var raw json.RawMessage
|
|
||||||
if details != nil {
|
|
||||||
b, err := json.Marshal(details)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("AuditService.Log marshal details: %w", err)
|
|
||||||
}
|
|
||||||
raw = b
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := domain.AuditEntry{
|
|
||||||
UserID: userID,
|
|
||||||
Action: action,
|
|
||||||
ObjectType: objectType,
|
|
||||||
ObjectID: objectID,
|
|
||||||
Details: raw,
|
|
||||||
}
|
|
||||||
return s.repo.Log(ctx, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query returns a filtered, paginated page of audit log entries.
|
|
||||||
func (s *AuditService) Query(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error) {
|
|
||||||
return s.repo.List(ctx, filter)
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Claims is the JWT payload for both access and refresh tokens.
|
|
||||||
type Claims struct {
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
UserID int16 `json:"uid"`
|
|
||||||
IsAdmin bool `json:"adm"`
|
|
||||||
SessionID int `json:"sid"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenPair holds an issued access/refresh token pair with the access TTL.
|
|
||||||
type TokenPair struct {
|
|
||||||
AccessToken string
|
|
||||||
RefreshToken string
|
|
||||||
ExpiresIn int // access token TTL in seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthService handles authentication and session lifecycle.
|
|
||||||
type AuthService struct {
|
|
||||||
users port.UserRepo
|
|
||||||
sessions port.SessionRepo
|
|
||||||
secret []byte
|
|
||||||
accessTTL time.Duration
|
|
||||||
refreshTTL time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthService creates an AuthService.
|
|
||||||
func NewAuthService(
|
|
||||||
users port.UserRepo,
|
|
||||||
sessions port.SessionRepo,
|
|
||||||
jwtSecret string,
|
|
||||||
accessTTL time.Duration,
|
|
||||||
refreshTTL time.Duration,
|
|
||||||
) *AuthService {
|
|
||||||
return &AuthService{
|
|
||||||
users: users,
|
|
||||||
sessions: sessions,
|
|
||||||
secret: []byte(jwtSecret),
|
|
||||||
accessTTL: accessTTL,
|
|
||||||
refreshTTL: refreshTTL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login validates credentials, creates a session, and returns a token pair.
|
|
||||||
func (s *AuthService) Login(ctx context.Context, name, password, userAgent string) (*TokenPair, error) {
|
|
||||||
user, err := s.users.GetByName(ctx, name)
|
|
||||||
if err != nil {
|
|
||||||
// Return ErrUnauthorized regardless of whether the user exists,
|
|
||||||
// to avoid username enumeration.
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.IsBlocked {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
var expiresAt *time.Time
|
|
||||||
if s.refreshTTL > 0 {
|
|
||||||
t := time.Now().Add(s.refreshTTL)
|
|
||||||
expiresAt = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue the refresh token first so we can store its hash.
|
|
||||||
refreshToken, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := s.sessions.Create(ctx, &domain.Session{
|
|
||||||
TokenHash: hashToken(refreshToken),
|
|
||||||
UserID: user.ID,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := s.issueToken(user.ID, user.IsAdmin, session.ID, s.accessTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue access token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-issue the refresh token with the real session ID now that we have it.
|
|
||||||
refreshToken, err = s.issueToken(user.ID, user.IsAdmin, session.ID, s.refreshTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TokenPair{
|
|
||||||
AccessToken: accessToken,
|
|
||||||
RefreshToken: refreshToken,
|
|
||||||
ExpiresIn: int(s.accessTTL.Seconds()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout deactivates the session identified by sessionID.
|
|
||||||
func (s *AuthService) Logout(ctx context.Context, sessionID int) error {
|
|
||||||
if err := s.sessions.Delete(ctx, sessionID); err != nil {
|
|
||||||
return fmt.Errorf("logout: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh validates a refresh token, issues a new token pair, and deactivates
|
|
||||||
// the old session.
|
|
||||||
func (s *AuthService) Refresh(ctx context.Context, refreshToken, userAgent string) (*TokenPair, error) {
|
|
||||||
claims, err := s.parseToken(refreshToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := s.sessions.GetByTokenHash(ctx, hashToken(refreshToken))
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
if session.ExpiresAt != nil && time.Now().After(*session.ExpiresAt) {
|
|
||||||
_ = s.sessions.Delete(ctx, session.ID)
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate: deactivate old session.
|
|
||||||
if err := s.sessions.Delete(ctx, session.ID); err != nil {
|
|
||||||
return nil, fmt.Errorf("deactivate old session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.users.GetByID(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.IsBlocked {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
var expiresAt *time.Time
|
|
||||||
if s.refreshTTL > 0 {
|
|
||||||
t := time.Now().Add(s.refreshTTL)
|
|
||||||
expiresAt = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
newRefresh, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
newSession, err := s.sessions.Create(ctx, &domain.Session{
|
|
||||||
TokenHash: hashToken(newRefresh),
|
|
||||||
UserID: user.ID,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := s.issueToken(user.ID, user.IsAdmin, newSession.ID, s.accessTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue access token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
newRefresh, err = s.issueToken(user.ID, user.IsAdmin, newSession.ID, s.refreshTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TokenPair{
|
|
||||||
AccessToken: accessToken,
|
|
||||||
RefreshToken: newRefresh,
|
|
||||||
ExpiresIn: int(s.accessTTL.Seconds()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListSessions returns all active sessions for the given user.
|
|
||||||
func (s *AuthService) ListSessions(ctx context.Context, userID int16, currentSessionID int) (*domain.SessionList, error) {
|
|
||||||
list, err := s.sessions.ListByUser(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list sessions: %w", err)
|
|
||||||
}
|
|
||||||
for i := range list.Items {
|
|
||||||
list.Items[i].IsCurrent = list.Items[i].ID == currentSessionID
|
|
||||||
}
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TerminateSession deactivates a specific session, enforcing ownership.
|
|
||||||
func (s *AuthService) TerminateSession(ctx context.Context, callerID int16, isAdmin bool, sessionID int) error {
|
|
||||||
if !isAdmin {
|
|
||||||
list, err := s.sessions.ListByUser(ctx, callerID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("terminate session: %w", err)
|
|
||||||
}
|
|
||||||
owned := false
|
|
||||||
for _, sess := range list.Items {
|
|
||||||
if sess.ID == sessionID {
|
|
||||||
owned = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !owned {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.sessions.Delete(ctx, sessionID); err != nil {
|
|
||||||
return fmt.Errorf("terminate session: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseAccessToken parses and validates an access token, returning its claims.
|
|
||||||
func (s *AuthService) ParseAccessToken(tokenStr string) (*Claims, error) {
|
|
||||||
claims, err := s.parseToken(tokenStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// issueToken signs a JWT with the given parameters.
|
|
||||||
func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl time.Duration) (string, error) {
|
|
||||||
now := time.Now()
|
|
||||||
claims := Claims{
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
|
||||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
|
||||||
},
|
|
||||||
UserID: userID,
|
|
||||||
IsAdmin: isAdmin,
|
|
||||||
SessionID: sessionID,
|
|
||||||
}
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
signed, err := token.SignedString(s.secret)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("sign token: %w", err)
|
|
||||||
}
|
|
||||||
return signed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseToken verifies the signature and parses claims from a token string.
|
|
||||||
func (s *AuthService) parseToken(tokenStr string) (*Claims, error) {
|
|
||||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
|
||||||
}
|
|
||||||
return s.secret, nil
|
|
||||||
})
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
claims, ok := token.Claims.(*Claims)
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hashToken returns the SHA-256 hex digest of a token string.
|
|
||||||
// The raw token is never stored; only the hash goes to the database.
|
|
||||||
func hashToken(token string) string {
|
|
||||||
sum := sha256.Sum256([]byte(token))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
@@ -1,759 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/rwcarlsen/goexif/exif"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
const fileObjectType = "file"
|
|
||||||
|
|
||||||
// fileObjectTypeID is the primary key of the "file" row in core.object_types.
|
|
||||||
// It matches the first value inserted in 007_seed_data.sql.
|
|
||||||
const fileObjectTypeID int16 = 1
|
|
||||||
|
|
||||||
// UploadParams holds the parameters for uploading a new file.
|
|
||||||
type UploadParams struct {
|
|
||||||
Reader io.Reader
|
|
||||||
MIMEType string
|
|
||||||
OriginalName *string
|
|
||||||
Notes *string
|
|
||||||
Metadata json.RawMessage
|
|
||||||
ContentDatetime *time.Time
|
|
||||||
IsPublic bool
|
|
||||||
TagIDs []uuid.UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateParams holds the parameters for updating file metadata.
|
|
||||||
type UpdateParams struct {
|
|
||||||
OriginalName *string
|
|
||||||
Notes *string
|
|
||||||
Metadata json.RawMessage
|
|
||||||
ContentDatetime *time.Time
|
|
||||||
IsPublic *bool
|
|
||||||
TagIDs *[]uuid.UUID // nil means don't change tags
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContentResult holds the open reader and metadata for a file download.
|
|
||||||
type ContentResult struct {
|
|
||||||
Body io.ReadCloser
|
|
||||||
MIMEType string
|
|
||||||
OriginalName *string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportFileError records a failed file during an import operation.
|
|
||||||
type ImportFileError struct {
|
|
||||||
Filename string `json:"filename"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportResult summarises a directory import.
|
|
||||||
type ImportResult struct {
|
|
||||||
Imported int `json:"imported"`
|
|
||||||
Skipped int `json:"skipped"`
|
|
||||||
Errors []ImportFileError `json:"errors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileService handles business logic for file records.
|
|
||||||
type FileService struct {
|
|
||||||
files port.FileRepo
|
|
||||||
mimes port.MimeRepo
|
|
||||||
storage port.FileStorage
|
|
||||||
acl *ACLService
|
|
||||||
audit *AuditService
|
|
||||||
tx port.Transactor
|
|
||||||
importPath string // default server-side import directory
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileService creates a FileService.
|
|
||||||
func NewFileService(
|
|
||||||
files port.FileRepo,
|
|
||||||
mimes port.MimeRepo,
|
|
||||||
storage port.FileStorage,
|
|
||||||
acl *ACLService,
|
|
||||||
audit *AuditService,
|
|
||||||
tx port.Transactor,
|
|
||||||
importPath string,
|
|
||||||
) *FileService {
|
|
||||||
return &FileService{
|
|
||||||
files: files,
|
|
||||||
mimes: mimes,
|
|
||||||
storage: storage,
|
|
||||||
acl: acl,
|
|
||||||
audit: audit,
|
|
||||||
tx: tx,
|
|
||||||
importPath: importPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Core CRUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Upload validates the MIME type, saves the file to storage, creates the DB
|
|
||||||
// record, and applies any initial tags — all within a single transaction.
|
|
||||||
// If ContentDatetime is nil and EXIF DateTimeOriginal is present, it is used.
|
|
||||||
func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, error) {
|
|
||||||
userID, _, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
// Validate MIME type against the whitelist.
|
|
||||||
mime, err := s.mimes.GetByName(ctx, p.MIMEType)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err // ErrUnsupportedMIME or DB error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buffer the upload so we can extract EXIF without re-reading storage.
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if _, err := io.Copy(&buf, p.Reader); err != nil {
|
|
||||||
return nil, fmt.Errorf("FileService.Upload: read body: %w", err)
|
|
||||||
}
|
|
||||||
data := buf.Bytes()
|
|
||||||
|
|
||||||
// Extract EXIF metadata (best-effort; non-image files will error silently).
|
|
||||||
exifData, exifDatetime := extractEXIFWithDatetime(data)
|
|
||||||
|
|
||||||
// Resolve content datetime: explicit > EXIF > zero value.
|
|
||||||
var contentDatetime time.Time
|
|
||||||
if p.ContentDatetime != nil {
|
|
||||||
contentDatetime = *p.ContentDatetime
|
|
||||||
} else if exifDatetime != nil {
|
|
||||||
contentDatetime = *exifDatetime
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign UUID v7 so CreatedAt can be derived from it later.
|
|
||||||
fileID, err := uuid.NewV7()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileService.Upload: generate UUID: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save file bytes to disk before opening the transaction so that a disk
|
|
||||||
// failure does not abort an otherwise healthy DB transaction.
|
|
||||||
if _, err := s.storage.Save(ctx, fileID, bytes.NewReader(data)); err != nil {
|
|
||||||
return nil, fmt.Errorf("FileService.Upload: save to storage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var created *domain.File
|
|
||||||
txErr := s.tx.WithTx(ctx, func(ctx context.Context) error {
|
|
||||||
f := &domain.File{
|
|
||||||
ID: fileID,
|
|
||||||
OriginalName: p.OriginalName,
|
|
||||||
MIMEType: mime.Name,
|
|
||||||
MIMEExtension: mime.Extension,
|
|
||||||
ContentDatetime: contentDatetime,
|
|
||||||
Notes: p.Notes,
|
|
||||||
Metadata: p.Metadata,
|
|
||||||
EXIF: exifData,
|
|
||||||
CreatorID: userID,
|
|
||||||
IsPublic: p.IsPublic,
|
|
||||||
}
|
|
||||||
|
|
||||||
var createErr error
|
|
||||||
created, createErr = s.files.Create(ctx, f)
|
|
||||||
if createErr != nil {
|
|
||||||
return createErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(p.TagIDs) > 0 {
|
|
||||||
if err := s.files.SetTags(ctx, created.ID, p.TagIDs); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tags, err := s.files.ListTags(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
created.Tags = tags
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if txErr != nil {
|
|
||||||
// Attempt to clean up the orphaned file; ignore cleanup errors.
|
|
||||||
_ = s.storage.Delete(ctx, fileID)
|
|
||||||
return nil, txErr
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_create", &objType, &created.ID, nil)
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a file by ID, enforcing view ACL.
|
|
||||||
func (s *FileService) Get(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanView(ctx, userID, isAdmin, f.CreatorID, f.IsPublic, fileObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update applies metadata changes to a file, enforcing edit ACL.
|
|
||||||
func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams) (*domain.File, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := &domain.File{}
|
|
||||||
if p.OriginalName != nil {
|
|
||||||
patch.OriginalName = p.OriginalName
|
|
||||||
}
|
|
||||||
if p.Notes != nil {
|
|
||||||
patch.Notes = p.Notes
|
|
||||||
}
|
|
||||||
if p.Metadata != nil {
|
|
||||||
patch.Metadata = p.Metadata
|
|
||||||
}
|
|
||||||
if p.ContentDatetime != nil {
|
|
||||||
patch.ContentDatetime = *p.ContentDatetime
|
|
||||||
}
|
|
||||||
if p.IsPublic != nil {
|
|
||||||
patch.IsPublic = *p.IsPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
var updated *domain.File
|
|
||||||
txErr := s.tx.WithTx(ctx, func(ctx context.Context) error {
|
|
||||||
var updateErr error
|
|
||||||
updated, updateErr = s.files.Update(ctx, id, patch)
|
|
||||||
if updateErr != nil {
|
|
||||||
return updateErr
|
|
||||||
}
|
|
||||||
if p.TagIDs != nil {
|
|
||||||
if err := s.files.SetTags(ctx, id, *p.TagIDs); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tags, err := s.files.ListTags(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
updated.Tags = tags
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if txErr != nil {
|
|
||||||
return nil, txErr
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_edit", &objType, &id, nil)
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete soft-deletes a file (moves to trash), enforcing edit ACL.
|
|
||||||
func (s *FileService) Delete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.files.SoftDelete(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_delete", &objType, &id, nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore moves a soft-deleted file out of trash, enforcing edit ACL.
|
|
||||||
func (s *FileService) Restore(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
restored, err := s.files.Restore(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_restore", &objType, &id, nil)
|
|
||||||
return restored, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PermanentDelete removes the file record and its stored bytes. Only allowed
|
|
||||||
// when the file is already in trash.
|
|
||||||
func (s *FileService) PermanentDelete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !f.IsDeleted {
|
|
||||||
return domain.ErrConflict
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.files.DeletePermanent(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_ = s.storage.Delete(ctx, id)
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_permanent_delete", &objType, &id, nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace swaps the stored bytes for a file with new content.
|
|
||||||
func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams) (*domain.File, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
mime, err := s.mimes.GetByName(ctx, p.MIMEType)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if _, err := io.Copy(&buf, p.Reader); err != nil {
|
|
||||||
return nil, fmt.Errorf("FileService.Replace: read body: %w", err)
|
|
||||||
}
|
|
||||||
data := buf.Bytes()
|
|
||||||
exifData, _ := extractEXIFWithDatetime(data)
|
|
||||||
|
|
||||||
if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil {
|
|
||||||
return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := &domain.File{
|
|
||||||
MIMEType: mime.Name,
|
|
||||||
MIMEExtension: mime.Extension,
|
|
||||||
EXIF: exifData,
|
|
||||||
}
|
|
||||||
if p.OriginalName != nil {
|
|
||||||
patch.OriginalName = p.OriginalName
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.files.Update(ctx, id, patch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_replace", &objType, &id, nil)
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List delegates to FileRepo with the given params.
|
|
||||||
func (s *FileService) List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error) {
|
|
||||||
return s.files.List(ctx, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Content / thumbnail / preview streaming
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// GetContent opens the raw file for download, enforcing view ACL.
|
|
||||||
func (s *FileService) GetContent(ctx context.Context, id uuid.UUID) (*ContentResult, error) {
|
|
||||||
f, err := s.Get(ctx, id) // ACL checked inside Get
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rc, err := s.storage.Read(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &ContentResult{
|
|
||||||
Body: rc,
|
|
||||||
MIMEType: f.MIMEType,
|
|
||||||
OriginalName: f.OriginalName,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetThumbnail returns the thumbnail JPEG, enforcing view ACL.
|
|
||||||
func (s *FileService) GetThumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
|
||||||
if _, err := s.Get(ctx, id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.storage.Thumbnail(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPreview returns the preview JPEG, enforcing view ACL.
|
|
||||||
func (s *FileService) GetPreview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
|
||||||
if _, err := s.Get(ctx, id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.storage.Preview(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tag operations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ListFileTags returns the tags on a file, enforcing view ACL.
|
|
||||||
func (s *FileService) ListFileTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
|
|
||||||
if _, err := s.Get(ctx, fileID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.files.ListTags(ctx, fileID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetFileTags replaces all tags on a file (full replace semantics), enforcing edit ACL.
|
|
||||||
func (s *FileService) SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) ([]domain.Tag, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.files.SetTags(ctx, fileID, tagIDs); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, nil)
|
|
||||||
return s.files.ListTags(ctx, fileID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddTag adds a single tag to a file, enforcing edit ACL.
|
|
||||||
func (s *FileService) AddTag(ctx context.Context, fileID, tagID uuid.UUID) ([]domain.Tag, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
current, err := s.files.ListTags(ctx, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Only add if not already present.
|
|
||||||
for _, t := range current {
|
|
||||||
if t.ID == tagID {
|
|
||||||
return current, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ids := make([]uuid.UUID, 0, len(current)+1)
|
|
||||||
for _, t := range current {
|
|
||||||
ids = append(ids, t.ID)
|
|
||||||
}
|
|
||||||
ids = append(ids, tagID)
|
|
||||||
|
|
||||||
if err := s.files.SetTags(ctx, fileID, ids); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, map[string]any{"tag_id": tagID})
|
|
||||||
return s.files.ListTags(ctx, fileID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveTag removes a single tag from a file, enforcing edit ACL.
|
|
||||||
func (s *FileService) RemoveTag(ctx context.Context, fileID, tagID uuid.UUID) error {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
current, err := s.files.ListTags(ctx, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ids := make([]uuid.UUID, 0, len(current))
|
|
||||||
for _, t := range current {
|
|
||||||
if t.ID != tagID {
|
|
||||||
ids = append(ids, t.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.files.SetTags(ctx, fileID, ids); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_tag_remove", &objType, &fileID, map[string]any{"tag_id": tagID})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bulk operations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// BulkDelete soft-deletes multiple files. Files the caller cannot edit are silently skipped.
|
|
||||||
func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error {
|
|
||||||
for _, id := range fileIDs {
|
|
||||||
if err := s.Delete(ctx, id); err != nil {
|
|
||||||
// Skip files not found or forbidden; surface real errors.
|
|
||||||
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BulkSetTags adds or removes the given tags on multiple files.
|
|
||||||
// For "add": tags are appended to each file's existing set.
|
|
||||||
// For "remove": tags are removed from each file's existing set.
|
|
||||||
// Returns the tag IDs that were applied (the input tagIDs, for add).
|
|
||||||
func (s *FileService) BulkSetTags(ctx context.Context, fileIDs []uuid.UUID, action string, tagIDs []uuid.UUID) ([]uuid.UUID, error) {
|
|
||||||
for _, fileID := range fileIDs {
|
|
||||||
switch action {
|
|
||||||
case "add":
|
|
||||||
for _, tagID := range tagIDs {
|
|
||||||
if _, err := s.AddTag(ctx, fileID, tagID); err != nil {
|
|
||||||
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "remove":
|
|
||||||
for _, tagID := range tagIDs {
|
|
||||||
if err := s.RemoveTag(ctx, fileID, tagID); err != nil {
|
|
||||||
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil, domain.ErrValidation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if action == "add" {
|
|
||||||
return tagIDs, nil
|
|
||||||
}
|
|
||||||
return []uuid.UUID{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CommonTags loads the tag sets for all given files and splits them into:
|
|
||||||
// - common: tag IDs present on every file
|
|
||||||
// - partial: tag IDs present on some but not all files
|
|
||||||
func (s *FileService) CommonTags(ctx context.Context, fileIDs []uuid.UUID) (common, partial []uuid.UUID, err error) {
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return nil, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count how many files each tag appears on.
|
|
||||||
counts := map[uuid.UUID]int{}
|
|
||||||
for _, fid := range fileIDs {
|
|
||||||
tags, err := s.files.ListTags(ctx, fid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
for _, t := range tags {
|
|
||||||
counts[t.ID]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
n := len(fileIDs)
|
|
||||||
for id, cnt := range counts {
|
|
||||||
if cnt == n {
|
|
||||||
common = append(common, id)
|
|
||||||
} else {
|
|
||||||
partial = append(partial, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if common == nil {
|
|
||||||
common = []uuid.UUID{}
|
|
||||||
}
|
|
||||||
if partial == nil {
|
|
||||||
partial = []uuid.UUID{}
|
|
||||||
}
|
|
||||||
return common, partial, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Import
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Import scans a server-side directory and uploads all supported files.
|
|
||||||
// If path is empty, the configured default import path is used.
|
|
||||||
func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, error) {
|
|
||||||
dir := path
|
|
||||||
if dir == "" {
|
|
||||||
dir = s.importPath
|
|
||||||
}
|
|
||||||
if dir == "" {
|
|
||||||
return nil, domain.ErrValidation
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileService.Import: read dir %q: %w", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &ImportResult{Errors: []ImportFileError{}}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() {
|
|
||||||
result.Skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fullPath := filepath.Join(dir, entry.Name())
|
|
||||||
|
|
||||||
mt, err := mimetype.DetectFile(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
result.Errors = append(result.Errors, ImportFileError{
|
|
||||||
Filename: entry.Name(),
|
|
||||||
Reason: fmt.Sprintf("MIME detection failed: %s", err),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
mimeStr := mt.String()
|
|
||||||
// Strip parameters (e.g. "text/plain; charset=utf-8" → "text/plain").
|
|
||||||
if idx := len(mimeStr); idx > 0 {
|
|
||||||
for i, c := range mimeStr {
|
|
||||||
if c == ';' {
|
|
||||||
mimeStr = mimeStr[:i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.mimes.GetByName(ctx, mimeStr); err != nil {
|
|
||||||
result.Skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Open(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
result.Errors = append(result.Errors, ImportFileError{
|
|
||||||
Filename: entry.Name(),
|
|
||||||
Reason: fmt.Sprintf("open failed: %s", err),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
name := entry.Name()
|
|
||||||
_, uploadErr := s.Upload(ctx, UploadParams{
|
|
||||||
Reader: f,
|
|
||||||
MIMEType: mimeStr,
|
|
||||||
OriginalName: &name,
|
|
||||||
})
|
|
||||||
f.Close()
|
|
||||||
|
|
||||||
if uploadErr != nil {
|
|
||||||
result.Errors = append(result.Errors, ImportFileError{
|
|
||||||
Filename: entry.Name(),
|
|
||||||
Reason: uploadErr.Error(),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.Imported++
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// extractEXIFWithDatetime parses EXIF from raw bytes, returning both the JSON
|
|
||||||
// representation and the DateTimeOriginal (if present). Both may be nil.
|
|
||||||
func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) {
|
|
||||||
x, err := exif.Decode(bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
b, err := x.MarshalJSON()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var dt *time.Time
|
|
||||||
if t, err := x.DateTime(); err == nil {
|
|
||||||
dt = &t
|
|
||||||
}
|
|
||||||
return json.RawMessage(b), dt
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
// Package storage provides a local-filesystem implementation of port.FileStorage.
|
|
||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
"image/jpeg"
|
|
||||||
_ "image/gif" // register GIF decoder
|
|
||||||
_ "image/png" // register PNG decoder
|
|
||||||
_ "golang.org/x/image/webp" // register WebP decoder
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DiskStorage implements port.FileStorage using the local filesystem.
|
|
||||||
//
|
|
||||||
// Directory layout:
|
|
||||||
//
|
|
||||||
// {filesPath}/{id} — original file (UUID basename, no extension)
|
|
||||||
// {thumbsPath}/{id}_thumb.jpg — thumbnail cache
|
|
||||||
// {thumbsPath}/{id}_preview.jpg — preview cache
|
|
||||||
type DiskStorage struct {
|
|
||||||
filesPath string
|
|
||||||
thumbsPath string
|
|
||||||
thumbWidth int
|
|
||||||
thumbHeight int
|
|
||||||
previewWidth int
|
|
||||||
previewHeight int
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.FileStorage = (*DiskStorage)(nil)
|
|
||||||
|
|
||||||
// NewDiskStorage creates a DiskStorage and ensures both directories exist.
|
|
||||||
func NewDiskStorage(
|
|
||||||
filesPath, thumbsPath string,
|
|
||||||
thumbW, thumbH, prevW, prevH int,
|
|
||||||
) (*DiskStorage, error) {
|
|
||||||
for _, p := range []string{filesPath, thumbsPath} {
|
|
||||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
|
||||||
return nil, fmt.Errorf("storage: create directory %q: %w", p, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &DiskStorage{
|
|
||||||
filesPath: filesPath,
|
|
||||||
thumbsPath: thumbsPath,
|
|
||||||
thumbWidth: thumbW,
|
|
||||||
thumbHeight: thumbH,
|
|
||||||
previewWidth: prevW,
|
|
||||||
previewHeight: prevH,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// port.FileStorage implementation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Save writes r to {filesPath}/{id} and returns the number of bytes written.
|
|
||||||
func (s *DiskStorage) Save(_ context.Context, id uuid.UUID, r io.Reader) (int64, error) {
|
|
||||||
dst := s.originalPath(id)
|
|
||||||
f, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("storage.Save create %q: %w", dst, err)
|
|
||||||
}
|
|
||||||
n, copyErr := io.Copy(f, r)
|
|
||||||
closeErr := f.Close()
|
|
||||||
if copyErr != nil {
|
|
||||||
os.Remove(dst)
|
|
||||||
return 0, fmt.Errorf("storage.Save write: %w", copyErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
os.Remove(dst)
|
|
||||||
return 0, fmt.Errorf("storage.Save close: %w", closeErr)
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read opens the original file for reading. The caller must close the result.
|
|
||||||
func (s *DiskStorage) Read(_ context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
|
||||||
f, err := os.Open(s.originalPath(id))
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("storage.Read: %w", err)
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes the original file. Returns ErrNotFound if it does not exist.
|
|
||||||
func (s *DiskStorage) Delete(_ context.Context, id uuid.UUID) error {
|
|
||||||
if err := os.Remove(s.originalPath(id)); err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return fmt.Errorf("storage.Delete: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thumbnail returns a JPEG that fits within the configured max width×height
|
|
||||||
// (never upscaled, never cropped). Generated on first call and cached.
|
|
||||||
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder.
|
|
||||||
func (s *DiskStorage) Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
|
||||||
return s.serveGenerated(ctx, id, s.thumbCachePath(id), s.thumbWidth, s.thumbHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preview returns a JPEG that fits within the configured max width×height
|
|
||||||
// (never upscaled, never cropped). Generated on first call and cached.
|
|
||||||
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder.
|
|
||||||
func (s *DiskStorage) Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
|
||||||
return s.serveGenerated(ctx, id, s.previewCachePath(id), s.previewWidth, s.previewHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// serveGenerated is the shared implementation for Thumbnail and Preview.
|
|
||||||
// imaging.Thumbnail fits the source within maxW×maxH without upscaling or cropping.
|
|
||||||
//
|
|
||||||
// Resolution order:
|
|
||||||
// 1. Return cached JPEG if present.
|
|
||||||
// 2. Decode as still image (JPEG/PNG/GIF via imaging).
|
|
||||||
// 3. Extract a frame with ffmpeg (video files).
|
|
||||||
// 4. Solid-colour placeholder (archives, unrecognised formats, etc.).
|
|
||||||
func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePath string, maxW, maxH int) (io.ReadCloser, error) {
|
|
||||||
// Fast path: cache hit.
|
|
||||||
if f, err := os.Open(cachePath); err == nil {
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the original file exists before doing any work.
|
|
||||||
srcPath := s.originalPath(id)
|
|
||||||
if _, err := os.Stat(srcPath); err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Try still-image decode (JPEG/PNG/GIF).
|
|
||||||
// 2. Try video frame extraction via ffmpeg.
|
|
||||||
// 3. Fall back to placeholder.
|
|
||||||
var img image.Image
|
|
||||||
if decoded, err := imaging.Open(srcPath, imaging.AutoOrientation(true)); err == nil {
|
|
||||||
img = imaging.Thumbnail(decoded, maxW, maxH, imaging.Lanczos)
|
|
||||||
} else if frame, err := extractVideoFrame(ctx, srcPath); err == nil {
|
|
||||||
img = imaging.Thumbnail(frame, maxW, maxH, imaging.Lanczos)
|
|
||||||
} else {
|
|
||||||
img = placeholder(maxW, maxH)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to cache atomically (temp→rename) and return an open reader.
|
|
||||||
if rc, err := writeCache(cachePath, img); err == nil {
|
|
||||||
return rc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache write failed (read-only fs, disk full, …). Serve from an
|
|
||||||
// in-memory buffer so the request still succeeds.
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
|
||||||
return nil, fmt.Errorf("storage: encode in-memory JPEG: %w", err)
|
|
||||||
}
|
|
||||||
return io.NopCloser(&buf), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeCache encodes img as JPEG to cachePath via an atomic temp→rename write,
|
|
||||||
// then opens and returns the cache file.
|
|
||||||
func writeCache(cachePath string, img image.Image) (io.ReadCloser, error) {
|
|
||||||
dir := filepath.Dir(cachePath)
|
|
||||||
tmp, err := os.CreateTemp(dir, ".cache-*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("storage: create temp file: %w", err)
|
|
||||||
}
|
|
||||||
tmpName := tmp.Name()
|
|
||||||
|
|
||||||
encErr := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 85})
|
|
||||||
closeErr := tmp.Close()
|
|
||||||
if encErr != nil {
|
|
||||||
os.Remove(tmpName)
|
|
||||||
return nil, fmt.Errorf("storage: encode cache JPEG: %w", encErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
os.Remove(tmpName)
|
|
||||||
return nil, fmt.Errorf("storage: close temp file: %w", closeErr)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpName, cachePath); err != nil {
|
|
||||||
os.Remove(tmpName)
|
|
||||||
return nil, fmt.Errorf("storage: rename cache file: %w", err)
|
|
||||||
}
|
|
||||||
f, err := os.Open(cachePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("storage: open cache file: %w", err)
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractVideoFrame uses ffmpeg to extract a single frame from a video file.
|
|
||||||
// It seeks 1 second in (keyframe-accurate fast seek) and pipes the frame out
|
|
||||||
// as PNG. If the video is shorter than 1 s the seek is silently ignored by
|
|
||||||
// ffmpeg and the first available frame is returned instead.
|
|
||||||
// Returns an error if ffmpeg is not installed or produces no output.
|
|
||||||
func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error) {
|
|
||||||
var out bytes.Buffer
|
|
||||||
cmd := exec.CommandContext(ctx, "ffmpeg",
|
|
||||||
"-ss", "1", // fast input seek; ignored gracefully on short files
|
|
||||||
"-i", srcPath,
|
|
||||||
"-vframes", "1",
|
|
||||||
"-f", "image2",
|
|
||||||
"-vcodec", "png",
|
|
||||||
"pipe:1",
|
|
||||||
)
|
|
||||||
cmd.Stdout = &out
|
|
||||||
cmd.Stderr = io.Discard // suppress ffmpeg progress output
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil || out.Len() == 0 {
|
|
||||||
return nil, fmt.Errorf("ffmpeg frame extract: %w", err)
|
|
||||||
}
|
|
||||||
return imaging.Decode(&out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Path helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (s *DiskStorage) originalPath(id uuid.UUID) string {
|
|
||||||
return filepath.Join(s.filesPath, id.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage) thumbCachePath(id uuid.UUID) string {
|
|
||||||
return filepath.Join(s.thumbsPath, id.String()+"_thumb.jpg")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage) previewCachePath(id uuid.UUID) string {
|
|
||||||
return filepath.Join(s.thumbsPath, id.String()+"_preview.jpg")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Image helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// placeholder returns a solid-colour image of size w×h for files that cannot
|
|
||||||
// be decoded as images. Uses #444455 from the design palette.
|
|
||||||
func placeholder(w, h int) *image.NRGBA {
|
|
||||||
return imaging.New(w, h, color.NRGBA{R: 0x44, G: 0x44, B: 0x55, A: 0xFF})
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
||||||
|
|
||||||
CREATE SCHEMA IF NOT EXISTS core;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS data;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS acl;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS activity;
|
|
||||||
|
|
||||||
-- UUID v7 generator
|
|
||||||
-- +goose StatementBegin
|
|
||||||
CREATE OR REPLACE FUNCTION public.uuid_v7(cts timestamptz DEFAULT clock_timestamp())
|
|
||||||
RETURNS uuid LANGUAGE plpgsql AS $$
|
|
||||||
DECLARE
|
|
||||||
state text = current_setting('uuidv7.old_tp', true);
|
|
||||||
old_tp text = split_part(state, ':', 1);
|
|
||||||
base int = coalesce(nullif(split_part(state, ':', 4), '')::int, (random()*16777215/2-1)::int);
|
|
||||||
tp text;
|
|
||||||
entropy text;
|
|
||||||
seq text = base;
|
|
||||||
seqn int = split_part(state, ':', 2);
|
|
||||||
ver text = coalesce(split_part(state, ':', 3), to_hex(8+(random()*3)::int));
|
|
||||||
BEGIN
|
|
||||||
base = (random()*16777215/2-1)::int;
|
|
||||||
tp = lpad(to_hex(floor(extract(epoch from cts)*1000)::int8), 12, '0') || '7';
|
|
||||||
IF tp IS DISTINCT FROM old_tp THEN
|
|
||||||
old_tp = tp;
|
|
||||||
ver = to_hex(8+(random()*3)::int);
|
|
||||||
base = (random()*16777215/2-1)::int;
|
|
||||||
seqn = base;
|
|
||||||
ELSE
|
|
||||||
seqn = seqn + (random()*1000)::int;
|
|
||||||
END IF;
|
|
||||||
PERFORM set_config('uuidv7.old_tp', old_tp||':'||seqn||':'||ver||':'||base, false);
|
|
||||||
entropy = md5(gen_random_uuid()::text);
|
|
||||||
seq = lpad(to_hex(seqn), 6, '0');
|
|
||||||
RETURN (tp || substring(seq from 1 for 3) || ver || substring(seq from 4 for 3) ||
|
|
||||||
substring(entropy from 1 for 12))::uuid;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- Extract timestamp from UUID v7
|
|
||||||
-- +goose StatementBegin
|
|
||||||
CREATE OR REPLACE FUNCTION public.uuid_extract_timestamp(uuid_val uuid)
|
|
||||||
RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
|
||||||
SELECT to_timestamp(
|
|
||||||
('x' || left(replace(uuid_val::text, '-', ''), 12))::bit(48)::bigint / 1000.0
|
|
||||||
);
|
|
||||||
$$;
|
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DROP FUNCTION IF EXISTS public.uuid_extract_timestamp(uuid);
|
|
||||||
DROP FUNCTION IF EXISTS public.uuid_v7(timestamptz);
|
|
||||||
|
|
||||||
DROP SCHEMA IF EXISTS activity;
|
|
||||||
DROP SCHEMA IF EXISTS acl;
|
|
||||||
DROP SCHEMA IF EXISTS data;
|
|
||||||
DROP SCHEMA IF EXISTS core;
|
|
||||||
|
|
||||||
DROP EXTENSION IF EXISTS "uuid-ossp";
|
|
||||||
DROP EXTENSION IF EXISTS pgcrypto;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
CREATE TABLE core.users (
|
|
||||||
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
name varchar(32) NOT NULL,
|
|
||||||
password text NOT NULL, -- bcrypt hash via pgcrypto
|
|
||||||
is_admin boolean NOT NULL DEFAULT false,
|
|
||||||
can_create boolean NOT NULL DEFAULT false,
|
|
||||||
is_blocked boolean NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
CONSTRAINT uni__users__name UNIQUE (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE core.mime_types (
|
|
||||||
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
name varchar(127) NOT NULL,
|
|
||||||
extension varchar(16) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT uni__mime_types__name UNIQUE (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE core.object_types (
|
|
||||||
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
name varchar(32) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT uni__object_types__name UNIQUE (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE core.users IS 'Application users';
|
|
||||||
COMMENT ON TABLE core.mime_types IS 'Whitelist of supported MIME types';
|
|
||||||
COMMENT ON TABLE core.object_types IS 'Reference: entity types for ACL and audit log';
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS core.object_types;
|
|
||||||
DROP TABLE IF EXISTS core.mime_types;
|
|
||||||
DROP TABLE IF EXISTS core.users;
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
CREATE TABLE data.categories (
|
|
||||||
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
|
|
||||||
name varchar(256) NOT NULL,
|
|
||||||
notes text,
|
|
||||||
color char(6),
|
|
||||||
metadata jsonb,
|
|
||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
is_public boolean NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
CONSTRAINT uni__categories__name UNIQUE (name),
|
|
||||||
CONSTRAINT chk__categories__color_hex
|
|
||||||
CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$')
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE data.tags (
|
|
||||||
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
|
|
||||||
name varchar(256) NOT NULL,
|
|
||||||
notes text,
|
|
||||||
color char(6),
|
|
||||||
category_id uuid REFERENCES data.categories(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
|
||||||
metadata jsonb,
|
|
||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
is_public boolean NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
CONSTRAINT uni__tags__name UNIQUE (name),
|
|
||||||
CONSTRAINT chk__tags__color_hex
|
|
||||||
CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$')
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE data.tag_rules (
|
|
||||||
when_tag_id uuid NOT NULL REFERENCES data.tags(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
then_tag_id uuid NOT NULL REFERENCES data.tags(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
is_active boolean NOT NULL DEFAULT true,
|
|
||||||
|
|
||||||
PRIMARY KEY (when_tag_id, then_tag_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE data.files (
|
|
||||||
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
|
|
||||||
original_name varchar(256), -- original filename at upload time
|
|
||||||
mime_id smallint NOT NULL REFERENCES core.mime_types(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
content_datetime timestamptz NOT NULL DEFAULT clock_timestamp(), -- content datetime (e.g. photo taken)
|
|
||||||
notes text,
|
|
||||||
metadata jsonb, -- user-editable key-value data
|
|
||||||
exif jsonb NOT NULL DEFAULT '{}'::jsonb, -- EXIF data extracted at upload (immutable)
|
|
||||||
phash bigint, -- perceptual hash for duplicate detection (future)
|
|
||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
is_public boolean NOT NULL DEFAULT false,
|
|
||||||
is_deleted boolean NOT NULL DEFAULT false -- soft delete (trash)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE data.file_tag (
|
|
||||||
file_id uuid NOT NULL REFERENCES data.files(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
tag_id uuid NOT NULL REFERENCES data.tags(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
|
|
||||||
PRIMARY KEY (file_id, tag_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE data.pools (
|
|
||||||
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
|
|
||||||
name varchar(256) NOT NULL,
|
|
||||||
notes text,
|
|
||||||
metadata jsonb,
|
|
||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
is_public boolean NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
CONSTRAINT uni__pools__name UNIQUE (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow
|
|
||||||
-- insertions without renumbering. Compact when gaps get too small.
|
|
||||||
CREATE TABLE data.file_pool (
|
|
||||||
file_id uuid NOT NULL REFERENCES data.files(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
pool_id uuid NOT NULL REFERENCES data.pools(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
position integer NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
PRIMARY KEY (file_id, pool_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE data.categories IS 'Logical grouping of tags';
|
|
||||||
COMMENT ON TABLE data.tags IS 'File labels/tags';
|
|
||||||
COMMENT ON TABLE data.tag_rules IS 'Auto-tagging rules: when when_tag is assigned, then_tag follows';
|
|
||||||
COMMENT ON TABLE data.files IS 'Managed files; actual content stored on disk as {id}.{ext}';
|
|
||||||
COMMENT ON TABLE data.file_tag IS 'Many-to-many: files <-> tags';
|
|
||||||
COMMENT ON TABLE data.pools IS 'Ordered collections of files';
|
|
||||||
COMMENT ON TABLE data.file_pool IS 'Many-to-many: files <-> pools, with ordering';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN data.files.original_name IS 'Original filename at upload time';
|
|
||||||
COMMENT ON COLUMN data.files.content_datetime IS 'Content datetime (e.g. when photo was taken); falls back to EXIF DateTimeOriginal';
|
|
||||||
COMMENT ON COLUMN data.files.metadata IS 'User-editable key-value metadata';
|
|
||||||
COMMENT ON COLUMN data.files.exif IS 'EXIF data extracted at upload time (immutable, system-managed)';
|
|
||||||
COMMENT ON COLUMN data.files.phash IS 'Perceptual hash for image/video duplicate detection';
|
|
||||||
COMMENT ON COLUMN data.files.is_deleted IS 'Soft-deleted files (trash); true = in recycle bin';
|
|
||||||
COMMENT ON COLUMN data.file_pool.position IS 'Manual ordering within pool; uses gapped integers';
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS data.file_pool;
|
|
||||||
DROP TABLE IF EXISTS data.pools;
|
|
||||||
DROP TABLE IF EXISTS data.file_tag;
|
|
||||||
DROP TABLE IF EXISTS data.files;
|
|
||||||
DROP TABLE IF EXISTS data.tag_rules;
|
|
||||||
DROP TABLE IF EXISTS data.tags;
|
|
||||||
DROP TABLE IF EXISTS data.categories;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
-- If is_public=true on the object, it is accessible to everyone (ACL ignored).
|
|
||||||
-- If is_public=false, only creator and users with can_view=true see it.
|
|
||||||
-- Admins bypass all ACL checks.
|
|
||||||
CREATE TABLE acl.permissions (
|
|
||||||
user_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
object_type_id smallint NOT NULL REFERENCES core.object_types(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
object_id uuid NOT NULL,
|
|
||||||
can_view boolean NOT NULL DEFAULT true,
|
|
||||||
can_edit boolean NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
PRIMARY KEY (user_id, object_type_id, object_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE acl.permissions IS 'Per-object permissions (used when is_public=false)';
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS acl.permissions;
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
CREATE TABLE activity.action_types (
|
|
||||||
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
name varchar(64) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT uni__action_types__name UNIQUE (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE activity.sessions (
|
|
||||||
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
token_hash text NOT NULL, -- hashed session token
|
|
||||||
user_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
user_agent varchar(256) NOT NULL,
|
|
||||||
started_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
||||||
expires_at timestamptz,
|
|
||||||
last_activity timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
||||||
is_active boolean NOT NULL DEFAULT true,
|
|
||||||
|
|
||||||
CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE activity.file_views (
|
|
||||||
file_id uuid NOT NULL REFERENCES data.files(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
user_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
||||||
|
|
||||||
PRIMARY KEY (file_id, viewed_at, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE activity.pool_views (
|
|
||||||
pool_id uuid NOT NULL REFERENCES data.pools(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
user_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
||||||
|
|
||||||
PRIMARY KEY (pool_id, viewed_at, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE activity.tag_uses (
|
|
||||||
tag_id uuid NOT NULL REFERENCES data.tags(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
user_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
used_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
||||||
is_included boolean NOT NULL, -- true=included in filter, false=excluded
|
|
||||||
|
|
||||||
PRIMARY KEY (tag_id, used_at, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE activity.audit_log (
|
|
||||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
user_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
action_type_id smallint NOT NULL REFERENCES activity.action_types(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
object_type_id smallint REFERENCES core.object_types(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
object_id uuid,
|
|
||||||
details jsonb, -- action-specific payload
|
|
||||||
performed_at timestamptz NOT NULL DEFAULT statement_timestamp()
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE activity.action_types IS 'Reference: types of auditable user actions';
|
|
||||||
COMMENT ON TABLE activity.sessions IS 'Active user sessions';
|
|
||||||
COMMENT ON TABLE activity.file_views IS 'File view history';
|
|
||||||
COMMENT ON TABLE activity.pool_views IS 'Pool view history';
|
|
||||||
COMMENT ON TABLE activity.tag_uses IS 'Tag usage in filters';
|
|
||||||
COMMENT ON TABLE activity.audit_log IS 'Unified audit trail for all user actions';
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS activity.audit_log;
|
|
||||||
DROP TABLE IF EXISTS activity.tag_uses;
|
|
||||||
DROP TABLE IF EXISTS activity.pool_views;
|
|
||||||
DROP TABLE IF EXISTS activity.file_views;
|
|
||||||
DROP TABLE IF EXISTS activity.sessions;
|
|
||||||
DROP TABLE IF EXISTS activity.action_types;
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
-- core
|
|
||||||
CREATE INDEX idx__users__name ON core.users USING hash (name);
|
|
||||||
|
|
||||||
-- data.categories
|
|
||||||
CREATE INDEX idx__categories__creator_id ON data.categories USING hash (creator_id);
|
|
||||||
|
|
||||||
-- data.tags
|
|
||||||
CREATE INDEX idx__tags__category_id ON data.tags USING hash (category_id);
|
|
||||||
CREATE INDEX idx__tags__creator_id ON data.tags USING hash (creator_id);
|
|
||||||
|
|
||||||
-- data.tag_rules
|
|
||||||
CREATE INDEX idx__tag_rules__when ON data.tag_rules USING hash (when_tag_id);
|
|
||||||
CREATE INDEX idx__tag_rules__then ON data.tag_rules USING hash (then_tag_id);
|
|
||||||
|
|
||||||
-- data.files
|
|
||||||
CREATE INDEX idx__files__mime_id ON data.files USING hash (mime_id);
|
|
||||||
CREATE INDEX idx__files__creator_id ON data.files USING hash (creator_id);
|
|
||||||
CREATE INDEX idx__files__content_datetime ON data.files USING btree (content_datetime DESC NULLS LAST);
|
|
||||||
CREATE INDEX idx__files__is_deleted ON data.files USING btree (is_deleted) WHERE is_deleted = true;
|
|
||||||
CREATE INDEX idx__files__phash ON data.files USING btree (phash) WHERE phash IS NOT NULL;
|
|
||||||
|
|
||||||
-- data.file_tag
|
|
||||||
CREATE INDEX idx__file_tag__tag_id ON data.file_tag USING hash (tag_id);
|
|
||||||
CREATE INDEX idx__file_tag__file_id ON data.file_tag USING hash (file_id);
|
|
||||||
|
|
||||||
-- data.pools
|
|
||||||
CREATE INDEX idx__pools__creator_id ON data.pools USING hash (creator_id);
|
|
||||||
|
|
||||||
-- data.file_pool
|
|
||||||
CREATE INDEX idx__file_pool__pool_id ON data.file_pool USING hash (pool_id);
|
|
||||||
CREATE INDEX idx__file_pool__file_id ON data.file_pool USING hash (file_id);
|
|
||||||
|
|
||||||
-- acl.permissions
|
|
||||||
CREATE INDEX idx__acl__object ON acl.permissions USING btree (object_type_id, object_id);
|
|
||||||
CREATE INDEX idx__acl__user ON acl.permissions USING hash (user_id);
|
|
||||||
|
|
||||||
-- activity.sessions
|
|
||||||
CREATE INDEX idx__sessions__user_id ON activity.sessions USING hash (user_id);
|
|
||||||
CREATE INDEX idx__sessions__token_hash ON activity.sessions USING hash (token_hash);
|
|
||||||
|
|
||||||
-- activity.file_views
|
|
||||||
CREATE INDEX idx__file_views__user_id ON activity.file_views USING hash (user_id);
|
|
||||||
|
|
||||||
-- activity.pool_views
|
|
||||||
CREATE INDEX idx__pool_views__user_id ON activity.pool_views USING hash (user_id);
|
|
||||||
|
|
||||||
-- activity.tag_uses
|
|
||||||
CREATE INDEX idx__tag_uses__user_id ON activity.tag_uses USING hash (user_id);
|
|
||||||
|
|
||||||
-- activity.audit_log
|
|
||||||
CREATE INDEX idx__audit_log__user_id ON activity.audit_log USING hash (user_id);
|
|
||||||
CREATE INDEX idx__audit_log__action_type_id ON activity.audit_log USING hash (action_type_id);
|
|
||||||
CREATE INDEX idx__audit_log__object ON activity.audit_log USING btree (object_type_id, object_id)
|
|
||||||
WHERE object_id IS NOT NULL;
|
|
||||||
CREATE INDEX idx__audit_log__performed_at ON activity.audit_log USING btree (performed_at DESC);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS activity.idx__audit_log__performed_at;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__audit_log__object;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__audit_log__action_type_id;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__audit_log__user_id;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__tag_uses__user_id;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__pool_views__user_id;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__file_views__user_id;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__sessions__token_hash;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__sessions__user_id;
|
|
||||||
DROP INDEX IF EXISTS acl.idx__acl__user;
|
|
||||||
DROP INDEX IF EXISTS acl.idx__acl__object;
|
|
||||||
DROP INDEX IF EXISTS data.idx__file_pool__file_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__file_pool__pool_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__pools__creator_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__file_tag__file_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__file_tag__tag_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__files__phash;
|
|
||||||
DROP INDEX IF EXISTS data.idx__files__is_deleted;
|
|
||||||
DROP INDEX IF EXISTS data.idx__files__content_datetime;
|
|
||||||
DROP INDEX IF EXISTS data.idx__files__creator_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__files__mime_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__tag_rules__then;
|
|
||||||
DROP INDEX IF EXISTS data.idx__tag_rules__when;
|
|
||||||
DROP INDEX IF EXISTS data.idx__tags__creator_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__tags__category_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__categories__creator_id;
|
|
||||||
DROP INDEX IF EXISTS core.idx__users__name;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
INSERT INTO core.mime_types (name, extension) VALUES
|
|
||||||
('image/jpeg', 'jpg'),
|
|
||||||
('image/png', 'png'),
|
|
||||||
('image/gif', 'gif'),
|
|
||||||
('image/webp', 'webp'),
|
|
||||||
('video/mp4', 'mp4'),
|
|
||||||
('video/quicktime', 'mov'),
|
|
||||||
('video/x-msvideo', 'avi'),
|
|
||||||
('video/webm', 'webm'),
|
|
||||||
('video/3gpp', '3gp'),
|
|
||||||
('video/x-m4v', 'm4v');
|
|
||||||
|
|
||||||
INSERT INTO core.object_types (name) VALUES
|
|
||||||
('file'), ('tag'), ('category'), ('pool');
|
|
||||||
|
|
||||||
INSERT INTO activity.action_types (name) VALUES
|
|
||||||
-- Auth
|
|
||||||
('user_login'), ('user_logout'),
|
|
||||||
-- Files
|
|
||||||
('file_create'), ('file_edit'), ('file_delete'), ('file_restore'),
|
|
||||||
('file_permanent_delete'), ('file_replace'),
|
|
||||||
-- Tags
|
|
||||||
('tag_create'), ('tag_edit'), ('tag_delete'),
|
|
||||||
-- Categories
|
|
||||||
('category_create'), ('category_edit'), ('category_delete'),
|
|
||||||
-- Pools
|
|
||||||
('pool_create'), ('pool_edit'), ('pool_delete'),
|
|
||||||
-- Relations
|
|
||||||
('file_tag_add'), ('file_tag_remove'),
|
|
||||||
('file_pool_add'), ('file_pool_remove'),
|
|
||||||
-- ACL
|
|
||||||
('acl_change'),
|
|
||||||
-- Admin
|
|
||||||
('user_create'), ('user_delete'), ('user_block'), ('user_unblock'),
|
|
||||||
('user_role_change'),
|
|
||||||
-- Sessions
|
|
||||||
('session_terminate');
|
|
||||||
|
|
||||||
INSERT INTO core.users (name, password, is_admin, can_create) VALUES
|
|
||||||
('admin', '$2a$10$zk.VTFjRRxbkTE7cKfc7KOWeZfByk1VEkbkgZMJggI1fFf.yDEHZy', true, true);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DELETE FROM core.users WHERE name = 'admin';
|
|
||||||
DELETE FROM activity.action_types;
|
|
||||||
DELETE FROM core.object_types;
|
|
||||||
DELETE FROM core.mime_types;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import "embed"
|
|
||||||
|
|
||||||
// FS holds all goose migration SQL files, embedded at build time.
|
|
||||||
//
|
|
||||||
//go:embed *.sql
|
|
||||||
var FS embed.FS
|
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
--
|
||||||
|
-- PostgreSQL database dump
|
||||||
|
--
|
||||||
|
|
||||||
|
-- Dumped from database version 14.18 (Ubuntu 14.18-0ubuntu0.22.04.1)
|
||||||
|
-- Dumped by pg_dump version 17.4
|
||||||
|
|
||||||
|
SET statement_timeout = 0;
|
||||||
|
SET lock_timeout = 0;
|
||||||
|
SET idle_in_transaction_session_timeout = 0;
|
||||||
|
SET transaction_timeout = 0;
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
SELECT pg_catalog.set_config('search_path', '', false);
|
||||||
|
SET check_function_bodies = false;
|
||||||
|
SET xmloption = content;
|
||||||
|
SET client_min_messages = warning;
|
||||||
|
SET row_security = off;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: acl; Type: SCHEMA; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SCHEMA acl;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: activity; Type: SCHEMA; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SCHEMA activity;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: data; Type: SCHEMA; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SCHEMA data;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
-- *not* creating schema, since initdb creates it
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: system; Type: SCHEMA; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SCHEMA system;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)';
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: add_file_to_tag_recursive(uuid, uuid); Type: FUNCTION; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE FUNCTION data.add_file_to_tag_recursive(f_id uuid, t_id uuid) RETURNS SETOF uuid
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
tmp uuid;
|
||||||
|
tt_id uuid;
|
||||||
|
ttt_id uuid;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO data.file_tag VALUES (f_id, t_id) ON CONFLICT DO NOTHING RETURNING tag_id INTO tmp;
|
||||||
|
IF tmp IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
RETURN NEXT t_id;
|
||||||
|
FOR tt_id IN
|
||||||
|
SELECT a.add_tag_id FROM data.autotags a WHERE a.trigger_tag_id=t_id AND a.is_active
|
||||||
|
LOOP
|
||||||
|
FOR ttt_id IN SELECT data.add_file_to_tag_recursive(f_id, tt_id)
|
||||||
|
LOOP
|
||||||
|
RETURN NEXT ttt_id;
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: uuid_extract_timestamp(uuid); Type: FUNCTION; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE FUNCTION public.uuid_extract_timestamp(uuid_val uuid) RETURNS timestamp with time zone
|
||||||
|
LANGUAGE sql IMMUTABLE
|
||||||
|
AS $$
|
||||||
|
SELECT to_timestamp(
|
||||||
|
('x' || LEFT(REPLACE(uuid_val::TEXT, '-', ''), 12))::BIT(48)::BIGINT
|
||||||
|
/ 1000.0
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: uuid_v7(timestamp with time zone); Type: FUNCTION; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE FUNCTION public.uuid_v7(cts timestamp with time zone DEFAULT clock_timestamp()) RETURNS uuid
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
state text = current_setting('uuidv7.old_tp',true);
|
||||||
|
old_tp text = split_part(state, ':',1);
|
||||||
|
base int = coalesce(nullif(split_part(state,':',4),'')::int,(random()*16777215/2-1)::int);
|
||||||
|
tp text;
|
||||||
|
entropy text;
|
||||||
|
seq text=base;
|
||||||
|
seqn int=split_part(state,':',2);
|
||||||
|
ver text = coalesce(split_part(state,':',3),to_hex(8+(random()*3)::int));
|
||||||
|
BEGIN
|
||||||
|
base = (random()*16777215/2-1)::int;
|
||||||
|
tp = lpad(to_hex(floor(extract(epoch from cts)*1000)::int8),12,'0')||'7';
|
||||||
|
if tp is distinct from old_tp then
|
||||||
|
old_tp = tp;
|
||||||
|
ver = to_hex(8+(random()*3)::int);
|
||||||
|
base = (random()*16777215/2-1)::int;
|
||||||
|
seqn = base;
|
||||||
|
else
|
||||||
|
seqn = seqn+(random()*1000)::int;
|
||||||
|
end if;
|
||||||
|
perform set_config('uuidv7.old_tp',old_tp||':'||seqn||':'||ver||':'||base, false);
|
||||||
|
entropy = md5(gen_random_uuid()::text);
|
||||||
|
seq = lpad(to_hex(seqn),6,'0');
|
||||||
|
return (tp || substring(seq from 1 for 3) || ver || substring(seq from 4 for 3) ||
|
||||||
|
substring(entropy from 1 for 12))::uuid;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
SET default_table_access_method = heap;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: categories; Type: TABLE; Schema: acl; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE acl.categories (
|
||||||
|
user_id smallint NOT NULL,
|
||||||
|
category_id uuid NOT NULL,
|
||||||
|
view boolean NOT NULL,
|
||||||
|
edit boolean NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: files; Type: TABLE; Schema: acl; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE acl.files (
|
||||||
|
user_id smallint NOT NULL,
|
||||||
|
file_id uuid NOT NULL,
|
||||||
|
view boolean NOT NULL,
|
||||||
|
edit boolean NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: pools; Type: TABLE; Schema: acl; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE acl.pools (
|
||||||
|
user_id smallint NOT NULL,
|
||||||
|
pool_id uuid NOT NULL,
|
||||||
|
view boolean NOT NULL,
|
||||||
|
edit boolean NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tags; Type: TABLE; Schema: acl; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE acl.tags (
|
||||||
|
user_id smallint NOT NULL,
|
||||||
|
tag_id uuid NOT NULL,
|
||||||
|
view boolean NOT NULL,
|
||||||
|
edit boolean NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: file_views; Type: TABLE; Schema: activity; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE activity.file_views (
|
||||||
|
file_id uuid NOT NULL,
|
||||||
|
"timestamp" timestamp with time zone NOT NULL,
|
||||||
|
user_id smallint NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: pool_views; Type: TABLE; Schema: activity; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE activity.pool_views (
|
||||||
|
pool_id uuid NOT NULL,
|
||||||
|
"timestamp" timestamp with time zone NOT NULL,
|
||||||
|
user_id smallint NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: sessions; Type: TABLE; Schema: activity; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE activity.sessions (
|
||||||
|
id integer NOT NULL,
|
||||||
|
token text NOT NULL,
|
||||||
|
user_id smallint NOT NULL,
|
||||||
|
user_agent character varying(256) NOT NULL,
|
||||||
|
started_at timestamp with time zone DEFAULT statement_timestamp() NOT NULL,
|
||||||
|
expires_at timestamp with time zone,
|
||||||
|
last_activity timestamp with time zone DEFAULT statement_timestamp() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: sessions_id_seq; Type: SEQUENCE; Schema: activity; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE activity.sessions_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: sessions_id_seq; Type: SEQUENCE OWNED BY; Schema: activity; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE activity.sessions_id_seq OWNED BY activity.sessions.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tag_uses; Type: TABLE; Schema: activity; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE activity.tag_uses (
|
||||||
|
tag_id uuid NOT NULL,
|
||||||
|
"timestamp" timestamp with time zone NOT NULL,
|
||||||
|
user_id smallint NOT NULL,
|
||||||
|
included boolean NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: autotags; Type: TABLE; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE data.autotags (
|
||||||
|
trigger_tag_id uuid NOT NULL,
|
||||||
|
add_tag_id uuid NOT NULL,
|
||||||
|
is_active boolean DEFAULT true NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: categories; Type: TABLE; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE data.categories (
|
||||||
|
id uuid DEFAULT public.uuid_v7() NOT NULL,
|
||||||
|
name character varying(256) NOT NULL,
|
||||||
|
notes text DEFAULT ''::text NOT NULL,
|
||||||
|
color character(6),
|
||||||
|
creator_id smallint NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: file_pool; Type: TABLE; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE data.file_pool (
|
||||||
|
file_id uuid NOT NULL,
|
||||||
|
pool_id uuid NOT NULL,
|
||||||
|
number smallint NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: file_tag; Type: TABLE; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE data.file_tag (
|
||||||
|
file_id uuid NOT NULL,
|
||||||
|
tag_id uuid NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: files; Type: TABLE; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE data.files (
|
||||||
|
id uuid DEFAULT public.uuid_v7() NOT NULL,
|
||||||
|
name character varying(256),
|
||||||
|
mime_id smallint NOT NULL,
|
||||||
|
datetime timestamp with time zone DEFAULT clock_timestamp() NOT NULL,
|
||||||
|
notes text,
|
||||||
|
metadata jsonb NOT NULL,
|
||||||
|
creator_id smallint NOT NULL,
|
||||||
|
is_deleted boolean DEFAULT false NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: pools; Type: TABLE; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE data.pools (
|
||||||
|
id uuid DEFAULT public.uuid_v7() NOT NULL,
|
||||||
|
name character varying(256) NOT NULL,
|
||||||
|
notes text,
|
||||||
|
creator_id smallint NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tags; Type: TABLE; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE data.tags (
|
||||||
|
id uuid DEFAULT public.uuid_v7() NOT NULL,
|
||||||
|
name character varying(256) NOT NULL,
|
||||||
|
notes text,
|
||||||
|
color character(6),
|
||||||
|
category_id uuid,
|
||||||
|
creator_id smallint NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: mime; Type: TABLE; Schema: system; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE system.mime (
|
||||||
|
id smallint NOT NULL,
|
||||||
|
name character varying(127) NOT NULL,
|
||||||
|
extension character varying(16) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: mime_id_seq; Type: SEQUENCE; Schema: system; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE system.mime_id_seq
|
||||||
|
AS smallint
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: mime_id_seq; Type: SEQUENCE OWNED BY; Schema: system; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE system.mime_id_seq OWNED BY system.mime.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: users; Type: TABLE; Schema: system; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE system.users (
|
||||||
|
id smallint NOT NULL,
|
||||||
|
name character varying(32) NOT NULL,
|
||||||
|
password text NOT NULL,
|
||||||
|
is_admin boolean DEFAULT false NOT NULL,
|
||||||
|
can_create boolean DEFAULT false NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: users_id_seq; Type: SEQUENCE; Schema: system; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE system.users_id_seq
|
||||||
|
AS smallint
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: system; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE system.users_id_seq OWNED BY system.users.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: sessions id; Type: DEFAULT; Schema: activity; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY activity.sessions ALTER COLUMN id SET DEFAULT nextval('activity.sessions_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: mime id; Type: DEFAULT; Schema: system; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY system.mime ALTER COLUMN id SET DEFAULT nextval('system.mime_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: users id; Type: DEFAULT; Schema: system; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY system.users ALTER COLUMN id SET DEFAULT nextval('system.users_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: categories categories_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY acl.categories
|
||||||
|
ADD CONSTRAINT categories_pkey PRIMARY KEY (user_id, category_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: files files_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY acl.files
|
||||||
|
ADD CONSTRAINT files_pkey PRIMARY KEY (user_id, file_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: pools pools_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY acl.pools
|
||||||
|
ADD CONSTRAINT pools_pkey PRIMARY KEY (user_id, pool_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY acl.tags
|
||||||
|
ADD CONSTRAINT tags_pkey PRIMARY KEY (user_id, tag_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: file_views file_views_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY activity.file_views
|
||||||
|
ADD CONSTRAINT file_views_pkey PRIMARY KEY (file_id, "timestamp", user_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: pool_views pool_views_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY activity.pool_views
|
||||||
|
ADD CONSTRAINT pool_views_pkey PRIMARY KEY (pool_id, "timestamp", user_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY activity.sessions
|
||||||
|
ADD CONSTRAINT sessions_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tag_uses tag_uses_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY activity.tag_uses
|
||||||
|
ADD CONSTRAINT tag_uses_pkey PRIMARY KEY (tag_id, "timestamp", user_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: autotags autotags_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY data.autotags
|
||||||
|
ADD CONSTRAINT autotags_pkey PRIMARY KEY (trigger_tag_id, add_tag_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: categories categories_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY data.categories
|
||||||
|
ADD CONSTRAINT categories_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: file_pool file_pool_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY data.file_pool
|
||||||
|
ADD CONSTRAINT file_pool_pkey PRIMARY KEY (file_id, pool_id, number);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: file_tag file_tag_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY data.file_tag
|
||||||
|
ADD CONSTRAINT file_tag_pkey PRIMARY KEY (file_id, tag_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: files files_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY data.files
|
||||||
|
ADD CONSTRAINT files_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: pools pools_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY data.pools
|
||||||
|
ADD CONSTRAINT pools_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY data.tags
|
||||||
|
ADD CONSTRAINT tags_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: mime mime_pkey; Type: CONSTRAINT; Schema: system; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY system.mime
|
||||||
|
ADD CONSTRAINT mime_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: users users_pkey; Type: CONSTRAINT; Schema: system; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY system.users
|
||||||
|
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- PostgreSQL database dump complete
|
||||||
|
--
|
||||||
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
# Tanabata File Manager — Frontend Structure
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Framework**: SvelteKit (SPA mode, `ssr: false`)
|
|
||||||
- **Language**: TypeScript
|
|
||||||
- **CSS**: Tailwind CSS + CSS custom properties (hybrid)
|
|
||||||
- **API types**: Auto-generated via openapi-typescript
|
|
||||||
- **PWA**: Service worker + web manifest
|
|
||||||
- **Font**: Epilogue (variable weight)
|
|
||||||
- **Package manager**: npm
|
|
||||||
|
|
||||||
## Monorepo Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
tanabata/
|
|
||||||
├── backend/ ← Go project (go.mod in here)
|
|
||||||
│ ├── cmd/
|
|
||||||
│ ├── internal/
|
|
||||||
│ ├── migrations/
|
|
||||||
│ ├── go.mod
|
|
||||||
│ └── go.sum
|
|
||||||
│
|
|
||||||
├── frontend/ ← SvelteKit project (package.json in here)
|
|
||||||
│ └── (see below)
|
|
||||||
│
|
|
||||||
├── openapi.yaml ← Shared API contract (root level)
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Dockerfile
|
|
||||||
├── .env.example
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
`openapi.yaml` lives at repository root — both backend and frontend
|
|
||||||
reference it. The frontend generates types from it; the backend
|
|
||||||
validates its handlers against it.
|
|
||||||
|
|
||||||
## Frontend Directory Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/
|
|
||||||
├── package.json
|
|
||||||
├── svelte.config.js
|
|
||||||
├── vite.config.ts
|
|
||||||
├── tsconfig.json
|
|
||||||
├── tailwind.config.ts
|
|
||||||
├── postcss.config.js
|
|
||||||
│
|
|
||||||
├── src/
|
|
||||||
│ ├── app.html # Shell HTML (PWA meta, font preload)
|
|
||||||
│ ├── app.css # Tailwind directives + CSS custom properties
|
|
||||||
│ ├── hooks.server.ts # Server hooks (not used in SPA mode)
|
|
||||||
│ ├── hooks.client.ts # Client hooks (global error handling)
|
|
||||||
│ │
|
|
||||||
│ ├── lib/ # Shared code ($lib/ alias)
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── api/ # API client layer
|
|
||||||
│ │ │ ├── client.ts # Base fetch wrapper: auth headers, token refresh,
|
|
||||||
│ │ │ │ # error parsing, base URL
|
|
||||||
│ │ │ ├── files.ts # listFiles, getFile, uploadFile, deleteFile, etc.
|
|
||||||
│ │ │ ├── tags.ts # listTags, createTag, getTag, updateTag, etc.
|
|
||||||
│ │ │ ├── categories.ts # Category API functions
|
|
||||||
│ │ │ ├── pools.ts # Pool API functions
|
|
||||||
│ │ │ ├── auth.ts # login, logout, refresh, listSessions
|
|
||||||
│ │ │ ├── acl.ts # getPermissions, setPermissions
|
|
||||||
│ │ │ ├── users.ts # getMe, updateMe, admin user CRUD
|
|
||||||
│ │ │ ├── audit.ts # queryAuditLog
|
|
||||||
│ │ │ ├── schema.ts # AUTO-GENERATED from openapi.yaml (do not edit)
|
|
||||||
│ │ │ └── types.ts # Friendly type aliases:
|
|
||||||
│ │ │ # export type File = components["schemas"]["File"]
|
|
||||||
│ │ │ # export type Tag = components["schemas"]["Tag"]
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── components/ # Reusable UI components
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── layout/ # App shell
|
|
||||||
│ │ │ │ ├── Navbar.svelte # Bottom navigation bar (mobile-first)
|
|
||||||
│ │ │ │ ├── Header.svelte # Section header with sorting controls
|
|
||||||
│ │ │ │ ├── SelectionBar.svelte # Floating bar for multi-select actions
|
|
||||||
│ │ │ │ └── Loader.svelte # Full-screen loading overlay
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── file/ # File-related components
|
|
||||||
│ │ │ │ ├── FileGrid.svelte # Thumbnail grid with infinite scroll
|
|
||||||
│ │ │ │ ├── FileCard.svelte # Single thumbnail (160×160, selectable)
|
|
||||||
│ │ │ │ ├── FileViewer.svelte # Full-screen preview with prev/next navigation
|
|
||||||
│ │ │ │ ├── FileUpload.svelte # Upload form + drag-and-drop zone
|
|
||||||
│ │ │ │ ├── FileDetail.svelte # Metadata editor (notes, datetime, tags)
|
|
||||||
│ │ │ │ └── FilterBar.svelte # DSL filter builder UI
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── tag/ # Tag-related components
|
|
||||||
│ │ │ │ ├── TagBadge.svelte # Colored pill with tag name
|
|
||||||
│ │ │ │ ├── TagPicker.svelte # Searchable tag selector (add/remove)
|
|
||||||
│ │ │ │ ├── TagList.svelte # Tag grid for section view
|
|
||||||
│ │ │ │ └── TagRuleEditor.svelte # Auto-tag rule management
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── pool/ # Pool-related components
|
|
||||||
│ │ │ │ ├── PoolCard.svelte # Pool preview card
|
|
||||||
│ │ │ │ ├── PoolFileList.svelte # Ordered file list with drag reorder
|
|
||||||
│ │ │ │ └── PoolDetail.svelte # Pool metadata editor
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── acl/ # Access control components
|
|
||||||
│ │ │ │ └── PermissionEditor.svelte # User permission grid
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ └── common/ # Shared primitives
|
|
||||||
│ │ │ ├── Button.svelte
|
|
||||||
│ │ │ ├── Modal.svelte
|
|
||||||
│ │ │ ├── ConfirmDialog.svelte
|
|
||||||
│ │ │ ├── Toast.svelte
|
|
||||||
│ │ │ ├── InfiniteScroll.svelte
|
|
||||||
│ │ │ ├── Pagination.svelte
|
|
||||||
│ │ │ ├── SortDropdown.svelte
|
|
||||||
│ │ │ ├── SearchInput.svelte
|
|
||||||
│ │ │ ├── ColorPicker.svelte
|
|
||||||
│ │ │ ├── Checkbox.svelte # Three-state: checked, unchecked, partial
|
|
||||||
│ │ │ └── EmptyState.svelte
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── stores/ # Svelte stores (global state)
|
|
||||||
│ │ │ ├── auth.ts # Current user, JWT tokens, isAuthenticated
|
|
||||||
│ │ │ ├── selection.ts # Selected item IDs, selection mode toggle
|
|
||||||
│ │ │ ├── sorting.ts # Per-section sort key + order (persisted to localStorage)
|
|
||||||
│ │ │ ├── theme.ts # Dark/light mode (persisted, respects prefers-color-scheme)
|
|
||||||
│ │ │ └── toast.ts # Notification queue (success, error, info)
|
|
||||||
│ │ │
|
|
||||||
│ │ └── utils/ # Pure helper functions
|
|
||||||
│ │ ├── format.ts # formatDate, formatFileSize, formatDuration
|
|
||||||
│ │ ├── dsl.ts # Filter DSL builder: UI state → query string
|
|
||||||
│ │ ├── pwa.ts # PWA reset, cache clear, update prompt
|
|
||||||
│ │ └── keyboard.ts # Keyboard shortcut helpers (Ctrl+A, Escape, etc.)
|
|
||||||
│ │
|
|
||||||
│ ├── routes/ # SvelteKit file-based routing
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── +layout.svelte # Root layout: Navbar, theme wrapper, toast container
|
|
||||||
│ │ ├── +layout.ts # Root load: auth guard → redirect to /login if no token
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── +page.svelte # / → redirect to /files
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── login/
|
|
||||||
│ │ │ └── +page.svelte # Login form (decorative Tanabata images)
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── files/
|
|
||||||
│ │ │ ├── +page.svelte # File grid: filter bar, sort, multi-select, upload
|
|
||||||
│ │ │ ├── +page.ts # Load: initial file list (cursor page)
|
|
||||||
│ │ │ ├── [id]/
|
|
||||||
│ │ │ │ ├── +page.svelte # File view: preview, metadata, tags, ACL
|
|
||||||
│ │ │ │ └── +page.ts # Load: file detail + tags
|
|
||||||
│ │ │ └── trash/
|
|
||||||
│ │ │ ├── +page.svelte # Trash: restore / permanent delete
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── tags/
|
|
||||||
│ │ │ ├── +page.svelte # Tag list: search, sort, multi-select
|
|
||||||
│ │ │ ├── +page.ts
|
|
||||||
│ │ │ ├── new/
|
|
||||||
│ │ │ │ └── +page.svelte # Create tag form
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ │ ├── +page.svelte # Tag detail: edit, category, rules, parent tags
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── categories/
|
|
||||||
│ │ │ ├── +page.svelte # Category list
|
|
||||||
│ │ │ ├── +page.ts
|
|
||||||
│ │ │ ├── new/
|
|
||||||
│ │ │ │ └── +page.svelte
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ │ ├── +page.svelte # Category detail: edit, view tags
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── pools/
|
|
||||||
│ │ │ ├── +page.svelte # Pool list
|
|
||||||
│ │ │ ├── +page.ts
|
|
||||||
│ │ │ ├── new/
|
|
||||||
│ │ │ │ └── +page.svelte
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ │ ├── +page.svelte # Pool detail: files (reorderable), filter, edit
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── settings/
|
|
||||||
│ │ │ ├── +page.svelte # Profile: name, password, active sessions
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ └── admin/
|
|
||||||
│ │ ├── +layout.svelte # Admin layout: restrict to is_admin
|
|
||||||
│ │ ├── users/
|
|
||||||
│ │ │ ├── +page.svelte # User management list
|
|
||||||
│ │ │ ├── +page.ts
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ │ ├── +page.svelte # User detail: role, block/unblock
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ └── audit/
|
|
||||||
│ │ ├── +page.svelte # Audit log with filters
|
|
||||||
│ │ └── +page.ts
|
|
||||||
│ │
|
|
||||||
│ └── service-worker.ts # PWA: offline cache for pinned files, app shell caching
|
|
||||||
│
|
|
||||||
└── static/
|
|
||||||
├── favicon.png
|
|
||||||
├── favicon.ico
|
|
||||||
├── manifest.webmanifest # PWA manifest (name, icons, theme_color)
|
|
||||||
├── images/
|
|
||||||
│ ├── tanabata-left.png # Login page decorations (from current design)
|
|
||||||
│ ├── tanabata-right.png
|
|
||||||
│ └── icons/ # PWA icons (192×192, 512×512, etc.)
|
|
||||||
└── fonts/
|
|
||||||
└── Epilogue-VariableFont_wght.ttf
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Architecture Decisions
|
|
||||||
|
|
||||||
### CSS Hybrid: Tailwind + Custom Properties
|
|
||||||
|
|
||||||
Theme colors defined as CSS custom properties in `app.css`:
|
|
||||||
|
|
||||||
```css
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--color-bg-primary: #312F45;
|
|
||||||
--color-bg-secondary: #181721;
|
|
||||||
--color-bg-elevated: #111118;
|
|
||||||
--color-accent: #9592B5;
|
|
||||||
--color-accent-hover: #7D7AA4;
|
|
||||||
--color-text-primary: #f0f0f0;
|
|
||||||
--color-text-muted: #9999AD;
|
|
||||||
--color-danger: #DB6060;
|
|
||||||
--color-info: #4DC7ED;
|
|
||||||
--color-warning: #F5E872;
|
|
||||||
--color-tag-default: #444455;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
|
||||||
--color-bg-primary: #f5f5f5;
|
|
||||||
--color-bg-secondary: #ffffff;
|
|
||||||
/* ... */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Tailwind references them in `tailwind.config.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export default {
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
bg: {
|
|
||||||
primary: 'var(--color-bg-primary)',
|
|
||||||
secondary: 'var(--color-bg-secondary)',
|
|
||||||
elevated: 'var(--color-bg-elevated)',
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: 'var(--color-accent)',
|
|
||||||
hover: 'var(--color-accent-hover)',
|
|
||||||
},
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Epilogue', 'sans-serif'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
darkMode: 'class', // controlled via data-theme attribute
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Usage in components: `<div class="bg-bg-primary text-text-primary rounded-xl p-4">`.
|
|
||||||
Complex cases use scoped `<style>` inside `.svelte` files.
|
|
||||||
|
|
||||||
### API Client Pattern
|
|
||||||
|
|
||||||
`client.ts` — thin wrapper around fetch:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// $lib/api/client.ts
|
|
||||||
import { authStore } from '$lib/stores/auth';
|
|
||||||
|
|
||||||
const BASE = '/api/v1';
|
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
const token = get(authStore).accessToken;
|
|
||||||
const res = await fetch(BASE + path, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
|
||||||
...init?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (res.status === 401) {
|
|
||||||
// attempt refresh, retry once
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json();
|
|
||||||
throw new ApiError(res.status, err.code, err.message, err.details);
|
|
||||||
}
|
|
||||||
if (res.status === 204) return undefined as T;
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
get: <T>(path: string) => request<T>(path),
|
|
||||||
post: <T>(path: string, body?: unknown) =>
|
|
||||||
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
|
||||||
patch: <T>(path: string, body?: unknown) =>
|
|
||||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
|
||||||
put: <T>(path: string, body?: unknown) =>
|
|
||||||
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
|
||||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
|
||||||
upload: <T>(path: string, formData: FormData) =>
|
|
||||||
request<T>(path, { method: 'POST', body: formData, headers: {} }),
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Domain-specific modules use it:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// $lib/api/files.ts
|
|
||||||
import { api } from './client';
|
|
||||||
import type { File, FileCursorPage } from './types';
|
|
||||||
|
|
||||||
export function listFiles(params: Record<string, string>) {
|
|
||||||
const qs = new URLSearchParams(params).toString();
|
|
||||||
return api.get<FileCursorPage>(`/files?${qs}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function uploadFile(formData: FormData) {
|
|
||||||
return api.upload<File>('/files', formData);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type Generation
|
|
||||||
|
|
||||||
Script in `package.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"scripts": {
|
|
||||||
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
|
|
||||||
"dev": "npm run generate:types && vite dev",
|
|
||||||
"build": "npm run generate:types && vite build"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Friendly aliases in `types.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { components } from './schema';
|
|
||||||
|
|
||||||
export type File = components['schemas']['File'];
|
|
||||||
export type Tag = components['schemas']['Tag'];
|
|
||||||
export type Category = components['schemas']['Category'];
|
|
||||||
export type Pool = components['schemas']['Pool'];
|
|
||||||
export type FileCursorPage = components['schemas']['FileCursorPage'];
|
|
||||||
export type TagOffsetPage = components['schemas']['TagOffsetPage'];
|
|
||||||
export type Error = components['schemas']['Error'];
|
|
||||||
// ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### SPA Mode
|
|
||||||
|
|
||||||
`svelte.config.js`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
import adapter from '@sveltejs/adapter-static';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
kit: {
|
|
||||||
adapter: adapter({ fallback: 'index.html' }),
|
|
||||||
// SPA: all routes handled client-side
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
The Go backend serves `index.html` for all non-API routes (SPA fallback).
|
|
||||||
In development, Vite dev server proxies `/api` to the Go backend.
|
|
||||||
|
|
||||||
### PWA
|
|
||||||
|
|
||||||
`service-worker.ts` handles:
|
|
||||||
- App shell caching (HTML, CSS, JS, fonts)
|
|
||||||
- User-pinned file caching (explicit, via UI button)
|
|
||||||
- Cache versioning and cleanup on update
|
|
||||||
- Reset function (clear all caches except pinned files)
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
# Tanabata File Manager — Go Project Structure
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Router**: Gin
|
|
||||||
- **Database**: pgx v5 (pgxpool)
|
|
||||||
- **Migrations**: goose v3 + go:embed (auto-migrate on startup)
|
|
||||||
- **Auth**: JWT (golang-jwt/jwt/v5)
|
|
||||||
- **Config**: environment variables via .env (joho/godotenv)
|
|
||||||
- **Logging**: slog (stdlib, Go 1.21+)
|
|
||||||
- **Validation**: go-playground/validator/v10
|
|
||||||
- **EXIF**: rwcarlsen/goexif or dsoprea/go-exif
|
|
||||||
- **Image processing**: disintegration/imaging (thumbnails, previews)
|
|
||||||
- **Architecture**: Clean Architecture (domain → service → repository/handler)
|
|
||||||
|
|
||||||
## Monorepo Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
tanabata/
|
|
||||||
├── backend/ ← Go project
|
|
||||||
├── frontend/ ← SvelteKit project
|
|
||||||
├── openapi.yaml ← Shared API contract
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Dockerfile
|
|
||||||
├── .env.example
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backend Directory Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
├── cmd/
|
|
||||||
│ └── server/
|
|
||||||
│ └── main.go # Entrypoint: config → DB → migrate → wire → run
|
|
||||||
│
|
|
||||||
├── internal/
|
|
||||||
│ │
|
|
||||||
│ ├── domain/ # Pure business entities & value objects
|
|
||||||
│ │ ├── file.go # File, FileFilter, FilePage
|
|
||||||
│ │ ├── tag.go # Tag, TagRule
|
|
||||||
│ │ ├── category.go # Category
|
|
||||||
│ │ ├── pool.go # Pool, PoolFile
|
|
||||||
│ │ ├── user.go # User, Session
|
|
||||||
│ │ ├── acl.go # Permission, ObjectType
|
|
||||||
│ │ ├── audit.go # AuditEntry, ActionType
|
|
||||||
│ │ └── errors.go # Domain error types (ErrNotFound, ErrForbidden, etc.)
|
|
||||||
│ │
|
|
||||||
│ ├── port/ # Interfaces (ports) — contracts between layers
|
|
||||||
│ │ ├── repository.go # FileRepo, TagRepo, CategoryRepo, PoolRepo,
|
|
||||||
│ │ │ # UserRepo, SessionRepo, ACLRepo, AuditRepo,
|
|
||||||
│ │ │ # MimeRepo, TagRuleRepo
|
|
||||||
│ │ └── storage.go # FileStorage interface (disk operations)
|
|
||||||
│ │
|
|
||||||
│ ├── service/ # Business logic (use cases)
|
|
||||||
│ │ ├── file_service.go # Upload, update, delete, trash/restore, replace,
|
|
||||||
│ │ │ # import, filter/list, duplicate detection
|
|
||||||
│ │ ├── tag_service.go # CRUD + auto-tag application logic
|
|
||||||
│ │ ├── category_service.go # CRUD (thin, delegates to repo + ACL + audit)
|
|
||||||
│ │ ├── pool_service.go # CRUD + file ordering, add/remove files
|
|
||||||
│ │ ├── auth_service.go # Login, logout, JWT issue/refresh, session management
|
|
||||||
│ │ ├── acl_service.go # Permission checks, grant/revoke
|
|
||||||
│ │ ├── audit_service.go # Log actions, query audit log
|
|
||||||
│ │ └── user_service.go # Profile update, admin CRUD, block/unblock
|
|
||||||
│ │
|
|
||||||
│ ├── handler/ # HTTP layer (Gin handlers)
|
|
||||||
│ │ ├── router.go # Route registration, middleware wiring
|
|
||||||
│ │ ├── middleware.go # Auth middleware (JWT extraction → context)
|
|
||||||
│ │ ├── request.go # Common request parsing helpers
|
|
||||||
│ │ ├── response.go # Error/success response builders
|
|
||||||
│ │ ├── file_handler.go # /files endpoints
|
|
||||||
│ │ ├── tag_handler.go # /tags endpoints
|
|
||||||
│ │ ├── category_handler.go # /categories endpoints
|
|
||||||
│ │ ├── pool_handler.go # /pools endpoints
|
|
||||||
│ │ ├── auth_handler.go # /auth endpoints
|
|
||||||
│ │ ├── acl_handler.go # /acl endpoints
|
|
||||||
│ │ ├── user_handler.go # /users endpoints
|
|
||||||
│ │ └── audit_handler.go # /audit endpoints
|
|
||||||
│ │
|
|
||||||
│ ├── db/ # Database adapters
|
|
||||||
│ │ ├── db.go # Common helpers: pagination, repo factory, transactor base
|
|
||||||
│ │ └── postgres/ # PostgreSQL implementation
|
|
||||||
│ │ ├── postgres.go # pgxpool init, tx-from-context helpers
|
|
||||||
│ │ ├── file_repo.go # FileRepo implementation
|
|
||||||
│ │ ├── tag_repo.go # TagRepo + TagRuleRepo implementation
|
|
||||||
│ │ ├── category_repo.go # CategoryRepo implementation
|
|
||||||
│ │ ├── pool_repo.go # PoolRepo implementation
|
|
||||||
│ │ ├── user_repo.go # UserRepo implementation
|
|
||||||
│ │ ├── session_repo.go # SessionRepo implementation
|
|
||||||
│ │ ├── acl_repo.go # ACLRepo implementation
|
|
||||||
│ │ ├── audit_repo.go # AuditRepo implementation
|
|
||||||
│ │ ├── mime_repo.go # MimeRepo implementation
|
|
||||||
│ │ └── filter_parser.go # DSL → SQL WHERE clause builder
|
|
||||||
│ │
|
|
||||||
│ ├── storage/ # File storage adapter
|
|
||||||
│ │ └── disk.go # FileStorage implementation (read/write/delete on disk)
|
|
||||||
│ │
|
|
||||||
│ └── config/ # Configuration
|
|
||||||
│ └── config.go # Struct + loader from env vars
|
|
||||||
│
|
|
||||||
├── migrations/ # SQL migration files (goose format)
|
|
||||||
│ ├── 001_init_schemas.sql
|
|
||||||
│ ├── 002_core_tables.sql
|
|
||||||
│ ├── 003_data_tables.sql
|
|
||||||
│ ├── 004_acl_tables.sql
|
|
||||||
│ ├── 005_activity_tables.sql
|
|
||||||
│ ├── 006_indexes.sql
|
|
||||||
│ └── 007_seed_data.sql
|
|
||||||
│
|
|
||||||
├── go.mod
|
|
||||||
└── go.sum
|
|
||||||
```
|
|
||||||
|
|
||||||
## Layer Dependency Rules
|
|
||||||
|
|
||||||
```
|
|
||||||
handler → service → port (interfaces) ← db/postgres / storage
|
|
||||||
↓
|
|
||||||
domain (entities, value objects, errors)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **domain/**: zero imports from other internal packages. Only stdlib.
|
|
||||||
- **port/**: imports only domain/. Defines interfaces.
|
|
||||||
- **service/**: imports domain/ and port/. Never imports db/ or handler/.
|
|
||||||
- **handler/**: imports domain/ and service/. Never imports db/.
|
|
||||||
- **db/postgres/**: imports domain/, port/, and db/ (common helpers). Implements port interfaces.
|
|
||||||
- **db/**: imports domain/ and port/. Shared utilities for all DB adapters.
|
|
||||||
- **storage/**: imports domain/ and port/. Implements FileStorage.
|
|
||||||
|
|
||||||
No layer may import a layer above it. No circular dependencies.
|
|
||||||
|
|
||||||
## Key Design Decisions
|
|
||||||
|
|
||||||
### Dependency Injection (Wiring)
|
|
||||||
|
|
||||||
Manual wiring in `cmd/server/main.go`. No DI frameworks.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Pseudocode
|
|
||||||
pool := postgres.NewPool(cfg.DatabaseURL)
|
|
||||||
goose.Up(pool, migrations)
|
|
||||||
|
|
||||||
// Repos (all from internal/db/postgres/)
|
|
||||||
fileRepo := postgres.NewFileRepo(pool)
|
|
||||||
tagRepo := postgres.NewTagRepo(pool)
|
|
||||||
// ...
|
|
||||||
|
|
||||||
// Storage
|
|
||||||
diskStore := storage.NewDiskStorage(cfg.FilesPath)
|
|
||||||
|
|
||||||
// Services
|
|
||||||
aclSvc := service.NewACLService(aclRepo, objectTypeRepo)
|
|
||||||
auditSvc := service.NewAuditService(auditRepo, actionTypeRepo)
|
|
||||||
fileSvc := service.NewFileService(fileRepo, mimeRepo, tagRepo, diskStore, aclSvc, auditSvc)
|
|
||||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc)
|
|
||||||
// ...
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
|
||||||
// ...
|
|
||||||
|
|
||||||
router := handler.NewRouter(cfg, fileHandler, tagHandler, ...)
|
|
||||||
router.Run(cfg.ListenAddr)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Context Propagation
|
|
||||||
|
|
||||||
Every service method receives `context.Context` as the first argument.
|
|
||||||
The handler extracts user info from JWT (via middleware) and puts it
|
|
||||||
into context. Services read the current user from context for ACL checks
|
|
||||||
and audit logging.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// middleware.go
|
|
||||||
func (m *AuthMiddleware) Handle(c *gin.Context) {
|
|
||||||
claims := parseJWT(c.GetHeader("Authorization"))
|
|
||||||
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin)
|
|
||||||
c.Request = c.Request.WithContext(ctx)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// domain/context.go
|
|
||||||
type ctxKey int
|
|
||||||
const userKey ctxKey = iota
|
|
||||||
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context { ... }
|
|
||||||
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Transaction Management
|
|
||||||
|
|
||||||
Repository interfaces include a `Transactor`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// port/repository.go
|
|
||||||
type Transactor interface {
|
|
||||||
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The postgres implementation wraps `pgxpool.Pool.BeginTx`. Inside `fn`,
|
|
||||||
all repo calls use the transaction from context. This allows services
|
|
||||||
to compose multiple repo calls in a single transaction:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// service/file_service.go
|
|
||||||
func (s *FileService) Upload(ctx context.Context, input UploadInput) (*domain.File, error) {
|
|
||||||
return s.tx.WithTx(ctx, func(ctx context.Context) error {
|
|
||||||
file, err := s.fileRepo.Create(ctx, ...) // uses tx
|
|
||||||
if err != nil { return err }
|
|
||||||
for _, tagID := range input.TagIDs {
|
|
||||||
s.tagRepo.AddFileTag(ctx, file.ID, tagID) // same tx
|
|
||||||
}
|
|
||||||
s.auditRepo.Log(ctx, ...) // same tx
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ACL Check Pattern
|
|
||||||
|
|
||||||
ACL logic is centralized in `ACLService`. Other services call it before
|
|
||||||
any data mutation or retrieval:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// service/acl_service.go
|
|
||||||
func (s *ACLService) CanView(ctx context.Context, objectType string, objectID uuid.UUID) error {
|
|
||||||
userID, isAdmin := domain.UserFromContext(ctx)
|
|
||||||
if isAdmin { return nil }
|
|
||||||
// Check is_public on the object
|
|
||||||
// If not public, check creator_id == userID
|
|
||||||
// If not creator, check acl.permissions
|
|
||||||
// Return domain.ErrForbidden if none match
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Mapping
|
|
||||||
|
|
||||||
Domain errors → HTTP status codes (handled in handler/response.go):
|
|
||||||
|
|
||||||
| Domain Error | HTTP Status | Error Code |
|
|
||||||
|-----------------------|-------------|-------------------|
|
|
||||||
| ErrNotFound | 404 | not_found |
|
|
||||||
| ErrForbidden | 403 | forbidden |
|
|
||||||
| ErrUnauthorized | 401 | unauthorized |
|
|
||||||
| ErrConflict | 409 | conflict |
|
|
||||||
| ErrValidation | 400 | validation_error |
|
|
||||||
| ErrUnsupportedMIME | 415 | unsupported_mime |
|
|
||||||
| (unexpected) | 500 | internal_error |
|
|
||||||
|
|
||||||
### Filter DSL
|
|
||||||
|
|
||||||
The DSL parser lives in `db/postgres/filter_parser.go` because it produces
|
|
||||||
SQL WHERE clauses — it is a PostgreSQL-specific adapter concern.
|
|
||||||
The service layer passes the raw DSL string to the repository; the
|
|
||||||
repository parses it and builds the query.
|
|
||||||
|
|
||||||
For a different DBMS, a corresponding parser would live in
|
|
||||||
`db/<dbms>/filter_parser.go`.
|
|
||||||
|
|
||||||
The interface:
|
|
||||||
```go
|
|
||||||
// port/repository.go
|
|
||||||
type FileRepo interface {
|
|
||||||
List(ctx context.Context, params FileListParams) (*domain.FilePage, error)
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// domain/file.go
|
|
||||||
type FileListParams struct {
|
|
||||||
Filter string // raw DSL string
|
|
||||||
Sort string
|
|
||||||
Order string
|
|
||||||
Cursor string
|
|
||||||
Anchor *uuid.UUID
|
|
||||||
Direction string // "forward" or "backward"
|
|
||||||
Limit int
|
|
||||||
Trash bool
|
|
||||||
Search string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### JWT Structure
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Claims struct {
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
UserID int16 `json:"uid"`
|
|
||||||
IsAdmin bool `json:"adm"`
|
|
||||||
SessionID int `json:"sid"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Access token: short-lived (15 min). Refresh token: long-lived (30 days),
|
|
||||||
stored as hash in `activity.sessions.token_hash`.
|
|
||||||
|
|
||||||
### Configuration (.env)
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Server
|
|
||||||
LISTEN_ADDR=:8080
|
|
||||||
JWT_SECRET=<random-32-bytes>
|
|
||||||
JWT_ACCESS_TTL=15m
|
|
||||||
JWT_REFRESH_TTL=720h
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL=postgres://user:pass@host:5432/tanabata?sslmode=disable
|
|
||||||
|
|
||||||
# Storage
|
|
||||||
FILES_PATH=/data/files
|
|
||||||
THUMBS_CACHE_PATH=/data/thumbs
|
|
||||||
|
|
||||||
# Thumbnails
|
|
||||||
THUMB_WIDTH=160
|
|
||||||
THUMB_HEIGHT=160
|
|
||||||
PREVIEW_WIDTH=1920
|
|
||||||
PREVIEW_HEIGHT=1080
|
|
||||||
|
|
||||||
# Import
|
|
||||||
IMPORT_PATH=/data/import
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
@startuml Tanabata File Manager entity relationship diagram
|
||||||
|
|
||||||
|
' skinparam linetype ortho
|
||||||
|
|
||||||
|
|
||||||
|
' ========== SYSTEM ==========
|
||||||
|
|
||||||
|
entity "system.users" as usr {
|
||||||
|
* id : smallserial <<generated>>
|
||||||
|
--
|
||||||
|
* name : varchar(32)
|
||||||
|
* password : text
|
||||||
|
* is_admin : boolean
|
||||||
|
* can_create : boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "system.mime" as mime {
|
||||||
|
* id : smallserial <<generated>>
|
||||||
|
--
|
||||||
|
* name : varchar(127)
|
||||||
|
* extension : varchar(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
' ========== DATA ==========
|
||||||
|
|
||||||
|
entity "data.categories" as cty {
|
||||||
|
* id : uuid <<generated>>
|
||||||
|
--
|
||||||
|
* name : varchar(256)
|
||||||
|
notes : text
|
||||||
|
color : char(6)
|
||||||
|
' * created_at : timestamptz <<generated>>
|
||||||
|
* creator_id : smallint
|
||||||
|
' * is_private : boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
cty::creator_id }o--|| usr::id
|
||||||
|
|
||||||
|
entity "data.files" as fle {
|
||||||
|
* id : uuid <<generated>>
|
||||||
|
--
|
||||||
|
name : varchar(256)
|
||||||
|
* mime_id : smallint
|
||||||
|
* datetime : timestamptz
|
||||||
|
notes : text
|
||||||
|
* metadata : jsonb
|
||||||
|
' * created_at : timestamptz <<generated>>
|
||||||
|
* creator_id : smallint
|
||||||
|
' * is_private : boolean
|
||||||
|
* is_deleted : boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
fle::mime_id }o--|| mime::id
|
||||||
|
fle::creator_id }o--|| usr::id
|
||||||
|
|
||||||
|
entity "data.tags" as tag {
|
||||||
|
* id : uuid <<generated>>
|
||||||
|
--
|
||||||
|
* name : varchar(256)
|
||||||
|
notes : text
|
||||||
|
color : char(6)
|
||||||
|
category_id : uuid
|
||||||
|
' * created_at : timestamptz <<generated>>
|
||||||
|
* creator_id : smallint
|
||||||
|
' * is_private : boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
tag::category_id }o--o| cty::id
|
||||||
|
tag::creator_id }o--|| usr::id
|
||||||
|
|
||||||
|
entity "data.file_tag" as ft {
|
||||||
|
* file_id : uuid
|
||||||
|
* tag_id : uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
ft::file_id }o--|| fle::id
|
||||||
|
ft::tag_id }o--|| tag::id
|
||||||
|
|
||||||
|
entity "data.autotags" as atg {
|
||||||
|
* trigger_tag_id : uuid
|
||||||
|
* add_tag_id : uuid
|
||||||
|
--
|
||||||
|
* is_active : boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
atg::trigger_tag_id }o--|| tag::id
|
||||||
|
atg::add_tag_id }o--|| tag::id
|
||||||
|
|
||||||
|
entity "data.pools" as pool {
|
||||||
|
* id : uuid <<generated>>
|
||||||
|
--
|
||||||
|
* name : varchar(256)
|
||||||
|
notes : text
|
||||||
|
' parent_id : uuid
|
||||||
|
' * created_at : timestamptz
|
||||||
|
* creator_id : smallint
|
||||||
|
' * is_private : boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
pool::creator_id }o--|| usr::id
|
||||||
|
' pool::parent_id }o--o| pool::id
|
||||||
|
|
||||||
|
entity "data.file_pool" as fp {
|
||||||
|
* file_id : uuid
|
||||||
|
* pool_id : uuid
|
||||||
|
* number : smallint
|
||||||
|
}
|
||||||
|
|
||||||
|
fp::file_id }o--|| fle::id
|
||||||
|
fp::pool_id }o--|| pool::id
|
||||||
|
|
||||||
|
|
||||||
|
' ========== ACL ==========
|
||||||
|
|
||||||
|
entity "acl.files" as acl_f {
|
||||||
|
* user_id : smallint
|
||||||
|
* file_id : uuid
|
||||||
|
--
|
||||||
|
* view : boolean
|
||||||
|
* edit : boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
acl_f::user_id }o--|| usr::id
|
||||||
|
acl_f::file_id }o--|| fle::id
|
||||||
|
|
||||||
|
entity "acl.tags" as acl_t {
|
||||||
|
* user_id : smallint
|
||||||
|
* tag_id : uuid
|
||||||
|
--
|
||||||
|
* view : boolean
|
||||||
|
* edit : boolean
|
||||||
|
' * files_view : boolean
|
||||||
|
' * files_edit : boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
acl_t::user_id }o--|| usr::id
|
||||||
|
acl_t::tag_id }o--|| tag::id
|
||||||
|
|
||||||
|
entity "acl.categories" as acl_c {
|
||||||
|
* user_id : smallint
|
||||||
|
* category_id : uuid
|
||||||
|
--
|
||||||
|
* view : boolean
|
||||||
|
* edit : boolean
|
||||||
|
' * tags_view : boolean
|
||||||
|
' * tags_edit : boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
acl_c::user_id }o--|| usr::id
|
||||||
|
acl_c::category_id }o--|| cty::id
|
||||||
|
|
||||||
|
entity "acl.pools" as acl_p {
|
||||||
|
* user_id : smallint
|
||||||
|
* pool_id : uuid
|
||||||
|
--
|
||||||
|
* view : boolean
|
||||||
|
* edit : boolean
|
||||||
|
' * files_view : boolean
|
||||||
|
' * files_edit : boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
acl_p::user_id }o--|| usr::id
|
||||||
|
acl_p::pool_id }o--|| pool::id
|
||||||
|
|
||||||
|
|
||||||
|
' ========== ACTIVITY ==========
|
||||||
|
|
||||||
|
entity "activity.sessions" as ssn {
|
||||||
|
* id : serial <<generated>>
|
||||||
|
--
|
||||||
|
* token : text
|
||||||
|
* user_id : smallint
|
||||||
|
* user_agent : varchar(512)
|
||||||
|
* started_at : timestamptz
|
||||||
|
expires_at : timestamptz
|
||||||
|
* last_activity : timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
ssn::user_id }o--|| usr::id
|
||||||
|
|
||||||
|
entity "activity.file_views" as fv {
|
||||||
|
* file_id : uuid
|
||||||
|
* timestamp : timestamptz
|
||||||
|
* user_id : smallint
|
||||||
|
}
|
||||||
|
|
||||||
|
fv::file_id }o--|| fle::id
|
||||||
|
fv::user_id }o--|| usr::id
|
||||||
|
|
||||||
|
entity "activity.tag_uses" as tu {
|
||||||
|
* tag_id : uuid
|
||||||
|
* timestamp : timestamptz
|
||||||
|
* user_id : smallint
|
||||||
|
--
|
||||||
|
* included : boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
tu::tag_id }o--|| tag::id
|
||||||
|
tu::user_id }o--|| usr::id
|
||||||
|
|
||||||
|
entity "activity.pool_views" as pv {
|
||||||
|
* pool_id : uuid
|
||||||
|
* timestamp : timestamptz
|
||||||
|
* user_id : smallint
|
||||||
|
}
|
||||||
|
|
||||||
|
pv::pool_id }o--|| pool::id
|
||||||
|
pv::user_id }o--|| usr::id
|
||||||
|
|
||||||
|
|
||||||
|
@enduml
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
from configparser import ConfigParser
|
|
||||||
from psycopg2.pool import ThreadedConnectionPool
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from os import access, W_OK, makedirs, chmod, system
|
|
||||||
from os.path import isfile, join, basename
|
|
||||||
from shutil import move
|
|
||||||
from magic import Magic
|
|
||||||
from preview_generator.manager import PreviewManager
|
|
||||||
|
|
||||||
conf = None
|
|
||||||
|
|
||||||
mage = None
|
|
||||||
previewer = None
|
|
||||||
|
|
||||||
db_pool = None
|
|
||||||
|
|
||||||
DEFAULT_SORTING = {
|
|
||||||
"files": {
|
|
||||||
"key": "created",
|
|
||||||
"asc": False
|
|
||||||
},
|
|
||||||
"tags": {
|
|
||||||
"key": "created",
|
|
||||||
"asc": False
|
|
||||||
},
|
|
||||||
"categories": {
|
|
||||||
"key": "created",
|
|
||||||
"asc": False
|
|
||||||
},
|
|
||||||
"pools": {
|
|
||||||
"key": "created",
|
|
||||||
"asc": False
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def Initialize(conf_path="/etc/tfm/tfm.conf"):
|
|
||||||
global mage, previewer
|
|
||||||
load_config(conf_path)
|
|
||||||
mage = Magic(mime=True)
|
|
||||||
previewer = PreviewManager(conf["Paths"]["Thumbs"])
|
|
||||||
db_connect(conf["DB.limits"]["MinimumConnections"], conf["DB.limits"]["MaximumConnections"], **conf["DB.params"])
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(path):
|
|
||||||
global conf
|
|
||||||
conf = ConfigParser()
|
|
||||||
conf.read(path)
|
|
||||||
|
|
||||||
|
|
||||||
def db_connect(minconn, maxconn, **kwargs):
|
|
||||||
global db_pool
|
|
||||||
db_pool = ThreadedConnectionPool(minconn, maxconn, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _db_cursor():
|
|
||||||
global db_pool
|
|
||||||
try:
|
|
||||||
conn = db_pool.getconn()
|
|
||||||
except:
|
|
||||||
raise RuntimeError("Database not connected")
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
yield cur
|
|
||||||
conn.commit()
|
|
||||||
except:
|
|
||||||
conn.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
db_pool.putconn(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_column_name(cur, table, column):
|
|
||||||
cur.execute("SELECT get_column_names(%s) AS name", (table,))
|
|
||||||
if all([column!=col["name"] for col in cur.fetchall()]):
|
|
||||||
raise RuntimeError("Invalid column name")
|
|
||||||
|
|
||||||
|
|
||||||
def authorize(username, password, useragent):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT tfm_session_request(tfm_user_auth(%s, %s), %s) AS sid", (username, password, useragent))
|
|
||||||
sid = cur.fetchone()["sid"]
|
|
||||||
return TSession(sid)
|
|
||||||
|
|
||||||
|
|
||||||
class TSession:
|
|
||||||
sid = None
|
|
||||||
|
|
||||||
def __init__(self, sid):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT tfm_session_validate(%s) IS NOT NULL AS valid", (sid,))
|
|
||||||
if not cur.fetchone()["valid"]:
|
|
||||||
raise RuntimeError("Invalid sid")
|
|
||||||
self.sid = sid
|
|
||||||
|
|
||||||
def terminate(self):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_session_terminate(%s)", (self.sid,))
|
|
||||||
del self
|
|
||||||
|
|
||||||
@property
|
|
||||||
def username(self):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT tfm_session_username(%s) AS name", (self.sid,))
|
|
||||||
return cur.fetchone()["name"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_admin(self):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT * FROM tfm_user_get_info(%s)", (self.sid,))
|
|
||||||
return cur.fetchone()["can_edit"]
|
|
||||||
|
|
||||||
def get_files(self, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
_validate_column_name(cur, "v_files", order_key)
|
|
||||||
cur.execute("SELECT * FROM tfm_get_files(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
|
||||||
order_key,
|
|
||||||
"ASC" if order_asc else "DESC",
|
|
||||||
int(offset),
|
|
||||||
int(limit) if limit is not None else "ALL"
|
|
||||||
), (self.sid,))
|
|
||||||
return list(map(dict, cur.fetchall()))
|
|
||||||
|
|
||||||
def get_files_by_filter(self, philter=None, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
_validate_column_name(cur, "v_files", order_key)
|
|
||||||
cur.execute("SELECT * FROM tfm_get_files_by_filter(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
|
||||||
order_key,
|
|
||||||
"ASC" if order_asc else "DESC",
|
|
||||||
int(offset),
|
|
||||||
int(limit) if limit is not None else "ALL"
|
|
||||||
), (self.sid, philter))
|
|
||||||
return list(map(dict, cur.fetchall()))
|
|
||||||
|
|
||||||
def get_tags(self, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
_validate_column_name(cur, "v_tags", order_key)
|
|
||||||
cur.execute("SELECT * FROM tfm_get_tags(%%s) ORDER BY %s %s, name ASC OFFSET %s LIMIT %s" % (
|
|
||||||
order_key,
|
|
||||||
"ASC" if order_asc else "DESC",
|
|
||||||
int(offset),
|
|
||||||
int(limit) if limit is not None else "ALL"
|
|
||||||
), (self.sid,))
|
|
||||||
return list(map(dict, cur.fetchall()))
|
|
||||||
|
|
||||||
def get_categories(self, order_key=DEFAULT_SORTING["categories"]["key"], order_asc=DEFAULT_SORTING["categories"]["asc"], offset=0, limit=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
_validate_column_name(cur, "v_categories", order_key)
|
|
||||||
cur.execute("SELECT * FROM tfm_get_categories(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
|
||||||
order_key,
|
|
||||||
"ASC" if order_asc else "DESC",
|
|
||||||
int(offset),
|
|
||||||
int(limit) if limit is not None else "ALL"
|
|
||||||
), (self.sid,))
|
|
||||||
return list(map(dict, cur.fetchall()))
|
|
||||||
|
|
||||||
def get_pools(self, order_key=DEFAULT_SORTING["pools"]["key"], order_asc=DEFAULT_SORTING["pools"]["asc"], offset=0, limit=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
_validate_column_name(cur, "v_pools", order_key)
|
|
||||||
cur.execute("SELECT * FROM tfm_get_pools(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
|
||||||
order_key,
|
|
||||||
"ASC" if order_asc else "DESC",
|
|
||||||
int(offset),
|
|
||||||
int(limit) if limit is not None else "ALL"
|
|
||||||
), (self.sid,))
|
|
||||||
return list(map(dict, cur.fetchall()))
|
|
||||||
|
|
||||||
def get_autotags(self, order_key="child_id", order_asc=True, offset=0, limit=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
_validate_column_name(cur, "v_autotags", order_key)
|
|
||||||
cur.execute("SELECT * FROM tfm_get_autotags(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
|
||||||
order_key,
|
|
||||||
"ASC" if order_asc else "DESC",
|
|
||||||
int(offset),
|
|
||||||
int(limit) if limit is not None else "ALL"
|
|
||||||
), (self.sid,))
|
|
||||||
return list(map(dict, cur.fetchall()))
|
|
||||||
|
|
||||||
def get_my_sessions(self, order_key="started", order_asc=False, offset=0, limit=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
_validate_column_name(cur, "v_sessions", order_key)
|
|
||||||
cur.execute("SELECT * FROM tfm_get_my_sessions(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
|
||||||
order_key,
|
|
||||||
"ASC" if order_asc else "DESC",
|
|
||||||
int(offset),
|
|
||||||
int(limit) if limit is not None else "ALL"
|
|
||||||
), (self.sid,))
|
|
||||||
return list(map(dict, cur.fetchall()))
|
|
||||||
|
|
||||||
def get_tags_by_file(self, file_id, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
_validate_column_name(cur, "v_tags", order_key)
|
|
||||||
cur.execute("SELECT * FROM tfm_get_tags_by_file(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
|
||||||
order_key,
|
|
||||||
"ASC" if order_asc else "DESC",
|
|
||||||
int(offset),
|
|
||||||
int(limit) if limit is not None else "ALL"
|
|
||||||
), (self.sid, file_id))
|
|
||||||
return list(map(dict, cur.fetchall()))
|
|
||||||
|
|
||||||
def get_files_by_tag(self, tag_id, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
_validate_column_name(cur, "v_files", order_key)
|
|
||||||
cur.execute("SELECT * FROM tfm_get_files_by_tag(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
|
||||||
order_key,
|
|
||||||
"ASC" if order_asc else "DESC",
|
|
||||||
int(offset),
|
|
||||||
int(limit) if limit is not None else "ALL"
|
|
||||||
), (self.sid, tag_id))
|
|
||||||
return list(map(dict, cur.fetchall()))
|
|
||||||
|
|
||||||
def get_files_by_pool(self, pool_id, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
_validate_column_name(cur, "v_files", order_key)
|
|
||||||
cur.execute("SELECT * FROM tfm_get_files_by_pool(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
|
||||||
order_key,
|
|
||||||
"ASC" if order_asc else "DESC",
|
|
||||||
int(offset),
|
|
||||||
int(limit) if limit is not None else "ALL"
|
|
||||||
), (self.sid, pool_id))
|
|
||||||
return list(map(dict, cur.fetchall()))
|
|
||||||
|
|
||||||
def get_parent_tags(self, tag_id, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
_validate_column_name(cur, "v_tags", order_key)
|
|
||||||
cur.execute("SELECT * FROM tfm_get_parent_tags(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
|
||||||
order_key,
|
|
||||||
"ASC" if order_asc else "DESC",
|
|
||||||
int(offset),
|
|
||||||
int(limit) if limit is not None else "ALL"
|
|
||||||
), (self.sid, tag_id))
|
|
||||||
return list(map(dict, cur.fetchall()))
|
|
||||||
|
|
||||||
def get_my_file_views(self, file_id=None, order_key="datetime", order_asc=False, offset=0, limit=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
_validate_column_name(cur, "v_files", order_key)
|
|
||||||
cur.execute("SELECT * FROM tfm_get_my_file_views(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
|
||||||
order_key,
|
|
||||||
"ASC" if order_asc else "DESC",
|
|
||||||
int(offset),
|
|
||||||
int(limit) if limit is not None else "ALL"
|
|
||||||
), (self.sid, file_id))
|
|
||||||
return list(map(dict, cur.fetchall()))
|
|
||||||
|
|
||||||
def get_file(self, file_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT * FROM tfm_get_files(%s) WHERE id=%s", (self.sid, file_id))
|
|
||||||
return cur.fetchone()
|
|
||||||
|
|
||||||
def get_tag(self, tag_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT * FROM tfm_get_tags(%s) WHERE id=%s", (self.sid, tag_id))
|
|
||||||
return cur.fetchone()
|
|
||||||
|
|
||||||
def get_category(self, category_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT * FROM tfm_get_categories(%s) WHERE id=%s", (self.sid, category_id))
|
|
||||||
return cur.fetchone()
|
|
||||||
|
|
||||||
def view_file(self, file_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_view_file(%s, %s)", (self.sid, file_id))
|
|
||||||
|
|
||||||
def add_file(self, path, datetime=None, notes=None, is_private=None, orig_name=True):
|
|
||||||
if not isfile(path):
|
|
||||||
raise FileNotFoundError("No such file '%s'" % path)
|
|
||||||
if not access(conf["Paths"]["Files"], W_OK) or not access(conf["Paths"]["Thumbs"], W_OK):
|
|
||||||
raise PermissionError("Invalid directories for files and thumbs")
|
|
||||||
mime = mage.from_file(path)
|
|
||||||
if orig_name == True:
|
|
||||||
orig_name = basename(path)
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT * FROM tfm_add_file(%s, %s, %s, %s, %s, %s)", (self.sid, mime, datetime, notes, is_private, orig_name))
|
|
||||||
res = cur.fetchone()
|
|
||||||
file_id = res["f_id"]
|
|
||||||
ext = res["ext"]
|
|
||||||
file_path = join(conf["Paths"]["Files"], file_id)
|
|
||||||
move(path, file_path)
|
|
||||||
thumb_path = previewer.get_jpeg_preview(file_path, height=160, width=160)
|
|
||||||
preview_path = previewer.get_jpeg_preview(file_path, height=1080, width=1920)
|
|
||||||
chmod(file_path, 0o664)
|
|
||||||
chmod(thumb_path, 0o664)
|
|
||||||
chmod(preview_path, 0o664)
|
|
||||||
return file_id, ext
|
|
||||||
|
|
||||||
def add_tag(self, name, notes=None, color=None, category_id=None, is_private=None):
|
|
||||||
if color is not None:
|
|
||||||
color = color.replace('#', '')
|
|
||||||
if not category_id:
|
|
||||||
category_id = None
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT tfm_add_tag(%s, %s, %s, %s, %s, %s) AS id", (self.sid, name, notes, color, category_id, is_private))
|
|
||||||
return cur.fetchone()["id"]
|
|
||||||
|
|
||||||
def add_category(self, name, notes=None, color=None, is_private=None):
|
|
||||||
if color is not None:
|
|
||||||
color = color.replace('#', '')
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT tfm_add_category(%s, %s, %s, %s, %s) AS id", (self.sid, name, notes, color, is_private))
|
|
||||||
return cur.fetchone()["id"]
|
|
||||||
|
|
||||||
def add_pool(self, name, notes=None, parent_id=None, is_private=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT tfm_add_pool(%s, %s, %s, %s, %s) AS id", (self.sid, name, notes, parent_id, is_private))
|
|
||||||
return cur.fetchone()["id"]
|
|
||||||
|
|
||||||
def add_autotag(self, child_id, parent_id, is_active=None, apply_to_existing=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT tfm_add_autotag(%s, %s, %s, %s, %s) AS added", (self.sid, child_id, parent_id, is_active, apply_to_existing))
|
|
||||||
return cur.fetchone()["added"]
|
|
||||||
|
|
||||||
def add_file_to_tag(self, file_id, tag_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT tfm_add_file_to_tag(%s, %s, %s) AS id", (self.sid, file_id, tag_id))
|
|
||||||
return list(map(lambda t: t["id"], cur.fetchall()))
|
|
||||||
|
|
||||||
def add_file_to_pool(self, file_id, pool_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("SELECT tfm_add_file_to_pool(%s, %s, %s) AS added", (self.sid, file_id, pool_id))
|
|
||||||
return cur.fetchone()["added"]
|
|
||||||
|
|
||||||
def edit_file(self, file_id, mime=None, datetime=None, notes=None, is_private=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_edit_file(%s, %s, %s, %s, %s, %s)", (self.sid, file_id, mime, datetime, notes, is_private))
|
|
||||||
|
|
||||||
def edit_tag(self, tag_id, name=None, notes=None, color=None, category_id=None, is_private=None):
|
|
||||||
if color is not None:
|
|
||||||
color = color.replace('#', '')
|
|
||||||
if not category_id:
|
|
||||||
category_id = None
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_edit_tag(%s, %s, %s, %s, %s, %s, %s)", (self.sid, tag_id, name, notes, color, category_id, is_private))
|
|
||||||
|
|
||||||
def edit_category(self, category_id, name=None, notes=None, color=None, is_private=None):
|
|
||||||
if color is not None:
|
|
||||||
color = color.replace('#', '')
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_edit_category(%s, %s, %s, %s, %s, %s)", (self.sid, category_id, name, notes, color, is_private))
|
|
||||||
|
|
||||||
def edit_pool(self, pool_id, name=None, notes=None, parent_id=None, is_private=None):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_edit_pool(%s, %s, %s, %s, %s, %s)", (self.sid, pool_id, name, notes, parent_id, is_private))
|
|
||||||
|
|
||||||
def remove_file(self, file_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_remove_file(%s, %s)", (self.sid, file_id))
|
|
||||||
if system("rm %s/%s*" % (conf["Paths"]["Files"], file_id)):
|
|
||||||
raise RuntimeError("Failed to remove file '%s'" % file_id)
|
|
||||||
|
|
||||||
def remove_tag(self, tag_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_remove_tag(%s, %s)", (self.sid, tag_id))
|
|
||||||
|
|
||||||
def remove_category(self, category_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_remove_category(%s, %s)", (self.sid, category_id))
|
|
||||||
|
|
||||||
def remove_pool(self, pool_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_remove_pool(%s, %s)", (self.sid, pool_id))
|
|
||||||
|
|
||||||
def remove_autotag(self, child_id, parent_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_remove_autotag(%s, %s, %s)", (self.sid, child_id, parent_id))
|
|
||||||
|
|
||||||
def remove_file_to_tag(self, file_id, tag_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_remove_file_to_tag(%s, %s, %s)", (self.sid, file_id, tag_id))
|
|
||||||
|
|
||||||
def remove_file_to_pool(self, file_id, pool_id):
|
|
||||||
with _db_cursor() as cur:
|
|
||||||
cur.execute("CALL tfm_remove_file_to_pool(%s, %s, %s)", (self.sid, file_id, pool_id))
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"tanabata/internal/storage/postgres"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
postgres.InitDB("postgres://hiko:taikibansei@192.168.0.25/Tanabata_new?application_name=Tanabata%20testing")
|
|
||||||
// test_json := json.RawMessage([]byte("{\"valery\": \"ponosoff\"}"))
|
|
||||||
// data, statusCode, err := db.FileGetSlice(1, "", "+2", -2, 0)
|
|
||||||
// data, statusCode, err := db.FileGet(1, "0197d056-cfb0-76b5-97e0-bd588826393c")
|
|
||||||
// data, statusCode, err := db.FileAdd(1, "ABOBA.png", "image/png", time.Now(), "slkdfjsldkflsdkfj;sldkf", test_json)
|
|
||||||
// statusCode, err := db.FileUpdate(2, "0197d159-bf3a-7617-a3a8-a4a9fc39eca6", map[string]interface{}{
|
|
||||||
// "name": "ponos.png",
|
|
||||||
// })
|
|
||||||
// statusCode, err := db.FileDelete(1, "0197d155-848f-7221-ba4a-4660f257c7d5")
|
|
||||||
// v, e, err := postgres.FileGetAccess(1, "0197d15a-57f9-712c-991e-c512290e774f")
|
|
||||||
// fmt.Printf("V: %s, E: %s\n", v, e)
|
|
||||||
// fmt.Printf("Status: %d\n", statusCode)
|
|
||||||
// fmt.Printf("Error: %s\n", err)
|
|
||||||
// fmt.Printf("%+v\n", data)
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"tanabata/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
db.InitDB("postgres://hiko:taikibansei@192.168.0.25/Tanabata_new?application_name=Tanabata%20testing")
|
|
||||||
// test_json := json.RawMessage([]byte("{\"valery\": \"ponosoff\"}"))
|
|
||||||
// data, statusCode, err := db.FileGetSlice(2, "", "+2", -2, 0)
|
|
||||||
// data, statusCode, err := db.FileGet(1, "0197d056-cfb0-76b5-97e0-bd588826393c")
|
|
||||||
// data, statusCode, err := db.FileAdd(1, "ABOBA.png", "image/png", time.Now(), "slkdfjsldkflsdkfj;sldkf", test_json)
|
|
||||||
// statusCode, err := db.FileUpdate(2, "0197d159-bf3a-7617-a3a8-a4a9fc39eca6", map[string]interface{}{
|
|
||||||
// "name": "ponos.png",
|
|
||||||
// })
|
|
||||||
statusCode, err := db.FileDelete(1, "0197d155-848f-7221-ba4a-4660f257c7d5")
|
|
||||||
fmt.Printf("Status: %d\n", statusCode)
|
|
||||||
fmt.Printf("Error: %s\n", err)
|
|
||||||
// fmt.Printf("%+v\n", data)
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
var connPool *pgxpool.Pool
|
|
||||||
|
|
||||||
func InitDB(connString string) error {
|
|
||||||
poolConfig, err := pgxpool.ParseConfig(connString)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error while parsing connection string: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
poolConfig.MaxConns = 100
|
|
||||||
poolConfig.MinConns = 0
|
|
||||||
poolConfig.MaxConnLifetime = time.Hour
|
|
||||||
poolConfig.HealthCheckPeriod = 30 * time.Second
|
|
||||||
|
|
||||||
connPool, err = pgxpool.NewWithConfig(context.Background(), poolConfig)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error while initializing DB connections pool: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func transaction(handler func(context.Context, pgx.Tx) (statusCode int, err error)) (statusCode int, err error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
tx, err := connPool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusCode, err = handler(ctx, tx)
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback(ctx)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = tx.Commit(ctx)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle database error
|
|
||||||
func handleDBError(errIn error) (statusCode int, err error) {
|
|
||||||
if errIn == nil {
|
|
||||||
statusCode = http.StatusOK
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if errors.Is(errIn, pgx.ErrNoRows) {
|
|
||||||
err = fmt.Errorf("not found")
|
|
||||||
statusCode = http.StatusNotFound
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(errIn, &pgErr) {
|
|
||||||
switch pgErr.Code {
|
|
||||||
case "22P02", "22007": // Invalid data format
|
|
||||||
err = fmt.Errorf("%s", pgErr.Message)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
case "23505": // Unique constraint violation
|
|
||||||
err = fmt.Errorf("already exists")
|
|
||||||
statusCode = http.StatusConflict
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return http.StatusInternalServerError, errIn
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Convert "filter" URL param to SQL "WHERE" condition
|
|
||||||
func filterToSQL(filter string) (sql string, statusCode int, err error) {
|
|
||||||
// filterTokens := strings.Split(string(filter), ";")
|
|
||||||
sql = "(true)"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert "sort" URL param to SQL "ORDER BY"
|
|
||||||
func sortToSQL(sort string) (sql string, statusCode int, err error) {
|
|
||||||
if sort == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sortOptions := strings.Split(sort, ",")
|
|
||||||
sql = " ORDER BY "
|
|
||||||
for i, sortOption := range sortOptions {
|
|
||||||
sortOrder := sortOption[:1]
|
|
||||||
sortColumn := sortOption[1:]
|
|
||||||
// parse sorting order marker
|
|
||||||
switch sortOrder {
|
|
||||||
case "+":
|
|
||||||
sortOrder = "ASC"
|
|
||||||
case "-":
|
|
||||||
sortOrder = "DESC"
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("invalid sorting order mark: %q", sortOrder)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// validate sorting column
|
|
||||||
var n int
|
|
||||||
n, err = strconv.Atoi(sortColumn)
|
|
||||||
if err != nil || n < 0 {
|
|
||||||
err = fmt.Errorf("invalid sorting column: %q", sortColumn)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// add sorting option to query
|
|
||||||
if i > 0 {
|
|
||||||
sql += ","
|
|
||||||
}
|
|
||||||
sql += fmt.Sprintf("%s %s NULLS LAST", sortColumn, sortOrder)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
module tanabata
|
|
||||||
|
|
||||||
go 1.23.0
|
|
||||||
|
|
||||||
toolchain go1.23.10
|
|
||||||
|
|
||||||
require github.com/jackc/pgx/v5 v5.7.5
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/pgx v3.6.2+incompatible // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
|
||||||
github.com/stretchr/testify v1.9.0 // indirect
|
|
||||||
golang.org/x/crypto v0.37.0 // indirect
|
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
|
||||||
golang.org/x/text v0.24.0 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=
|
|
||||||
github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
|
||||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
|
||||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
|
||||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
func UserLogin(ctx context.Context, name, password string) (user_id int, err error) {
|
|
||||||
row := connPool.QueryRow(ctx, "SELECT id FROM users WHERE name=$1 AND password=crypt($2, password)", name, password)
|
|
||||||
err = row.Scan(&user_id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func UserAuth(ctx context.Context, user_id int) (ok, isAdmin bool) {
|
|
||||||
row := connPool.QueryRow(ctx, "SELECT is_admin FROM users WHERE id=$1", user_id)
|
|
||||||
err := row.Scan(&isAdmin)
|
|
||||||
ok = (err == nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FileStore struct {
|
|
||||||
db *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFileStore(db *pgxpool.Pool) *FileStore {
|
|
||||||
return &FileStore{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user's access rights to file
|
|
||||||
func (s *FileStore) getAccess(user_id int, file_id string) (canView, canEdit bool, err error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
row := connPool.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
COALESCE(a.view, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE),
|
|
||||||
COALESCE(a.edit, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE)
|
|
||||||
FROM data.files f
|
|
||||||
LEFT JOIN acl.files a ON a.file_id=f.id AND a.user_id=$1
|
|
||||||
LEFT JOIN system.users u ON u.id=$1
|
|
||||||
WHERE f.id=$2
|
|
||||||
`, user_id, file_id)
|
|
||||||
err = row.Scan(&canView, &canEdit)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a set of files
|
|
||||||
func (s *FileStore) GetSlice(user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], statusCode int, err error) {
|
|
||||||
filterCond, statusCode, err := filterToSQL(filter)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sortExpr, statusCode, err := sortToSQL(sort)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// prepare query
|
|
||||||
query := `
|
|
||||||
SELECT
|
|
||||||
f.id,
|
|
||||||
f.name,
|
|
||||||
m.name,
|
|
||||||
m.extension,
|
|
||||||
uuid_extract_timestamp(f.id),
|
|
||||||
u.name,
|
|
||||||
u.is_admin
|
|
||||||
FROM data.files f
|
|
||||||
JOIN system.mime m ON m.id=f.mime_id
|
|
||||||
JOIN system.users u ON u.id=f.creator_id
|
|
||||||
WHERE NOT f.is_deleted AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=f.id AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1)) AND
|
|
||||||
`
|
|
||||||
query += filterCond
|
|
||||||
queryCount := query
|
|
||||||
query += sortExpr
|
|
||||||
if limit >= 0 {
|
|
||||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
|
||||||
}
|
|
||||||
if offset > 0 {
|
|
||||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
|
||||||
}
|
|
||||||
// execute query
|
|
||||||
statusCode, err = transaction(func(ctx context.Context, tx pgx.Tx) (statusCode int, err error) {
|
|
||||||
rows, err := tx.Query(ctx, query, user_id)
|
|
||||||
if err != nil {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
count := 0
|
|
||||||
for rows.Next() {
|
|
||||||
var file domain.FileItem
|
|
||||||
err = rows.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
files.Data = append(files.Data, file)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
files.Pagination.Limit = limit
|
|
||||||
files.Pagination.Offset = offset
|
|
||||||
files.Pagination.Count = count
|
|
||||||
row := tx.QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM (%s) tmp", queryCount), user_id)
|
|
||||||
err = row.Scan(&files.Pagination.Total)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
}
|
|
||||||
return
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
statusCode = http.StatusOK
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file
|
|
||||||
func (s *FileStore) Get(user_id int, file_id string) (file domain.FileFull, statusCode int, err error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
row := connPool.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
f.id,
|
|
||||||
f.name,
|
|
||||||
m.name,
|
|
||||||
m.extension,
|
|
||||||
uuid_extract_timestamp(f.id),
|
|
||||||
u.name,
|
|
||||||
u.is_admin,
|
|
||||||
f.notes,
|
|
||||||
f.metadata,
|
|
||||||
(SELECT COUNT(*) FROM activity.file_views fv WHERE fv.file_id=$2 AND fv.user_id=$1)
|
|
||||||
FROM data.files f
|
|
||||||
JOIN system.mime m ON m.id=f.mime_id
|
|
||||||
JOIN system.users u ON u.id=f.creator_id
|
|
||||||
WHERE NOT f.is_deleted AND f.id=$2 AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))
|
|
||||||
`, user_id, file_id)
|
|
||||||
err = row.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin, &file.Notes, &file.Metadata, &file.Viewed)
|
|
||||||
if err != nil {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rows, err := connPool.Query(ctx, `
|
|
||||||
SELECT
|
|
||||||
t.id,
|
|
||||||
t.name,
|
|
||||||
COALESCE(t.color, c.color)
|
|
||||||
FROM data.tags t
|
|
||||||
LEFT JOIN data.categories c ON c.id=t.category_id
|
|
||||||
JOIN data.file_tag ft ON ft.tag_id=t.id
|
|
||||||
WHERE ft.file_id=$1
|
|
||||||
`, file_id)
|
|
||||||
if err != nil {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var tag domain.TagCore
|
|
||||||
err = rows.Scan(&tag.ID, &tag.Name, &tag.Color)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file.Tags = append(file.Tags, tag)
|
|
||||||
}
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusCode = http.StatusOK
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add file
|
|
||||||
func (s *FileStore) Add(user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, statusCode int, err error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
var mime_id int
|
|
||||||
var extension string
|
|
||||||
row := connPool.QueryRow(ctx, "SELECT id, extension FROM system.mime WHERE name=$1", mime)
|
|
||||||
err = row.Scan(&mime_id, &extension)
|
|
||||||
if err != nil {
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
err = fmt.Errorf("unsupported file type: %q", mime)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
} else {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
row = connPool.QueryRow(ctx, `
|
|
||||||
INSERT INTO data.files (name, mime_id, datetime, creator_id, notes, metadata)
|
|
||||||
VALUES (NULLIF($1, ''), $2, $3, $4, NULLIF($5 ,''), $6)
|
|
||||||
RETURNING id
|
|
||||||
`, name, mime_id, datetime, user_id, notes, metadata)
|
|
||||||
err = row.Scan(&file.ID)
|
|
||||||
if err != nil {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file.Name.String = name
|
|
||||||
file.Name.Valid = (name != "")
|
|
||||||
file.MIME.Name = mime
|
|
||||||
file.MIME.Extension = extension
|
|
||||||
statusCode = http.StatusOK
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update file
|
|
||||||
func (s *FileStore) Update(user_id int, file_id string, updates map[string]interface{}) (statusCode int, err error) {
|
|
||||||
if len(updates) == 0 {
|
|
||||||
err = fmt.Errorf("no fields provided for update")
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writableFields := map[string]bool{
|
|
||||||
"name": true,
|
|
||||||
"datetime": true,
|
|
||||||
"notes": true,
|
|
||||||
"metadata": true,
|
|
||||||
}
|
|
||||||
query := "UPDATE data.files SET"
|
|
||||||
newValues := []interface{}{user_id}
|
|
||||||
count := 2
|
|
||||||
for field, value := range updates {
|
|
||||||
if !writableFields[field] {
|
|
||||||
err = fmt.Errorf("invalid field: %q", field)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
}
|
|
||||||
query += fmt.Sprintf(" %s=NULLIF($%d, '')", field, count)
|
|
||||||
newValues = append(newValues, value)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
query += fmt.Sprintf(
|
|
||||||
" WHERE id=$%d AND (creator_id=$1 OR (SELECT edit FROM acl.files WHERE file_id=$%d AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))",
|
|
||||||
count, count)
|
|
||||||
newValues = append(newValues, file_id)
|
|
||||||
ctx := context.Background()
|
|
||||||
commandTag, err := connPool.Exec(ctx, query, newValues...)
|
|
||||||
if err != nil {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if commandTag.RowsAffected() == 0 {
|
|
||||||
err = fmt.Errorf("not found")
|
|
||||||
statusCode = http.StatusNotFound
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusCode = http.StatusNoContent
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete file
|
|
||||||
func (s *FileStore) Delete(user_id int, file_id string) (statusCode int, err error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
commandTag, err := connPool.Exec(ctx,
|
|
||||||
"DELETE FROM data.files WHERE id=$2 AND (creator_id=$1 OR (SELECT edit FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))",
|
|
||||||
user_id, file_id)
|
|
||||||
if err != nil {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if commandTag.RowsAffected() == 0 {
|
|
||||||
err = fmt.Errorf("not found")
|
|
||||||
statusCode = http.StatusNotFound
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusCode = http.StatusNoContent
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Storage struct {
|
|
||||||
db *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
var connPool *pgxpool.Pool
|
|
||||||
|
|
||||||
// Initialize new database storage
|
|
||||||
func New(dbURL string) (*Storage, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
config, err := pgxpool.ParseConfig(dbURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse DB URL: %w", err)
|
|
||||||
}
|
|
||||||
config.MaxConns = 10
|
|
||||||
config.MinConns = 2
|
|
||||||
config.HealthCheckPeriod = time.Minute
|
|
||||||
db, err := pgxpool.NewWithConfig(ctx, config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
|
||||||
}
|
|
||||||
err = db.Ping(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("database ping failed: %w", err)
|
|
||||||
}
|
|
||||||
return &Storage{db: db}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close database storage
|
|
||||||
func (s *Storage) Close() {
|
|
||||||
s.db.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run handler inside transaction
|
|
||||||
func (s *Storage) transaction(ctx context.Context, handler func(context.Context, pgx.Tx) (statusCode int, err error)) (statusCode int, err error) {
|
|
||||||
tx, err := connPool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusCode, err = handler(ctx, tx)
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback(ctx)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = tx.Commit(ctx)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle database error
|
|
||||||
func (s *Storage) handleDBError(errIn error) (statusCode int, err error) {
|
|
||||||
if errIn == nil {
|
|
||||||
statusCode = http.StatusOK
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if errors.Is(errIn, pgx.ErrNoRows) {
|
|
||||||
err = fmt.Errorf("not found")
|
|
||||||
statusCode = http.StatusNotFound
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(errIn, &pgErr) {
|
|
||||||
switch pgErr.Code {
|
|
||||||
case "22P02", "22007": // Invalid data format
|
|
||||||
err = fmt.Errorf("%s", pgErr.Message)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
case "23505": // Unique constraint violation
|
|
||||||
err = fmt.Errorf("already exists")
|
|
||||||
statusCode = http.StatusConflict
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return http.StatusInternalServerError, errIn
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"tanabata/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Storage interface {
|
|
||||||
FileRepository
|
|
||||||
Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileRepository interface {
|
|
||||||
GetSlice(user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], statusCode int, err error)
|
|
||||||
Get(user_id int, file_id string) (file domain.FileFull, statusCode int, err error)
|
|
||||||
Add(user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, statusCode int, err error)
|
|
||||||
Update(user_id int, file_id string, updates map[string]interface{}) (statusCode int, err error)
|
|
||||||
Delete(user_id int, file_id string) (statusCode int, err error)
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
IsAdmin bool `json:"isAdmin"`
|
|
||||||
CanCreate bool `json:"canCreate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MIME struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Extension string `json:"extension"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type (
|
|
||||||
CategoryCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Color pgtype.Text `json:"color"`
|
|
||||||
}
|
|
||||||
CategoryItem struct {
|
|
||||||
CategoryCore
|
|
||||||
}
|
|
||||||
CategoryFull struct {
|
|
||||||
CategoryCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes pgtype.Text `json:"notes"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
FileCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name pgtype.Text `json:"name"`
|
|
||||||
MIME MIME `json:"mime"`
|
|
||||||
}
|
|
||||||
FileItem struct {
|
|
||||||
FileCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
}
|
|
||||||
FileFull struct {
|
|
||||||
FileCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes pgtype.Text `json:"notes"`
|
|
||||||
Metadata json.RawMessage `json:"metadata"`
|
|
||||||
Tags []TagCore `json:"tags"`
|
|
||||||
Viewed int `json:"viewed"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
TagCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Color pgtype.Text `json:"color"`
|
|
||||||
}
|
|
||||||
TagItem struct {
|
|
||||||
TagCore
|
|
||||||
Category CategoryCore `json:"category"`
|
|
||||||
}
|
|
||||||
TagFull struct {
|
|
||||||
TagCore
|
|
||||||
Category CategoryCore `json:"category"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes pgtype.Text `json:"notes"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type Autotag struct {
|
|
||||||
TriggerTag TagCore `json:"triggerTag"`
|
|
||||||
AddTag TagCore `json:"addTag"`
|
|
||||||
IsActive bool `json:"isActive"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type (
|
|
||||||
PoolCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
PoolItem struct {
|
|
||||||
PoolCore
|
|
||||||
}
|
|
||||||
PoolFull struct {
|
|
||||||
PoolCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes pgtype.Text `json:"notes"`
|
|
||||||
Viewed int `json:"viewed"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type Session struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
UserAgent string `json:"userAgent"`
|
|
||||||
StartedAt time.Time `json:"startedAt"`
|
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
|
||||||
LastActivity time.Time `json:"lastActivity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Pagination struct {
|
|
||||||
Total int `json:"total"`
|
|
||||||
Offset int `json:"offset"`
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Slice[T any] struct {
|
|
||||||
Pagination Pagination `json:"pagination"`
|
|
||||||
Data []T `json:"data"`
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
body {
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.decoration {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.decoration.left {
|
|
||||||
left: 0;
|
|
||||||
width: 20vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.decoration.right {
|
|
||||||
right: 0;
|
|
||||||
width: 20vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
#auth {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#auth h1 {
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#auth .form-control {
|
|
||||||
margin: 14px 0;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#login {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #312F45;
|
|
||||||
color: #f0f0f0;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: stretch;
|
|
||||||
font-family: Epilogue;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
height: 50px;
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 14px;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: #9592B5;
|
|
||||||
border-color: #454261;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #7D7AA4;
|
|
||||||
border-color: #454261;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background-color: #DB6060;
|
|
||||||
border-color: #851E1E;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background-color: #D64848;
|
|
||||||
border-color: #851E1E;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
header, footer {
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 5px 5px #0004;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-header {
|
|
||||||
height: .8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#select {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sorting {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sorting {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlighted {
|
|
||||||
color: #9999AD;
|
|
||||||
}
|
|
||||||
|
|
||||||
#icon-expand {
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sorting-options {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 114%;
|
|
||||||
padding: 4px 10px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background-color: #111118;
|
|
||||||
border-radius: 10px;
|
|
||||||
text-align: left;
|
|
||||||
box-shadow: 0 0 10px black;
|
|
||||||
z-index: 9999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sorting-option {
|
|
||||||
padding: 4px 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sorting-option input[type="radio"] {
|
|
||||||
float: unset;
|
|
||||||
margin-left: 1.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filtering-wrapper {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filtering-block {
|
|
||||||
position: absolute;
|
|
||||||
top: 128px;
|
|
||||||
left: 14px;
|
|
||||||
right: 14px;
|
|
||||||
padding: 14px;
|
|
||||||
background-color: #111118;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 0 10px 4px #0004;
|
|
||||||
z-index: 9998;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
main:after {
|
|
||||||
content: "";
|
|
||||||
flex: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-preview {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-selected:after {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
width: 100%;
|
|
||||||
height: 50%;
|
|
||||||
background-image: url("/static/images/icon-select.svg");
|
|
||||||
background-size: contain;
|
|
||||||
background-position: right;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview {
|
|
||||||
margin: 1px 0;
|
|
||||||
padding: 0;
|
|
||||||
width: 160px;
|
|
||||||
height: 160px;
|
|
||||||
max-width: calc(33vw - 7px);
|
|
||||||
max-height: calc(33vw - 7px);
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-thumb {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
object-position: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview .overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
background-color: #0002;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview:hover .overlay {
|
|
||||||
background-color: #0004;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-preview, .filtering-token {
|
|
||||||
margin: 5px 5px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background-color: #444455;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-preview {
|
|
||||||
margin: 5px 5px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background-color: #444455;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
min-width: 100vw;
|
|
||||||
min-height: 100vh;
|
|
||||||
max-width: 100vw;
|
|
||||||
max-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file .preview-img {
|
|
||||||
max-width: 100vw;
|
|
||||||
max-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-manager {
|
|
||||||
position: fixed;
|
|
||||||
left: 10px;
|
|
||||||
right: 10px;
|
|
||||||
bottom: 65px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
max-height: 40vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 15px 10px;
|
|
||||||
background-color: #181721;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 0 5px #0008;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-manager hr {
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-header > * {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#selection-edit-tags {
|
|
||||||
color: #4DC7ED;
|
|
||||||
}
|
|
||||||
|
|
||||||
#selection-add-to-pool {
|
|
||||||
color: #F5E872;
|
|
||||||
}
|
|
||||||
|
|
||||||
#selection-delete {
|
|
||||||
color: #DB6060;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-tags {
|
|
||||||
max-height: 100%;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="color"] {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-container, .filtering-operators, .filtering-tokens {
|
|
||||||
padding: 5px;
|
|
||||||
background-color: #212529;
|
|
||||||
border: 1px solid #495057;
|
|
||||||
border-radius: .375rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filtering-operators {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-container, .filtering-tokens {
|
|
||||||
margin: 15px 0;
|
|
||||||
height: 200px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-container:after, .filtering-tokens:after {
|
|
||||||
content: "";
|
|
||||||
flex: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-container-selected {
|
|
||||||
height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#files-filter {
|
|
||||||
margin-bottom: 0;
|
|
||||||
height: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-wrapper {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: #000a;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
/* overflow-y: scroll;*/
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-nav {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-nav:hover {
|
|
||||||
background-color: #b4adff40;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-nav-prev {
|
|
||||||
left: 0;
|
|
||||||
right: 80vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-nav-next {
|
|
||||||
left: 80vw;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-nav-close {
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: unset;
|
|
||||||
height: 15vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-nav-icon {
|
|
||||||
width: 20px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-nav-close > .viewer-nav-icon {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#viewer {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sessions-wrapper {
|
|
||||||
padding: 14px;
|
|
||||||
background-color: #111118;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-terminate {
|
|
||||||
height: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #0007;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 40px;
|
|
||||||
width: 18vw;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav.curr, .nav:hover {
|
|
||||||
background-color: #343249;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navicon {
|
|
||||||
display: block;
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#loader {
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #000a;
|
|
||||||
z-index: 9999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader-wrapper {
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader-img {
|
|
||||||
max-width: 20vw;
|
|
||||||
max-height: 20vh;
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 158 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M4.51245 10.9993C4.51245 10.7415 4.61483 10.4944 4.79705 10.3122C4.97928 10.1299 5.22644 10.0276 5.48415 10.0276H10.0097V5.50203C10.0097 5.24432 10.112 4.99717 10.2943 4.81494C10.4765 4.63271 10.7237 4.53033 10.9814 4.53033C11.2391 4.53033 11.4862 4.63271 11.6685 4.81494C11.8507 4.99717 11.9531 5.24432 11.9531 5.50203V10.0276H16.4786C16.7363 10.0276 16.9835 10.1299 17.1657 10.3122C17.3479 10.4944 17.4503 10.7415 17.4503 10.9993C17.4503 11.257 17.3479 11.5041 17.1657 11.6863C16.9835 11.8686 16.7363 11.971 16.4786 11.971H11.9531V16.4965C11.9531 16.7542 11.8507 17.0013 11.6685 17.1836C11.4862 17.3658 11.2391 17.4682 10.9814 17.4682C10.7237 17.4682 10.4765 17.3658 10.2943 17.1836C10.112 17.0013 10.0097 16.7542 10.0097 16.4965V11.971H5.48415C5.22644 11.971 4.97928 11.8686 4.79705 11.6863C4.61483 11.5041 4.51245 11.257 4.51245 10.9993Z" fill="#9999AD"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.91409 0.335277C8.9466 -0.111759 13.0162 -0.111759 17.0487 0.335277C19.4157 0.599579 21.3267 2.46394 21.604 4.84396C22.0834 8.93416 22.0834 13.0658 21.604 17.156C21.3254 19.536 19.4144 21.3991 17.0487 21.6647C13.0162 22.1118 8.9466 22.1118 4.91409 21.6647C2.54704 21.3991 0.636031 19.536 0.358773 17.156C-0.119591 13.0659 -0.119591 8.93404 0.358773 4.84396C0.636031 2.46394 2.54833 0.599579 4.91409 0.335277ZM16.8336 2.26572C12.944 1.83459 9.01873 1.83459 5.12916 2.26572C4.40913 2.3456 3.73705 2.66589 3.2215 3.17486C2.70595 3.68383 2.37704 4.35174 2.28792 5.07069C1.82714 9.01056 1.82714 12.9907 2.28792 16.9306C2.37732 17.6493 2.70634 18.3169 3.22186 18.8256C3.73739 19.3343 4.40932 19.6544 5.12916 19.7343C8.98616 20.1644 12.9766 20.1644 16.8336 19.7343C17.5532 19.6542 18.2248 19.3339 18.7401 18.8253C19.2554 18.3166 19.5842 17.6491 19.6735 16.9306C20.1343 12.9907 20.1343 9.01056 19.6735 5.07069C19.5839 4.3524 19.255 3.68524 18.7397 3.17681C18.2245 2.66839 17.553 2.34835 16.8336 2.26831" fill="#9999AD"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M18.875 9.25C21.1532 9.25 23 7.40317 23 5.125C23 2.84683 21.1532 1 18.875 1C16.5968 1 14.75 2.84683 14.75 5.125C14.75 7.40317 16.5968 9.25 18.875 9.25Z" stroke="#9999AD" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M5.125 23C7.40317 23 9.25 21.1532 9.25 18.875C9.25 16.5968 7.40317 14.75 5.125 14.75C2.84683 14.75 1 16.5968 1 18.875C1 21.1532 2.84683 23 5.125 23Z" stroke="#9999AD" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M14.75 14.75H23V21.625C23 21.9897 22.8551 22.3394 22.5973 22.5973C22.3394 22.8551 21.9897 23 21.625 23H16.125C15.7603 23 15.4106 22.8551 15.1527 22.5973C14.8949 22.3394 14.75 21.9897 14.75 21.625V14.75ZM1 1H9.25V7.875C9.25 8.23967 9.10513 8.58941 8.84727 8.84727C8.58941 9.10513 8.23967 9.25 7.875 9.25H2.375C2.01033 9.25 1.66059 9.10513 1.40273 8.84727C1.14487 8.58941 1 8.23967 1 7.875V1Z" stroke="#9999AD" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,3 +0,0 @@
|
|||||||
<svg width="16" height="9" viewBox="0 0 16 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M8.10279 7.27726L14.8104 0.579294C14.8755 0.513843 14.9531 0.462123 15.0386 0.427199C15.1241 0.392275 15.2157 0.374857 15.308 0.375976C15.4003 0.377095 15.4915 0.396729 15.5761 0.433714C15.6607 0.470699 15.737 0.524284 15.8005 0.591294C15.9306 0.728358 16.0022 0.910756 15.9999 1.09973C15.9977 1.2887 15.9219 1.46935 15.7885 1.60329L8.58484 8.79625C8.5202 8.86132 8.44324 8.91285 8.35845 8.94783C8.27366 8.98282 8.18275 9.00055 8.09103 8.99999C7.99931 8.99943 7.90862 8.98059 7.82427 8.94458C7.73991 8.90857 7.66359 8.8561 7.59975 8.79025L0.204043 1.21929C0.0731536 1.08376 0 0.902704 0 0.714294C0 0.525883 0.0731536 0.344832 0.204043 0.209296C0.268362 0.143072 0.345316 0.0904251 0.43035 0.0544745C0.515384 0.0185239 0.606768 0 0.69909 0C0.791413 0 0.882797 0.0185239 0.967831 0.0544745C1.05286 0.0904251 1.12982 0.143072 1.19414 0.209296L8.10279 7.27726Z" fill="#9999AD"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 985 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M13.0465 20.4651H8.95346V22H13.0465V20.4651ZM1.53488 13.0465V8.95352H1.29521e-06V13.0465H1.53488ZM20.4651 12.5994V13.0465H21.9999V12.5994H20.4651ZM13.9582 3.43921L18.0092 7.08506L19.0356 5.94311L14.9855 2.29726L13.9582 3.43921ZM21.9999 12.5994C21.9999 10.8711 22.0153 9.77621 21.5804 8.79798L20.1775 9.42319C20.4497 10.0351 20.4651 10.736 20.4651 12.5994H21.9999ZM18.0092 7.08506C19.3937 8.33138 19.9053 8.81231 20.1775 9.42319L21.5804 8.79798C21.1445 7.81873 20.3208 7.09938 19.0356 5.94311L18.0092 7.08506ZM8.98416 1.53494C10.6029 1.53494 11.2138 1.54722 11.7572 1.75596L12.3077 0.323405C11.4359 -0.012222 10.4863 5.70826e-05 8.98416 5.70826e-05V1.53494ZM14.9855 2.29828C13.8743 1.29856 13.1795 0.656985 12.3077 0.323405L11.7582 1.75596C12.3026 1.9647 12.761 2.36172 13.9582 3.43921L14.9855 2.29828ZM8.95346 20.4651C7.00212 20.4651 5.61664 20.4631 4.56371 20.3219C3.53534 20.1837 2.94185 19.9238 2.50902 19.491L1.42437 20.5756C2.18976 21.3431 3.16083 21.6818 4.36008 21.8434C5.53682 22.002 7.04612 22 8.95346 22V20.4651ZM1.29521e-06 13.0465C1.29521e-06 14.9539 -0.00204521 16.4621 0.156559 17.6399C0.318233 18.8392 0.657953 19.8102 1.42335 20.5766L2.50799 19.492C2.07618 19.0581 1.81627 18.4646 1.67814 17.4353C1.53693 16.3844 1.53488 14.9979 1.53488 13.0465H1.29521e-06ZM13.0465 22C14.9538 22 16.4621 22.002 17.6399 21.8434C18.8391 21.6818 19.8102 21.342 20.5766 20.5766L19.4919 19.492C19.0581 19.9238 18.4646 20.1837 17.4352 20.3219C16.3843 20.4631 14.9978 20.4651 13.0465 20.4651V22ZM20.4651 13.0465C20.4651 14.9979 20.463 16.3844 20.3218 17.4363C20.1837 18.4647 19.9238 19.0581 19.4909 19.491L20.5756 20.5756C21.343 19.8102 21.6817 18.8392 21.8434 17.6399C22.002 16.4632 21.9999 14.9539 21.9999 13.0465H20.4651ZM1.53488 8.95352C1.53488 7.00217 1.53693 5.61669 1.67814 4.56376C1.81627 3.53539 2.07618 2.94191 2.50902 2.50907L1.42437 1.42442C0.656929 2.18982 0.318233 3.16088 0.156559 4.36014C-0.00204521 5.53688 1.29521e-06 7.04617 1.29521e-06 8.95352H1.53488ZM8.98416 5.70826e-05C7.06556 5.70826e-05 5.55012 -0.00198942 4.36827 0.156615C3.1639 0.318289 2.18976 0.658009 1.42335 1.4234L2.50799 2.50805C2.94185 2.07624 3.53636 1.81633 4.57189 1.67819C5.62891 1.53698 7.02258 1.53494 8.98416 1.53494V5.70826e-05Z" fill="#9999AD"/>
|
|
||||||
<path d="M12.0232 1.27905V3.83718C12.0232 6.24899 12.0232 7.45541 12.7722 8.20443C13.5212 8.95345 14.7276 8.95345 17.1395 8.95345H21.2325" stroke="#9999AD" stroke-width="1.5"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1,3 +0,0 @@
|
|||||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M6.07102 2.7508H4.64702C4.80588 1.9743 5.22797 1.27646 5.84196 0.775241C6.45595 0.274027 7.22417 0.000183205 8.01676 0H16.7276C18.1259 0 19.467 0.55548 20.4558 1.54424C21.4445 2.533 22 3.87405 22 5.27237V13.9832C22 14.7743 21.7273 15.5411 21.2279 16.1546C20.7285 16.7681 20.033 17.1907 19.2584 17.3511V15.9253C19.6583 15.7816 20.0042 15.518 20.2487 15.1704C20.4932 14.8228 20.6245 14.4082 20.6246 13.9832V5.27237C20.6246 4.23883 20.214 3.24762 19.4832 2.5168C18.7524 1.78597 17.7612 1.3754 16.7276 1.3754H8.01676C7.59001 1.37527 7.17373 1.50748 6.82525 1.75381C6.47678 2.00014 6.21327 2.34846 6.07102 2.7508ZM3.4385 3.66774C2.52656 3.66774 1.65196 4.03001 1.00711 4.67485C0.36227 5.3197 0 6.19429 0 7.10624V18.5679C0 19.4799 0.36227 20.3545 1.00711 20.9993C1.65196 21.6442 2.52656 22.0064 3.4385 22.0064H14.9002C15.8121 22.0064 16.6867 21.6442 17.3316 20.9993C17.9764 20.3545 18.3387 19.4799 18.3387 18.5679V7.10624C18.3387 6.19429 17.9764 5.3197 17.3316 4.67485C16.6867 4.03001 15.8121 3.66774 14.9002 3.66774H3.4385ZM1.3754 7.10624C1.3754 6.55907 1.59276 6.03431 1.97967 5.64741C2.36658 5.2605 2.89133 5.04314 3.4385 5.04314H14.9002C15.4473 5.04314 15.9721 5.2605 16.359 5.64741C16.7459 6.03431 16.9633 6.55907 16.9633 7.10624V18.5679C16.9633 19.1151 16.7459 19.6398 16.359 20.0268C15.9721 20.4137 15.4473 20.631 14.9002 20.631H3.4385C2.89133 20.631 2.36658 20.4137 1.97967 20.0268C1.59276 19.6398 1.3754 19.1151 1.3754 18.5679V7.10624Z" fill="#9999AD"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,3 +0,0 @@
|
|||||||
<svg width="31" height="23" viewBox="0 0 31 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M1.414 7.97C1.78906 7.59506 2.29767 7.38443 2.828 7.38443C3.35833 7.38443 3.86695 7.59506 4.242 7.97L11.314 15.042L25.454 0.900004C25.6397 0.714184 25.8602 0.566757 26.1028 0.466141C26.3455 0.365526 26.6056 0.313692 26.8683 0.313599C27.131 0.313506 27.3911 0.365156 27.6339 0.4656C27.8766 0.566044 28.0972 0.713315 28.283 0.899004C28.4688 1.08469 28.6163 1.30516 28.7169 1.54783C28.8175 1.79049 28.8693 2.0506 28.8694 2.3133C28.8695 2.57599 28.8179 2.83614 28.7174 3.07887C28.617 3.32161 28.4697 3.54218 28.284 3.728L11.314 20.698L1.414 10.798C1.03906 10.4229 0.82843 9.91433 0.82843 9.384C0.82843 8.85368 1.03906 8.34506 1.414 7.97Z" fill="white" stroke="black" stroke-width="1"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 794 B |