46 Commits

Author SHA1 Message Date
H1K0 b774d2b3c9 style(backend): improve error handling 2025-10-10 01:37:01 +03:00
H1K0 d124229308 fix(backend/infrastructure/persistence/postgres): some fixes on FileRepository methods 2025-10-10 00:31:51 +03:00
H1K0 bf7a11076f fix(backend/infrastructure/persistence/postgres): add testing connection to db and improve error messages 2025-10-09 21:01:38 +03:00
H1K0 c7176fadf6 fix(backend/domain): fix typo in error code 2025-10-09 01:46:10 +03:00
H1K0 bc4354bf7b refactor(backend/domain): get rid of pgx types 2025-10-09 01:45:43 +03:00
H1K0 00ab98072b feat(backend/repositories): add get tags by file method
refactor(backend/domain): start switching from pgx types
2025-10-09 01:39:19 +03:00
H1K0 c39d82fafd refactor(backend): switch to DDD 2025-10-09 00:59:30 +03:00
H1K0 4e0fc431e2 refactor(backend/domain): rename domain.go -> entity.go 2025-10-08 20:20:51 +03:00
H1K0 7384751d6b fix(backend/storage/postgres): fix FileGetAccess function 2025-07-05 15:38:31 +03:00
H1K0 49952c62ef refactor(backend): change backend file structure 2025-07-05 14:50:44 +03:00
H1K0 057ba22b18 feat(backend/db): add function to get user's rights to a file 2025-07-04 23:21:53 +03:00
H1K0 35d41a46c0 fix(backend/db): fix error and status handling in some functions 2025-07-04 22:46:00 +03:00
H1K0 dbc34a8e0d feat(db): add recursive function to add tags to file including autotags 2025-07-04 02:32:47 +03:00
H1K0 912c7b80a3 feat(backend/db): add basic CRUD functions for files 2025-07-04 00:37:06 +03:00
H1K0 900770ff36 docs(erd): add can_create column to system.users 2025-07-03 20:19:45 +03:00
H1K0 7062cb630e feat(backend/models): add CanCreate attribute to user model 2025-07-03 20:18:47 +03:00
H1K0 436f164ab9 feat(db): add can_create column to system.users 2025-07-03 20:17:44 +03:00
H1K0 d9ca913620 refactor(backend/models): make file's Metadata not nullable 2025-07-03 20:10:42 +03:00
H1K0 153020b9c7 refactor(db): make metadata in data.files not nullable 2025-07-03 19:33:51 +03:00
H1K0 effcd2c073 feat(db): add is_deleted column to data.files 2025-07-03 19:00:55 +03:00
H1K0 78885b3656 docs(erd): add is_deleted column to data.files 2025-07-03 18:57:53 +03:00
H1K0 828d611f4d fix(backend/db): set status code 200 when no error in handleDBError 2025-07-03 18:34:48 +03:00
H1K0 82bd446a85 feat(backend/models): add aggregated fields to full file, tag, pool models 2025-07-03 18:23:46 +03:00
H1K0 24075e5a76 style(backend/models): change JSON names to camelCase 2025-07-03 18:07:33 +03:00
H1K0 27184bf17a refactor(backend/models): separate core, item and full models 2025-07-03 18:02:46 +03:00
H1K0 ec17dfb0ce feat(backend/db): add database error handler 2025-07-03 17:31:17 +03:00
H1K0 6e3328ca83 refactor(db): make metadata in data.files nullable 2025-07-03 16:19:34 +03:00
H1K0 164ea9a6c8 feat(db): add uuid_extract_timestamp function 2025-07-03 16:18:44 +03:00
H1K0 761babfa1a refactor(backend/models): use pgtype for nullable fields 2025-07-03 16:16:44 +03:00
H1K0 59eacd6bc5 refactor(backend/db): remove ctx argument from transaction wrapper 2025-07-03 15:35:56 +03:00
H1K0 5ac528be05 feat(backend/db): add util functions
`sortToSQL` and unimplemented `filterToSQL`
2025-07-03 14:55:30 +03:00
H1K0 ad3c77b40e feat(backend/db): add transaction wrapper 2025-07-03 03:00:03 +03:00
H1K0 e807d61b05 init(backend/db): initialize db handler 2025-07-03 02:53:06 +03:00
H1K0 429213f29c refactor(backend/models): objects.go -> models.go 2025-07-03 02:52:59 +03:00
H1K0 ace4fa1c0a feat(backend/models): add pagination and slice 2025-07-03 02:52:53 +03:00
H1K0 d543101054 init(backend/models): add objects models 2025-07-03 02:52:28 +03:00
H1K0 b1587f05cc refactor(db): rename schema access -> acl 2025-07-03 02:26:50 +03:00
H1K0 9230b9a5dc docs(erd): rename schema access -> acl 2025-07-03 02:24:40 +03:00
H1K0 be65d0623b init(backend): initialize backend 2025-07-03 00:10:25 +03:00
H1K0 4221abe905 init(db): add database schema 2025-07-02 23:30:53 +03:00
H1K0 a27ae7d4ab docs(erd): adjust sessions table schema for storing JWT whitelist 2025-07-02 23:25:34 +03:00
H1K0 cc53b862e4 docs(erd): change column names in access schema
`read` -> `view`
`write` -> `edit`
2025-07-02 22:11:25 +03:00
H1K0 49fc1537b7 docs(erd): remove created_at columns due to switching to UUID v7 2025-07-02 20:17:47 +03:00
H1K0 1c013183f0 docs(erd): addsurrogate PK to mime table and change some ID types to numeric 2025-07-02 20:14:09 +03:00
H1K0 e8084bdeb0 docs(erd): CRLF -> LF 2025-07-02 12:36:06 +03:00
H1K0 6f7f38c9da docs(erd): add entity relationship diagram (ERD) 2025-07-01 23:58:13 +03:00
150 changed files with 1488 additions and 12092 deletions
-36
View File
@@ -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
-67
View File
@@ -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
-86
View File
@@ -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
-55
View File
@@ -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:)
-70
View File
@@ -1,70 +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/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")
// Repositories
userRepo := postgres.NewUserRepo(pool)
sessionRepo := postgres.NewSessionRepo(pool)
// Services
authSvc := service.NewAuthService(
userRepo,
sessionRepo,
cfg.JWTSecret,
cfg.JWTAccessTTL,
cfg.JWTRefreshTTL,
)
// Handlers
authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc)
r := handler.NewRouter(authMiddleware, authHandler)
slog.Info("starting server", "addr", cfg.ListenAddr)
if err := r.Run(cfg.ListenAddr); err != nil {
slog.Error("server error", "err", err)
os.Exit(1)
}
}
+12 -45
View File
@@ -1,52 +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 (
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/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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // 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/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/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
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
)
+16 -118
View File
@@ -1,134 +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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/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/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/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/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/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/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.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
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/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.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=
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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=
-107
View File
@@ -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
}
-70
View File
@@ -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
}
-97
View File
@@ -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
}
-67
View File
@@ -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
}
-196
View File
@@ -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
}
-19
View File
@@ -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
}
-46
View File
@@ -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
}
-29
View File
@@ -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
}
-32
View File
@@ -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 (
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
type User struct {
@@ -20,26 +18,26 @@ type MIME struct {
type (
CategoryCore struct {
ID string `json:"id"`
Name string `json:"name"`
Color pgtype.Text `json:"color"`
ID *string `json:"id"`
Name *string `json:"name"`
Color *string `json:"color"`
}
CategoryItem struct {
CategoryCore
}
CategoryFull struct {
CategoryCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes *string `json:"notes"`
}
)
type (
FileCore struct {
ID string `json:"id"`
Name pgtype.Text `json:"name"`
MIME MIME `json:"mime"`
ID string `json:"id"`
Name *string `json:"name"`
MIME MIME `json:"mime"`
}
FileItem struct {
FileCore
@@ -50,18 +48,17 @@ type (
FileCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
Notes *string `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"`
ID string `json:"id"`
Name string `json:"name"`
Color *string `json:"color"`
}
TagItem struct {
TagCore
@@ -72,7 +69,7 @@ type (
Category CategoryCore `json:"category"`
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
Notes *string `json:"notes"`
UsedIncl int `json:"usedIncl"`
UsedExcl int `json:"usedExcl"`
}
@@ -94,10 +91,10 @@ type (
}
PoolFull struct {
PoolCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
Viewed int `json:"viewed"`
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes *string `json:"notes"`
Viewed int `json:"viewed"`
}
)
+59 -15
View File
@@ -1,21 +1,65 @@
package domain
// DomainError is a typed domain error with a stable machine-readable code.
// Handlers map these codes to HTTP status codes.
import "fmt"
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 {
code string
message string
Err error `json:"-"`
Code ErrorCode `json:"code"`
Message string `json:"message"`
Details []any `json:"-"`
}
func (e *DomainError) Error() string { return e.message }
func (e *DomainError) Code() string { return e.code }
func (e *DomainError) Wrap(err error) *DomainError {
e.Err = err
return e
}
// Sentinel domain errors. Use errors.Is(err, domain.ErrNotFound) for matching.
var (
ErrNotFound = &DomainError{"not_found", "not found"}
ErrForbidden = &DomainError{"forbidden", "forbidden"}
ErrUnauthorized = &DomainError{"unauthorized", "unauthorized"}
ErrConflict = &DomainError{"conflict", "conflict"}
ErrValidation = &DomainError{"validation_error", "validation error"}
ErrUnsupportedMIME = &DomainError{"unsupported_mime", "unsupported MIME type"}
)
func NewErrorFileNotFound(file_id string) *DomainError {
return &DomainError{
Code: ErrCodeFileNotFound,
Message: fmt.Sprintf("File not found: %q", file_id),
}
}
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",
}
}
-67
View File
@@ -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()
}
-41
View File
@@ -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
}
+17
View File
@@ -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)
}
-41
View File
@@ -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
}
-39
View File
@@ -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
}
-140
View File
@@ -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)
}
-52
View File
@@ -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()
}
}
-55
View File
@@ -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
}
}
-37
View File
@@ -1,37 +0,0 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// NewRouter builds and returns a configured Gin engine.
// Additional handlers will be added here as they are implemented.
func NewRouter(auth *AuthMiddleware, authHandler *AuthHandler) *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)
}
}
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
import (
"errors"
"fmt"
"net/http"
"strconv"
"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
func filterToSQL(filter string) (sql string, statusCode int, err error) {
func filterToSQL(filter string) (sql string, 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) {
func sortToSQL(sort string) (sql string, err error) {
if sort == "" {
return
}
@@ -32,7 +63,6 @@ func sortToSQL(sort string) (sql string, statusCode int, err error) {
sortOrder = "DESC"
default:
err = fmt.Errorf("invalid sorting order mark: %q", sortOrder)
statusCode = http.StatusBadRequest
return
}
// validate sorting column
@@ -40,7 +70,6 @@ func sortToSQL(sort string) (sql string, statusCode int, err error) {
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
@@ -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,
}
}
-155
View File
@@ -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 poolfile membership.
type PoolRepo interface {
List(ctx context.Context, params OffsetParams) (*domain.PoolOffsetPage, error)
GetByID(ctx context.Context, id uuid.UUID) (*domain.Pool, error)
Create(ctx context.Context, p *domain.Pool) (*domain.Pool, error)
Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*domain.Pool, error)
Delete(ctx context.Context, id uuid.UUID) error
// ListFiles returns pool files ordered by position (cursor-based).
ListFiles(ctx context.Context, poolID uuid.UUID, params PoolFileListParams) (*domain.PoolFilePage, error)
// AddFiles appends files starting at position; nil position means append at end.
AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error
// RemoveFiles removes files from the pool.
RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
// Reorder sets the full ordered sequence of file IDs in the pool.
Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
}
// 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)
}
-31
View File
@@ -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. ext is the file extension without a leading dot (e.g. "jpg").
Save(ctx context.Context, id uuid.UUID, ext string, 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, ext string) (io.ReadCloser, error)
// Delete removes the file content from storage.
Delete(ctx context.Context, id uuid.UUID, ext string) 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)
}
-282
View File
@@ -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[:])
}
-65
View File
@@ -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;
-37
View File
@@ -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;
-118
View File
@@ -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;
-22
View File
@@ -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;
-87
View File
@@ -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;
-36
View File
@@ -1,36 +0,0 @@
-- +goose Up
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;
-8
View File
@@ -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
+592
View File
@@ -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
--
-383
View File
@@ -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)
-320
View File
@@ -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
```
+212
View File
@@ -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
-374
View File
@@ -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))
-22
View File
@@ -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)
}
-79
View File
@@ -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
}
-53
View File
@@ -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
}
-19
View File
@@ -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
)
-32
View File
@@ -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)
}
-120
View File
@@ -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"`
}
-36
View File
@@ -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;
}
-54
View File
@@ -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;
}
-388
View File
@@ -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;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

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

