Compare commits
9 Commits
b692fabed5
...
6c9b1bf1cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c9b1bf1cd | |||
| caeff6786e | |||
| 296f44b4ed | |||
| f7cf8cb914 | |||
| 1b3fc04e06 | |||
| d8f6364008 | |||
| 8dd2d631e5 | |||
| a9209ae3a3 | |||
| ba0713151c |
36
.env.example
Normal file
36
.env.example
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 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
|
||||||
70
backend/cmd/server/main.go
Normal file
70
backend/cmd/server/main.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,52 @@
|
|||||||
module tanabata/backend
|
module tanabata/backend
|
||||||
|
|
||||||
go 1.21
|
go 1.26
|
||||||
|
|
||||||
require github.com/google/uuid v1.6.0
|
toolchain go1.26.1
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|||||||
132
backend/go.sum
132
backend/go.sum
@ -1,2 +1,134 @@
|
|||||||
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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/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=
|
||||||
|
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
backend/internal/config/config.go
Normal file
107
backend/internal/config/config.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
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
backend/internal/db/db.go
Normal file
70
backend/internal/db/db.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// Package db provides shared helpers used by all database adapters.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// txKey is the context key used to store an active transaction.
|
||||||
|
type txKey struct{}
|
||||||
|
|
||||||
|
// TxFromContext returns the pgx.Tx stored in ctx by the Transactor, along
|
||||||
|
// with a boolean indicating whether a transaction is active.
|
||||||
|
func TxFromContext(ctx context.Context) (pgx.Tx, bool) {
|
||||||
|
tx, ok := ctx.Value(txKey{}).(pgx.Tx)
|
||||||
|
return tx, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextWithTx returns a copy of ctx that carries tx.
|
||||||
|
// Called by the Transactor before invoking the user function.
|
||||||
|
func ContextWithTx(ctx context.Context, tx pgx.Tx) context.Context {
|
||||||
|
return context.WithValue(ctx, txKey{}, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Querier is the common query interface satisfied by both *pgxpool.Pool and
|
||||||
|
// pgx.Tx, allowing repo helpers to work with either.
|
||||||
|
type Querier interface {
|
||||||
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||||
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanRow executes a single-row query against q and scans the result using
|
||||||
|
// scan. It wraps pgx.ErrNoRows so callers can detect missing rows without
|
||||||
|
// importing pgx directly.
|
||||||
|
func ScanRow[T any](ctx context.Context, q Querier, sql string, args []any, scan func(pgx.Row) (T, error)) (T, error) {
|
||||||
|
row := q.QueryRow(ctx, sql, args...)
|
||||||
|
val, err := scan(row)
|
||||||
|
if err != nil {
|
||||||
|
var zero T
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return zero, fmt.Errorf("%w", pgx.ErrNoRows)
|
||||||
|
}
|
||||||
|
return zero, fmt.Errorf("ScanRow: %w", err)
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClampLimit enforces the [1, max] range on limit, returning def when limit
|
||||||
|
// is zero or negative.
|
||||||
|
func ClampLimit(limit, def, max int) int {
|
||||||
|
if limit <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if limit > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClampOffset returns 0 for negative offsets.
|
||||||
|
func ClampOffset(offset int) int {
|
||||||
|
if offset < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return offset
|
||||||
|
}
|
||||||
97
backend/internal/db/postgres/mime_repo.go
Normal file
97
backend/internal/db/postgres/mime_repo.go
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mimeRow struct {
|
||||||
|
ID int16 `db:"id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Extension string `db:"extension"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMIMEType(r mimeRow) domain.MIMEType {
|
||||||
|
return domain.MIMEType{
|
||||||
|
ID: r.ID,
|
||||||
|
Name: r.Name,
|
||||||
|
Extension: r.Extension,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MimeRepo implements port.MimeRepo using PostgreSQL.
|
||||||
|
type MimeRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMimeRepo creates a MimeRepo backed by pool.
|
||||||
|
func NewMimeRepo(pool *pgxpool.Pool) *MimeRepo {
|
||||||
|
return &MimeRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ port.MimeRepo = (*MimeRepo)(nil)
|
||||||
|
|
||||||
|
func (r *MimeRepo) List(ctx context.Context) ([]domain.MIMEType, error) {
|
||||||
|
const sql = `SELECT id, name, extension FROM core.mime_types ORDER BY name`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sql)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("MimeRepo.List: %w", err)
|
||||||
|
}
|
||||||
|
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[mimeRow])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("MimeRepo.List scan: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]domain.MIMEType, len(collected))
|
||||||
|
for i, row := range collected {
|
||||||
|
result[i] = toMIMEType(row)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MimeRepo) GetByID(ctx context.Context, id int16) (*domain.MIMEType, error) {
|
||||||
|
const sql = `SELECT id, name, extension FROM core.mime_types WHERE id = $1`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sql, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("MimeRepo.GetByID: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[mimeRow])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("MimeRepo.GetByID scan: %w", err)
|
||||||
|
}
|
||||||
|
m := toMIMEType(row)
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MimeRepo) GetByName(ctx context.Context, name string) (*domain.MIMEType, error) {
|
||||||
|
const sql = `SELECT id, name, extension FROM core.mime_types WHERE name = $1`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sql, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("MimeRepo.GetByName: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[mimeRow])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrUnsupportedMIME
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("MimeRepo.GetByName scan: %w", err)
|
||||||
|
}
|
||||||
|
m := toMIMEType(row)
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
67
backend/internal/db/postgres/postgres.go
Normal file
67
backend/internal/db/postgres/postgres.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
162
backend/internal/db/postgres/session_repo.go
Normal file
162
backend/internal/db/postgres/session_repo.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
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
backend/internal/db/postgres/user_repo.go
Normal file
196
backend/internal/db/postgres/user_repo.go
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/db"
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// userRow matches the columns returned by every user SELECT.
|
||||||
|
type userRow struct {
|
||||||
|
ID int16 `db:"id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Password string `db:"password"`
|
||||||
|
IsAdmin bool `db:"is_admin"`
|
||||||
|
CanCreate bool `db:"can_create"`
|
||||||
|
IsBlocked bool `db:"is_blocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// userRowWithTotal extends userRow with a window-function total for List.
|
||||||
|
type userRowWithTotal struct {
|
||||||
|
userRow
|
||||||
|
Total int `db:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toUser(r userRow) domain.User {
|
||||||
|
return domain.User{
|
||||||
|
ID: r.ID,
|
||||||
|
Name: r.Name,
|
||||||
|
Password: r.Password,
|
||||||
|
IsAdmin: r.IsAdmin,
|
||||||
|
CanCreate: r.CanCreate,
|
||||||
|
IsBlocked: r.IsBlocked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// userSortColumn whitelists valid sort keys to prevent SQL injection.
|
||||||
|
var userSortColumn = map[string]string{
|
||||||
|
"name": "name",
|
||||||
|
"id": "id",
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRepo implements port.UserRepo using PostgreSQL.
|
||||||
|
type UserRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserRepo creates a UserRepo backed by pool.
|
||||||
|
func NewUserRepo(pool *pgxpool.Pool) *UserRepo {
|
||||||
|
return &UserRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ port.UserRepo = (*UserRepo)(nil)
|
||||||
|
|
||||||
|
func (r *UserRepo) List(ctx context.Context, params port.OffsetParams) (*domain.UserPage, error) {
|
||||||
|
col, ok := userSortColumn[params.Sort]
|
||||||
|
if !ok {
|
||||||
|
col = "id"
|
||||||
|
}
|
||||||
|
ord := "ASC"
|
||||||
|
if params.Order == "desc" {
|
||||||
|
ord = "DESC"
|
||||||
|
}
|
||||||
|
limit := db.ClampLimit(params.Limit, 50, 200)
|
||||||
|
offset := db.ClampOffset(params.Offset)
|
||||||
|
|
||||||
|
sql := fmt.Sprintf(`
|
||||||
|
SELECT id, name, password, is_admin, can_create, is_blocked,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM core.users
|
||||||
|
ORDER BY %s %s
|
||||||
|
LIMIT $1 OFFSET $2`, col, ord)
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sql, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("UserRepo.List: %w", err)
|
||||||
|
}
|
||||||
|
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[userRowWithTotal])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("UserRepo.List scan: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
page := &domain.UserPage{Offset: offset, Limit: limit}
|
||||||
|
if len(collected) > 0 {
|
||||||
|
page.Total = collected[0].Total
|
||||||
|
}
|
||||||
|
page.Items = make([]domain.User, len(collected))
|
||||||
|
for i, row := range collected {
|
||||||
|
page.Items[i] = toUser(row.userRow)
|
||||||
|
}
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) GetByID(ctx context.Context, id int16) (*domain.User, error) {
|
||||||
|
const sql = `
|
||||||
|
SELECT id, name, password, is_admin, can_create, is_blocked
|
||||||
|
FROM core.users WHERE id = $1`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sql, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("UserRepo.GetByID: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("UserRepo.GetByID scan: %w", err)
|
||||||
|
}
|
||||||
|
u := toUser(row)
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) GetByName(ctx context.Context, name string) (*domain.User, error) {
|
||||||
|
const sql = `
|
||||||
|
SELECT id, name, password, is_admin, can_create, is_blocked
|
||||||
|
FROM core.users WHERE name = $1`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sql, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("UserRepo.GetByName: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("UserRepo.GetByName scan: %w", err)
|
||||||
|
}
|
||||||
|
u := toUser(row)
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) Create(ctx context.Context, u *domain.User) (*domain.User, error) {
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO core.users (name, password, is_admin, can_create)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, name, password, is_admin, can_create, is_blocked`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sql, u.Name, u.Password, u.IsAdmin, u.CanCreate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("UserRepo.Create: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("UserRepo.Create scan: %w", err)
|
||||||
|
}
|
||||||
|
created := toUser(row)
|
||||||
|
return &created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) Update(ctx context.Context, id int16, u *domain.User) (*domain.User, error) {
|
||||||
|
const sql = `
|
||||||
|
UPDATE core.users
|
||||||
|
SET name = $2, password = $3, is_admin = $4, can_create = $5
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -17,5 +17,13 @@ type Category struct {
|
|||||||
CreatorID int16
|
CreatorID int16
|
||||||
CreatorName string // denormalized
|
CreatorName string // denormalized
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
CreatedAt time.Time // extracted from UUID v7
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,18 +7,26 @@ type ctxKey int
|
|||||||
const userKey ctxKey = iota
|
const userKey ctxKey = iota
|
||||||
|
|
||||||
type contextUser struct {
|
type contextUser struct {
|
||||||
ID int16
|
ID int16
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
SessionID int
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context {
|
// WithUser stores user identity and current session ID in ctx.
|
||||||
return context.WithValue(ctx, userKey, contextUser{ID: userID, IsAdmin: isAdmin})
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) {
|
// 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)
|
u, ok := ctx.Value(userKey).(contextUser)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, false
|
return 0, false, 0
|
||||||
}
|
}
|
||||||
return u.ID, u.IsAdmin
|
return u.ID, u.IsAdmin, u.SessionID
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,21 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import "errors"
|
// DomainError is a typed domain error with a stable machine-readable code.
|
||||||
|
// Handlers map these codes to HTTP status codes.
|
||||||
|
type DomainError struct {
|
||||||
|
code string
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
// Sentinel domain errors. Handlers map these to HTTP status codes.
|
func (e *DomainError) Error() string { return e.message }
|
||||||
|
func (e *DomainError) Code() string { return e.code }
|
||||||
|
|
||||||
|
// Sentinel domain errors. Use errors.Is(err, domain.ErrNotFound) for matching.
|
||||||
var (
|
var (
|
||||||
ErrNotFound = errors.New("not found")
|
ErrNotFound = &DomainError{"not_found", "not found"}
|
||||||
ErrForbidden = errors.New("forbidden")
|
ErrForbidden = &DomainError{"forbidden", "forbidden"}
|
||||||
ErrUnauthorized = errors.New("unauthorized")
|
ErrUnauthorized = &DomainError{"unauthorized", "unauthorized"}
|
||||||
ErrConflict = errors.New("conflict")
|
ErrConflict = &DomainError{"conflict", "conflict"}
|
||||||
ErrValidation = errors.New("validation error")
|
ErrValidation = &DomainError{"validation_error", "validation error"}
|
||||||
ErrUnsupportedMIME = errors.New("unsupported MIME type")
|
ErrUnsupportedMIME = &DomainError{"unsupported_mime", "unsupported MIME type"}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -29,21 +29,26 @@ type File struct {
|
|||||||
CreatorName string // denormalized from core.users
|
CreatorName string // denormalized from core.users
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
IsDeleted bool
|
IsDeleted bool
|
||||||
CreatedAt time.Time // extracted from UUID v7
|
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
||||||
Tags []Tag // loaded with the file
|
Tags []Tag // loaded with the file
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileListParams holds all parameters for listing/filtering files.
|
// FileListParams holds all parameters for listing/filtering files.
|
||||||
type FileListParams struct {
|
type FileListParams struct {
|
||||||
Filter string
|
// Pagination
|
||||||
Sort string
|
|
||||||
Order string
|
|
||||||
Cursor string
|
Cursor string
|
||||||
Anchor *uuid.UUID
|
|
||||||
Direction string // "forward" or "backward"
|
Direction string // "forward" or "backward"
|
||||||
|
Anchor *uuid.UUID
|
||||||
Limit int
|
Limit int
|
||||||
Trash bool
|
|
||||||
Search string
|
// 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.
|
// FilePage is the result of a cursor-based file listing.
|
||||||
@ -52,3 +57,11 @@ type FilePage struct {
|
|||||||
NextCursor *string
|
NextCursor *string
|
||||||
PrevCursor *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()
|
||||||
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ type Pool struct {
|
|||||||
CreatorName string // denormalized
|
CreatorName string // denormalized
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
FileCount int
|
FileCount int
|
||||||
CreatedAt time.Time // extracted from UUID v7
|
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// PoolFile is a File with its ordering position within a pool.
|
// PoolFile is a File with its ordering position within a pool.
|
||||||
@ -31,3 +31,11 @@ type PoolFilePage struct {
|
|||||||
Items []PoolFile
|
Items []PoolFile
|
||||||
NextCursor *string
|
NextCursor *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PoolOffsetPage is an offset-based page of pools.
|
||||||
|
type PoolOffsetPage struct {
|
||||||
|
Items []Pool
|
||||||
|
Total int
|
||||||
|
Offset int
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ type Tag struct {
|
|||||||
CreatorID int16
|
CreatorID int16
|
||||||
CreatorName string // denormalized
|
CreatorName string // denormalized
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
CreatedAt time.Time // extracted from UUID v7
|
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// TagRule defines an auto-tagging rule: when WhenTagID is applied,
|
// TagRule defines an auto-tagging rule: when WhenTagID is applied,
|
||||||
@ -31,3 +31,11 @@ type TagRule struct {
|
|||||||
ThenTagName string // denormalized
|
ThenTagName string // denormalized
|
||||||
IsActive bool
|
IsActive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TagOffsetPage is an offset-based page of tags.
|
||||||
|
type TagOffsetPage struct {
|
||||||
|
Items []Tag
|
||||||
|
Total int
|
||||||
|
Offset int
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|||||||
@ -4,12 +4,12 @@ import "time"
|
|||||||
|
|
||||||
// User is an application user.
|
// User is an application user.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int16
|
ID int16
|
||||||
Name string
|
Name string
|
||||||
Password string // bcrypt hash; only populated when needed for auth
|
Password string // bcrypt hash; only populated when needed for auth
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
CanCreate bool
|
CanCreate bool
|
||||||
IsBlocked bool
|
IsBlocked bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session is an active user session.
|
// Session is an active user session.
|
||||||
@ -24,7 +24,7 @@ type Session struct {
|
|||||||
IsCurrent bool // true when this session matches the caller's token
|
IsCurrent bool // true when this session matches the caller's token
|
||||||
}
|
}
|
||||||
|
|
||||||
// OffsetPage is a generic offset-based page of users.
|
// UserPage is an offset-based page of users.
|
||||||
type UserPage struct {
|
type UserPage struct {
|
||||||
Items []User
|
Items []User
|
||||||
Total int
|
Total int
|
||||||
|
|||||||
140
backend/internal/handler/auth_handler.go
Normal file
140
backend/internal/handler/auth_handler.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthHandler handles all /auth endpoints.
|
||||||
|
type AuthHandler struct {
|
||||||
|
authSvc *service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthHandler creates an AuthHandler backed by authSvc.
|
||||||
|
func NewAuthHandler(authSvc *service.AuthService) *AuthHandler {
|
||||||
|
return &AuthHandler{authSvc: authSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login handles POST /auth/login.
|
||||||
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pair, err := h.authSvc.Login(c.Request.Context(), req.Name, req.Password, c.GetHeader("User-Agent"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, gin.H{
|
||||||
|
"access_token": pair.AccessToken,
|
||||||
|
"refresh_token": pair.RefreshToken,
|
||||||
|
"expires_in": pair.ExpiresIn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh handles POST /auth/refresh.
|
||||||
|
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pair, err := h.authSvc.Refresh(c.Request.Context(), req.RefreshToken, c.GetHeader("User-Agent"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, gin.H{
|
||||||
|
"access_token": pair.AccessToken,
|
||||||
|
"refresh_token": pair.RefreshToken,
|
||||||
|
"expires_in": pair.ExpiresIn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout handles POST /auth/logout. Requires authentication.
|
||||||
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
|
_, _, sessionID := domain.UserFromContext(c.Request.Context())
|
||||||
|
|
||||||
|
if err := h.authSvc.Logout(c.Request.Context(), sessionID); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions handles GET /auth/sessions. Requires authentication.
|
||||||
|
func (h *AuthHandler) ListSessions(c *gin.Context) {
|
||||||
|
userID, _, sessionID := domain.UserFromContext(c.Request.Context())
|
||||||
|
|
||||||
|
list, err := h.authSvc.ListSessions(c.Request.Context(), userID, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
ExpiresAt any `json:"expires_at"`
|
||||||
|
LastActivity string `json:"last_activity"`
|
||||||
|
IsCurrent bool `json:"is_current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]sessionItem, len(list.Items))
|
||||||
|
for i, s := range list.Items {
|
||||||
|
var expiresAt any
|
||||||
|
if s.ExpiresAt != nil {
|
||||||
|
expiresAt = s.ExpiresAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
}
|
||||||
|
items[i] = sessionItem{
|
||||||
|
ID: s.ID,
|
||||||
|
UserAgent: s.UserAgent,
|
||||||
|
StartedAt: s.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
LastActivity: s.LastActivity.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
IsCurrent: s.IsCurrent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"total": list.Total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminateSession handles DELETE /auth/sessions/:id. Requires authentication.
|
||||||
|
func (h *AuthHandler) TerminateSession(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
|
||||||
|
|
||||||
|
if err := h.authSvc.TerminateSession(c.Request.Context(), userID, isAdmin, id); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
52
backend/internal/handler/middleware.go
Normal file
52
backend/internal/handler/middleware.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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
backend/internal/handler/response.go
Normal file
55
backend/internal/handler/response.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errorBody is the JSON shape returned for all error responses.
|
||||||
|
type errorBody struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondJSON(c *gin.Context, status int, data any) {
|
||||||
|
c.JSON(status, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondError maps a domain error to the appropriate HTTP status and writes
|
||||||
|
// a JSON error body. Unknown errors become 500.
|
||||||
|
func respondError(c *gin.Context, err error) {
|
||||||
|
var de *domain.DomainError
|
||||||
|
if errors.As(err, &de) {
|
||||||
|
c.JSON(domainStatus(de), errorBody{Code: de.Code(), Message: de.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, errorBody{
|
||||||
|
Code: "internal_error",
|
||||||
|
Message: "internal server error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainStatus maps a DomainError sentinel to its HTTP status code per the
|
||||||
|
// error mapping table in docs/GO_PROJECT_STRUCTURE.md.
|
||||||
|
func domainStatus(de *domain.DomainError) int {
|
||||||
|
switch de {
|
||||||
|
case domain.ErrNotFound:
|
||||||
|
return http.StatusNotFound
|
||||||
|
case domain.ErrForbidden:
|
||||||
|
return http.StatusForbidden
|
||||||
|
case domain.ErrUnauthorized:
|
||||||
|
return http.StatusUnauthorized
|
||||||
|
case domain.ErrConflict:
|
||||||
|
return http.StatusConflict
|
||||||
|
case domain.ErrValidation:
|
||||||
|
return http.StatusBadRequest
|
||||||
|
case domain.ErrUnsupportedMIME:
|
||||||
|
return http.StatusUnsupportedMediaType
|
||||||
|
default:
|
||||||
|
return http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/internal/handler/router.go
Normal file
37
backend/internal/handler/router.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
155
backend/internal/port/repository.go
Normal file
155
backend/internal/port/repository.go
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transactor executes fn inside a single database transaction.
|
||||||
|
// All repository calls made within fn receive the transaction via context.
|
||||||
|
type Transactor interface {
|
||||||
|
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// OffsetParams holds common offset-pagination and sort parameters.
|
||||||
|
type OffsetParams struct {
|
||||||
|
Sort string
|
||||||
|
Order string // "asc" | "desc"
|
||||||
|
Search string
|
||||||
|
Offset int
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolFileListParams holds parameters for listing files inside a pool.
|
||||||
|
type PoolFileListParams struct {
|
||||||
|
Cursor string
|
||||||
|
Limit int
|
||||||
|
Filter string // filter DSL expression
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileRepo is the persistence interface for file records.
|
||||||
|
type FileRepo interface {
|
||||||
|
// List returns a cursor-based page of files.
|
||||||
|
List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error)
|
||||||
|
// GetByID returns the file with its tags loaded.
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*domain.File, error)
|
||||||
|
// Create inserts a new file record and returns it.
|
||||||
|
Create(ctx context.Context, f *domain.File) (*domain.File, error)
|
||||||
|
// Update applies partial metadata changes and returns the updated record.
|
||||||
|
Update(ctx context.Context, id uuid.UUID, f *domain.File) (*domain.File, error)
|
||||||
|
// SoftDelete moves a file to trash (sets is_deleted = true).
|
||||||
|
SoftDelete(ctx context.Context, id uuid.UUID) error
|
||||||
|
// Restore moves a file out of trash (sets is_deleted = false).
|
||||||
|
Restore(ctx context.Context, id uuid.UUID) (*domain.File, error)
|
||||||
|
// DeletePermanent removes a file record. Only allowed when is_deleted = true.
|
||||||
|
DeletePermanent(ctx context.Context, id uuid.UUID) error
|
||||||
|
|
||||||
|
// ListTags returns all tags assigned to a file.
|
||||||
|
ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error)
|
||||||
|
// SetTags replaces all tags on a file (full replace semantics).
|
||||||
|
SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagRepo is the persistence interface for tags.
|
||||||
|
type TagRepo interface {
|
||||||
|
List(ctx context.Context, params OffsetParams) (*domain.TagOffsetPage, error)
|
||||||
|
// ListByCategory returns tags belonging to a specific category.
|
||||||
|
ListByCategory(ctx context.Context, categoryID uuid.UUID, params OffsetParams) (*domain.TagOffsetPage, error)
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*domain.Tag, error)
|
||||||
|
Create(ctx context.Context, t *domain.Tag) (*domain.Tag, error)
|
||||||
|
Update(ctx context.Context, id uuid.UUID, t *domain.Tag) (*domain.Tag, error)
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagRuleRepo is the persistence interface for auto-tag rules.
|
||||||
|
type TagRuleRepo interface {
|
||||||
|
// ListByTag returns all rules where WhenTagID == tagID.
|
||||||
|
ListByTag(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error)
|
||||||
|
Create(ctx context.Context, r domain.TagRule) (*domain.TagRule, error)
|
||||||
|
// SetActive toggles a rule's is_active flag.
|
||||||
|
SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active bool) error
|
||||||
|
Delete(ctx context.Context, whenTagID, thenTagID uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryRepo is the persistence interface for categories.
|
||||||
|
type CategoryRepo interface {
|
||||||
|
List(ctx context.Context, params OffsetParams) (*domain.CategoryOffsetPage, error)
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*domain.Category, error)
|
||||||
|
Create(ctx context.Context, c *domain.Category) (*domain.Category, error)
|
||||||
|
Update(ctx context.Context, id uuid.UUID, c *domain.Category) (*domain.Category, error)
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolRepo is the persistence interface for pools and pool–file membership.
|
||||||
|
type PoolRepo interface {
|
||||||
|
List(ctx context.Context, params OffsetParams) (*domain.PoolOffsetPage, error)
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*domain.Pool, error)
|
||||||
|
Create(ctx context.Context, p *domain.Pool) (*domain.Pool, error)
|
||||||
|
Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*domain.Pool, error)
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
|
||||||
|
// ListFiles returns pool files ordered by position (cursor-based).
|
||||||
|
ListFiles(ctx context.Context, poolID uuid.UUID, params PoolFileListParams) (*domain.PoolFilePage, error)
|
||||||
|
// AddFiles appends files starting at position; nil position means append at end.
|
||||||
|
AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error
|
||||||
|
// RemoveFiles removes files from the pool.
|
||||||
|
RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
||||||
|
// Reorder sets the full ordered sequence of file IDs in the pool.
|
||||||
|
Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRepo is the persistence interface for users.
|
||||||
|
type UserRepo interface {
|
||||||
|
List(ctx context.Context, params OffsetParams) (*domain.UserPage, error)
|
||||||
|
GetByID(ctx context.Context, id int16) (*domain.User, error)
|
||||||
|
// GetByName is used during login to look up credentials.
|
||||||
|
GetByName(ctx context.Context, name string) (*domain.User, error)
|
||||||
|
Create(ctx context.Context, u *domain.User) (*domain.User, error)
|
||||||
|
Update(ctx context.Context, id int16, u *domain.User) (*domain.User, error)
|
||||||
|
Delete(ctx context.Context, id int16) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionRepo is the persistence interface for auth sessions.
|
||||||
|
type SessionRepo interface {
|
||||||
|
// ListByUser returns all active sessions for a user.
|
||||||
|
ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error)
|
||||||
|
// GetByTokenHash looks up a session by the hashed refresh token.
|
||||||
|
GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error)
|
||||||
|
Create(ctx context.Context, s *domain.Session) (*domain.Session, error)
|
||||||
|
// UpdateLastActivity refreshes the last_activity timestamp.
|
||||||
|
UpdateLastActivity(ctx context.Context, id int, t time.Time) error
|
||||||
|
// Delete terminates a single session.
|
||||||
|
Delete(ctx context.Context, id int) error
|
||||||
|
// DeleteByUserID terminates all sessions for a user (logout everywhere).
|
||||||
|
DeleteByUserID(ctx context.Context, userID int16) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLRepo is the persistence interface for per-object permissions.
|
||||||
|
type ACLRepo interface {
|
||||||
|
// List returns all permission entries for a given object.
|
||||||
|
List(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error)
|
||||||
|
// Get returns the permission entry for a specific user and object; returns
|
||||||
|
// ErrNotFound if no entry exists.
|
||||||
|
Get(ctx context.Context, userID int16, objectTypeID int16, objectID uuid.UUID) (*domain.Permission, error)
|
||||||
|
// Set replaces all permissions for an object (full replace semantics).
|
||||||
|
Set(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditRepo is the persistence interface for the audit log.
|
||||||
|
type AuditRepo interface {
|
||||||
|
Log(ctx context.Context, entry domain.AuditEntry) error
|
||||||
|
List(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MimeRepo is the persistence interface for the MIME type whitelist.
|
||||||
|
type MimeRepo interface {
|
||||||
|
// List returns all supported MIME types.
|
||||||
|
List(ctx context.Context) ([]domain.MIMEType, error)
|
||||||
|
// GetByName returns the MIME type record for a given MIME name (e.g. "image/jpeg").
|
||||||
|
// Returns ErrUnsupportedMIME if not in the whitelist.
|
||||||
|
GetByName(ctx context.Context, name string) (*domain.MIMEType, error)
|
||||||
|
}
|
||||||
31
backend/internal/port/storage.go
Normal file
31
backend/internal/port/storage.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
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
backend/internal/service/auth_service.go
Normal file
282
backend/internal/service/auth_service.go
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
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[:])
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ CREATE SCHEMA IF NOT EXISTS acl;
|
|||||||
CREATE SCHEMA IF NOT EXISTS activity;
|
CREATE SCHEMA IF NOT EXISTS activity;
|
||||||
|
|
||||||
-- UUID v7 generator
|
-- UUID v7 generator
|
||||||
|
-- +goose StatementBegin
|
||||||
CREATE OR REPLACE FUNCTION public.uuid_v7(cts timestamptz DEFAULT clock_timestamp())
|
CREATE OR REPLACE FUNCTION public.uuid_v7(cts timestamptz DEFAULT clock_timestamp())
|
||||||
RETURNS uuid LANGUAGE plpgsql AS $$
|
RETURNS uuid LANGUAGE plpgsql AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
@ -38,14 +39,17 @@ BEGIN
|
|||||||
substring(entropy from 1 for 12))::uuid;
|
substring(entropy from 1 for 12))::uuid;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
-- Extract timestamp from UUID v7
|
-- Extract timestamp from UUID v7
|
||||||
|
-- +goose StatementBegin
|
||||||
CREATE OR REPLACE FUNCTION public.uuid_extract_timestamp(uuid_val uuid)
|
CREATE OR REPLACE FUNCTION public.uuid_extract_timestamp(uuid_val uuid)
|
||||||
RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
||||||
SELECT to_timestamp(
|
SELECT to_timestamp(
|
||||||
('x' || left(replace(uuid_val::text, '-', ''), 12))::bit(48)::bigint / 1000.0
|
('x' || left(replace(uuid_val::text, '-', ''), 12))::bit(48)::bigint / 1000.0
|
||||||
);
|
);
|
||||||
$$;
|
$$;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ CREATE TABLE core.users (
|
|||||||
password text NOT NULL, -- bcrypt hash via pgcrypto
|
password text NOT NULL, -- bcrypt hash via pgcrypto
|
||||||
is_admin boolean NOT NULL DEFAULT false,
|
is_admin boolean NOT NULL DEFAULT false,
|
||||||
can_create 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)
|
CONSTRAINT uni__users__name UNIQUE (name)
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,6 +16,7 @@ CREATE TABLE activity.sessions (
|
|||||||
started_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
started_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
||||||
expires_at timestamptz,
|
expires_at timestamptz,
|
||||||
last_activity timestamptz NOT NULL DEFAULT statement_timestamp(),
|
last_activity timestamptz NOT NULL DEFAULT statement_timestamp(),
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
|
||||||
CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash)
|
CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash)
|
||||||
);
|
);
|
||||||
|
|||||||
@ -26,7 +26,11 @@ INSERT INTO activity.action_types (name) VALUES
|
|||||||
-- Sessions
|
-- Sessions
|
||||||
('session_terminate');
|
('session_terminate');
|
||||||
|
|
||||||
|
INSERT INTO core.users (name, password, is_admin, can_create) VALUES
|
||||||
|
('admin', '$2a$10$zk.VTFjRRxbkTE7cKfc7KOWeZfByk1VEkbkgZMJggI1fFf.yDEHZy', true, true);
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
|
|
||||||
|
DELETE FROM core.users WHERE name = 'admin';
|
||||||
DELETE FROM activity.action_types;
|
DELETE FROM activity.action_types;
|
||||||
DELETE FROM core.object_types;
|
DELETE FROM core.object_types;
|
||||||
|
|||||||
8
backend/migrations/embed.go
Normal file
8
backend/migrations/embed.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// FS holds all goose migration SQL files, embedded at build time.
|
||||||
|
//
|
||||||
|
//go:embed *.sql
|
||||||
|
var FS embed.FS
|
||||||
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
42
frontend/README.md
Normal file
42
frontend/README.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# recreate this project
|
||||||
|
npx sv@0.13.2 create --template minimal --types ts --install npm frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
2583
frontend/package-lock.json
generated
Normal file
2583
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"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",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.50.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
|
"svelte": "^5.54.0",
|
||||||
|
"svelte-check": "^4.4.2",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/src/app.css
Normal file
35
frontend/src/app.css
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--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;
|
||||||
|
|
||||||
|
--font-sans: 'Epilogue', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--color-bg-primary: #f5f5f5;
|
||||||
|
--color-bg-secondary: #ffffff;
|
||||||
|
--color-bg-elevated: #e8e8ec;
|
||||||
|
--color-accent: #6B68A0;
|
||||||
|
--color-accent-hover: #5A578F;
|
||||||
|
--color-text-primary: #111118;
|
||||||
|
--color-text-muted: #555566;
|
||||||
|
--color-tag-default: #ccccdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Epilogue';
|
||||||
|
src: url('/fonts/Epilogue-VariableFont_wght.ttf') format('truetype');
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
13
frontend/src/app.d.ts
vendored
Normal file
13
frontend/src/app.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
12
frontend/src/app.html
Normal file
12
frontend/src/app.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="preload" href="/fonts/Epilogue-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin="anonymous" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
frontend/src/lib/api/types.ts
Normal file
23
frontend/src/lib/api/types.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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 PoolFile = components['schemas']['PoolFile'];
|
||||||
|
export type User = components['schemas']['User'];
|
||||||
|
export type Session = components['schemas']['Session'];
|
||||||
|
export type Permission = components['schemas']['Permission'];
|
||||||
|
export type AuditEntry = components['schemas']['AuditLogEntry'];
|
||||||
|
export type TagRule = components['schemas']['TagRule'];
|
||||||
|
|
||||||
|
export type FileCursorPage = components['schemas']['FileCursorPage'];
|
||||||
|
export type TagOffsetPage = components['schemas']['TagOffsetPage'];
|
||||||
|
export type CategoryOffsetPage = components['schemas']['CategoryOffsetPage'];
|
||||||
|
export type PoolOffsetPage = components['schemas']['PoolOffsetPage'];
|
||||||
|
export type UserOffsetPage = components['schemas']['UserOffsetPage'];
|
||||||
|
export type AuditOffsetPage = components['schemas']['AuditLogOffsetPage'];
|
||||||
|
export type PoolFileCursorPage = components['schemas']['PoolFileCursorPage'];
|
||||||
|
export type SessionList = components['schemas']['SessionList'];
|
||||||
|
|
||||||
|
export type ApiError = components['schemas']['Error'];
|
||||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
11
frontend/src/routes/+layout.svelte
Normal file
11
frontend/src/routes/+layout.svelte
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="icon" href={favicon} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
2
frontend/src/routes/+page.svelte
Normal file
2
frontend/src/routes/+page.svelte
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<h1>Welcome to SvelteKit</h1>
|
||||||
|
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||||
BIN
frontend/static/fonts/Epilogue-VariableFont_wght.ttf
vendored
Normal file
BIN
frontend/static/fonts/Epilogue-VariableFont_wght.ttf
vendored
Normal file
Binary file not shown.
3
frontend/static/robots.txt
vendored
Normal file
3
frontend/static/robots.txt
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
12
frontend/svelte.config.js
Normal file
12
frontend/svelte.config.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({ fallback: 'index.html' })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()]
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user