@@ -1,4 +0,0 @@
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.371 15.3C13.1935 15.3 14.671 13.8226 14.671 12C14.671 10.1775 13.1935 8.70001 11.371 8.70001C9.54844 8.70001 8.07098 10.1775 8.07098 12C8.07098 13.8226 9.54844 15.3 11.371 15.3Z" stroke="#9999AD" stroke-width="1.5"/>
<path d="M13.3125 1.1672C12.9088 1 12.3962 1 11.371 1C10.3458 1 9.8332 1 9.4295 1.1672C9.1624 1.27776 8.91971 1.43989 8.7153 1.6443C8.51089 1.84871 8.34877 2.0914 8.2382 2.3585C8.137 2.6038 8.0963 2.8909 8.0809 3.3078C8.07405 3.60919 7.9907 3.90389 7.8387 4.16423C7.68669 4.42456 7.47101 4.642 7.2119 4.7961C6.94889 4.94355 6.65271 5.02171 6.35119 5.02325C6.04967 5.02479 5.75271 4.94965 5.4882 4.8049C5.1186 4.6091 4.8513 4.5013 4.5862 4.4661C4.00795 4.39006 3.42317 4.54674 2.9604 4.9017C2.615 5.169 2.3576 5.6123 1.845 6.5C1.3324 7.3877 1.075 7.831 1.0189 8.2655C0.981102 8.552 1.00012 8.84314 1.07486 9.12228C1.1496 9.40143 1.2786 9.66312 1.4545 9.8924C1.6173 10.1036 1.845 10.2807 2.1981 10.5029C2.7184 10.8296 3.0528 11.3862 3.0528 12C3.0528 12.6138 2.7184 13.1704 2.1981 13.496C1.845 13.7193 1.6162 13.8964 1.4545 14.1076C1.2786 14.3369 1.1496 14.5986 1.07486 14.8777C1.00012 15.1569 0.981102 15.448 1.0189 15.7345C1.0761 16.1679 1.3324 16.6123 1.8439 17.5C2.3576 18.3877 2.6139 18.831 2.9604 19.0983C3.18968 19.2742 3.45137 19.4032 3.73052 19.4779C4.00967 19.5527 4.30081 19.5717 4.5873 19.5339C4.8513 19.4987 5.1186 19.3909 5.4882 19.1951C5.75271 19.0503 6.04967 18.9752 6.35119 18.9767C6.65271 18.9783 6.94889 19.0565 7.2119 19.2039C7.7432 19.5119 8.0589 20.0784 8.0809 20.6922C8.0963 21.1102 8.1359 21.3962 8.2382 21.6415C8.34877 21.9086 8.51089 22.1513 8.7153 22.3557C8.91971 22.5601 9.1624 22.7222 9.4295 22.8328C9.8332 23 10.3458 23 11.371 23C12.3962 23 12.9088 23 13.3125 22.8328C13.5796 22.7222 13.8223 22.5601 14.0267 22.3557C14.2311 22.1513 14.3932 21.9086 14.5038 21.6415C14.605 21.3962 14.6457 21.1102 14.6611 20.6922C14.6831 20.0784 14.9988 19.5108 15.5301 19.2039C15.7931 19.0565 16.0893 18.9783 16.3908 18.9767C16.6923 18.9752 16.9893 19.0503 17.2538 19.1951C17.6234 19.3909 17.8907 19.4987 18.1547 19.5339C18.4412 19.5717 18.7323 19.5527 19.0115 19.4779C19.2906 19.4032 19.5523 19.2742 19.7816 19.0983C20.1281 18.8321 20.3844 18.3877 20.897 17.5C21.4096 16.6123 21.667 16.169 21.7231 15.7345C21.7609 15.448 21.7419 15.1569 21.6672 14.8777C21.5924 14.5986 21.4634 14.3369 21.2875 14.1076C21.1247 13.8964 20.897 13.7193 20.5439 13.4971C20.2862 13.3405 20.0726 13.1209 19.9231 12.859C19.7736 12.5971 19.6931 12.3015 19.6892 12C19.6892 11.3862 20.0236 10.8296 20.5439 10.504C20.897 10.2807 21.1258 10.1036 21.2875 9.8924C21.4634 9.66312 21.5924 9.40143 21.6672 9.12228C21.7419 8.84314 21.7609 8.552 21.7231 8.2655C21.6659 7.8321 21.4096 7.3877 20.8981 6.5C20.3844 5.6123 20.1281 5.169 19.7816 4.9017C19.5523 4.7258 19.2906 4.59679 19.0115 4.52205C18.7323 4.44731 18.4412 4.4283 18.1547 4.4661C17.8907 4.5013 17.6234 4.6091 17.2527 4.8049C16.9883 4.94946 16.6916 5.02449 16.3903 5.02295C16.089 5.02141 15.793 4.94335 15.5301 4.7961C15.271 4.642 15.0553 4.42456 14.9033 4.16423C14.7513 3.90389 14.668 3.60919 14.6611 3.3078C14.6457 2.8898 14.6061 2.6038 14.5038 2.3585C14.3932 2.0914 14.2311 1.84871 14.0267 1.6443C13.8223 1.43989 13.5796 1.27776 13.3125 1.1672Z" stroke="#9999AD" stroke-width="1.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 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="M4.00067 16.5511C2.30116 14.8505 1.45086 14.0013 1.13515 12.8979C0.818352 11.7946 1.08895 10.6231 1.63016 8.28122L1.94146 6.93041C2.39576 4.9592 2.62346 3.97359 3.29777 3.29819C3.97317 2.62388 4.95878 2.39618 6.92999 1.94188L8.2808 1.62947C10.6238 1.08937 11.7942 0.818769 12.8975 1.13447C14.0008 1.45127 14.85 2.30158 16.5496 4.00109L18.5626 6.0141C21.5227 8.97312 23 10.4515 23 12.2885C23 14.1267 21.5216 15.6051 18.5637 18.563C15.6046 21.522 14.1262 23.0004 12.2881 23.0004C10.4511 23.0004 8.97161 21.522 6.01369 18.5641L4.00067 16.5511Z" stroke="#9999AD" stroke-width="1.5"/>
<path d="M9.82325 10.1229C10.6824 9.26375 10.6824 7.87077 9.82325 7.01161C8.96409 6.15246 7.57112 6.15246 6.71196 7.01162C5.8528 7.87077 5.8528 9.26375 6.71196 10.1229C7.57112 10.9821 8.96409 10.9821 9.82325 10.1229Z" stroke="#9999AD" stroke-width="1.5"/>
<path d="M11.4962 19.1504L19.1731 11.4724" stroke="#9999AD" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,3 +0,0 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 25C5.59625 25 0 19.4037 0 12.5C0 5.59625 5.59625 0 12.5 0C19.4037 0 25 5.59625 25 12.5C25 19.4037 19.4037 25 12.5 25ZM12.5 22.5C15.1522 22.5 17.6957 21.4464 19.5711 19.5711C21.4464 17.6957 22.5 15.1522 22.5 12.5C22.5 9.84783 21.4464 7.3043 19.5711 5.42893C17.6957 3.55357 15.1522 2.5 12.5 2.5C9.84783 2.5 7.3043 3.55357 5.42893 5.42893C3.55357 7.3043 2.5 9.84783 2.5 12.5C2.5 15.1522 3.55357 17.6957 5.42893 19.5711C7.3043 21.4464 9.84783 22.5 12.5 22.5ZM6.25 11.25H18.75V13.75H6.25V11.25Z" fill="#DB6060"/>
</svg>

Before

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

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