Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fedd9ac3a0 | |||
| 586b597ae5 | |||
| e4173c73fe | |||
| 22983bee73 | |||
| 87555c9670 | |||
| 844ad5452e | |||
| ff13997bbd | |||
| 726b24e6c3 | |||
| 7facfb0dd5 | |||
| f014ae4d1d |
@@ -1,36 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# Tanabata File Manager — environment variables
|
|
||||||
# Copy to .env and fill in the values.
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Server
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
LISTEN_ADDR=:8080
|
|
||||||
JWT_SECRET=change-me-to-a-random-32-byte-secret
|
|
||||||
JWT_ACCESS_TTL=15m
|
|
||||||
JWT_REFRESH_TTL=720h
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Database
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DATABASE_URL=postgres://tanabata:password@localhost:5432/tanabata?sslmode=disable
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Storage
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
FILES_PATH=/data/files
|
|
||||||
THUMBS_CACHE_PATH=/data/thumbs
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Thumbnails
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
THUMB_WIDTH=160
|
|
||||||
THUMB_HEIGHT=160
|
|
||||||
PREVIEW_WIDTH=1920
|
|
||||||
PREVIEW_HEIGHT=1080
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Import
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
IMPORT_PATH=/data/import
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# Tanabata File Manager — .gitattributes
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Line endings: normalize to LF in repo, native on checkout
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
* text=auto eol=lf
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Explicitly text
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
*.go text eol=lf
|
|
||||||
*.mod text eol=lf
|
|
||||||
*.sum text eol=lf
|
|
||||||
*.sql text eol=lf
|
|
||||||
*.ts text eol=lf
|
|
||||||
*.js text eol=lf
|
|
||||||
*.svelte text eol=lf
|
|
||||||
*.html text eol=lf
|
|
||||||
*.css text eol=lf
|
|
||||||
*.json text eol=lf
|
|
||||||
*.yaml text eol=lf
|
|
||||||
*.yml text eol=lf
|
|
||||||
*.md text eol=lf
|
|
||||||
*.txt text eol=lf
|
|
||||||
*.env* text eol=lf
|
|
||||||
*.sh text eol=lf
|
|
||||||
*.toml text eol=lf
|
|
||||||
*.xml text eol=lf
|
|
||||||
*.svg text eol=lf
|
|
||||||
Dockerfile text eol=lf
|
|
||||||
Makefile text eol=lf
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Explicitly binary
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.jpeg binary
|
|
||||||
*.gif binary
|
|
||||||
*.webp binary
|
|
||||||
*.ico binary
|
|
||||||
*.ttf binary
|
|
||||||
*.woff binary
|
|
||||||
*.woff2 binary
|
|
||||||
*.eot binary
|
|
||||||
*.otf binary
|
|
||||||
*.zip binary
|
|
||||||
*.gz binary
|
|
||||||
*.tar binary
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Diff behavior
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
*.sql diff=sql
|
|
||||||
*.go diff=golang
|
|
||||||
*.css diff=css
|
|
||||||
*.html diff=html
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Linguist: set repo language stats correctly
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
docs/reference/** linguist-documentation
|
|
||||||
frontend/static/** linguist-vendored
|
|
||||||
*.min.js linguist-vendored
|
|
||||||
*.min.css linguist-vendored
|
|
||||||
@@ -1,86 +1,3 @@
|
|||||||
# =============================================================================
|
venv/
|
||||||
# Tanabata File Manager — .gitignore
|
*__pycache__/
|
||||||
# =============================================================================
|
.st*
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Environment & secrets
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# OS
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
Desktop.ini
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# IDE
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
!.vscode/settings.json
|
|
||||||
.idea/
|
|
||||||
*.iml
|
|
||||||
*.sublime-project
|
|
||||||
*.sublime-workspace
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Backend (Go)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
backend/tmp/
|
|
||||||
backend/cmd/server/server
|
|
||||||
*.exe
|
|
||||||
*.exe~
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
*.test
|
|
||||||
*.out
|
|
||||||
*.prof
|
|
||||||
coverage.out
|
|
||||||
coverage.html
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Frontend (SvelteKit / Node)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
frontend/node_modules/
|
|
||||||
frontend/.svelte-kit/
|
|
||||||
frontend/build/
|
|
||||||
frontend/dist/
|
|
||||||
frontend/src/lib/api/schema.ts
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Docker
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
docker-compose.override.yml
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Data directories (runtime, not in repo)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
data/
|
|
||||||
*.sqlite
|
|
||||||
*.sqlite3
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Misc
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
*.log
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Reference: exclude vendored libs, keep design sources
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
docs/reference/**/bootstrap.min.css
|
|
||||||
docs/reference/**/bootstrap.min.css.map
|
|
||||||
docs/reference/**/jquery-*.min.js
|
|
||||||
docs/reference/**/__pycache__/
|
|
||||||
docs/reference/**/*.pyc
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
# Tanabata File Manager
|
|
||||||
|
|
||||||
Multi-user, tag-based web file manager for images and video.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Monorepo: `backend/` (Go) + `frontend/` (SvelteKit).
|
|
||||||
|
|
||||||
- Backend: Go + Gin + pgx v5 + goose migrations. Clean Architecture.
|
|
||||||
- Frontend: SvelteKit SPA + Tailwind CSS + CSS custom properties.
|
|
||||||
- DB: PostgreSQL 14+.
|
|
||||||
- Auth: JWT Bearer tokens.
|
|
||||||
|
|
||||||
## Key documents (read before coding)
|
|
||||||
|
|
||||||
- `openapi.yaml` — full REST API specification (36 paths, 58 operations)
|
|
||||||
- `docs/GO_PROJECT_STRUCTURE.md` — backend architecture, layer rules, DI pattern
|
|
||||||
- `docs/FRONTEND_STRUCTURE.md` — frontend architecture, CSS approach, API client
|
|
||||||
- `docs/Описание.md` — product requirements in Russian
|
|
||||||
- `backend/migrations/001_init.sql` — database schema (4 schemas, 16 tables)
|
|
||||||
|
|
||||||
## Design reference
|
|
||||||
|
|
||||||
The `docs/reference/` directory contains the previous Python/Flask version.
|
|
||||||
Use its visual design as the basis for the new frontend:
|
|
||||||
- Color palette: #312F45 (bg), #9592B5 (accent), #444455 (tag default), #111118 (elevated)
|
|
||||||
- Font: Epilogue (variable weight)
|
|
||||||
- Dark theme is primary
|
|
||||||
- Mobile-first layout with bottom navbar
|
|
||||||
- 160×160 thumbnail grid for files
|
|
||||||
- Colored tag pills
|
|
||||||
- Floating selection bar for multi-select
|
|
||||||
|
|
||||||
## Backend commands
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
go run ./cmd/server # run dev server
|
|
||||||
go test ./... # run all tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend commands
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm run dev # vite dev server
|
|
||||||
npm run build # production build
|
|
||||||
npm run generate:types # regenerate API types from openapi.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- Go: gofmt, no global state, context.Context as first param in all service methods
|
|
||||||
- TypeScript: strict mode, named exports
|
|
||||||
- SQL: snake_case, all migrations via goose
|
|
||||||
- API errors: { code, message, details? }
|
|
||||||
- Git: conventional commits (feat:, fix:, docs:, refactor:)
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<h1 align="center">🎋 Tanabata File Manager 🎋</h1>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- [![Release version][release-shield]][release-link] -->
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [About](#about)
|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
Tanabata (_jp._ 七夕) is Japanese festival. People generally celebrate this day (July 7th) by writing wishes, sometimes in the form of poetry, on _tanzaku_ (_jp._ 短冊), small pieces of paper, and hanging them on _sasa_ (_jp._ 笹), bamboo. See [this Wikipedia page](https://en.wikipedia.org/wiki/Tanabata) for more information.
|
||||||
|
|
||||||
|
Tanabata File Manager is a software project that will let you enjoy the Tanabata festival. It allows you to store and organize your files as _sasa_ bamboos, on which you can hang almost any number of _tanzaku_, just like adding tags on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<h6 align="center"><i>© Masahiko AMANO aka H1K0, 2022—present</i></h6>
|
||||||
|
|
||||||
|
<!-- [release-shield]: https://img.shields.io/github/release/H1K0/tanabata/all.svg?style=for-the-badge
|
||||||
|
[release-link]: https://github.com/H1K0/tanabata/releases -->
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
title: Tanabata FM
|
||||||
|
description: Web file manager with tags!
|
||||||
|
remote_theme: pages-themes/merlot@v0.2.0
|
||||||
|
plugins:
|
||||||
|
- jekyll-remote-theme
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/stdlib"
|
|
||||||
"github.com/pressly/goose/v3"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/config"
|
|
||||||
"tanabata/backend/internal/db/postgres"
|
|
||||||
"tanabata/backend/internal/handler"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
"tanabata/backend/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to load config", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := postgres.NewPool(context.Background(), cfg.DatabaseURL)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to connect to database", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
slog.Info("database connected")
|
|
||||||
|
|
||||||
migDB := stdlib.OpenDBFromPool(pool)
|
|
||||||
goose.SetBaseFS(migrations.FS)
|
|
||||||
if err := goose.SetDialect("postgres"); err != nil {
|
|
||||||
slog.Error("goose dialect error", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := goose.Up(migDB, "."); err != nil {
|
|
||||||
slog.Error("migrations failed", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
migDB.Close()
|
|
||||||
slog.Info("migrations applied")
|
|
||||||
|
|
||||||
// Repositories
|
|
||||||
userRepo := postgres.NewUserRepo(pool)
|
|
||||||
sessionRepo := postgres.NewSessionRepo(pool)
|
|
||||||
|
|
||||||
// Services
|
|
||||||
authSvc := service.NewAuthService(
|
|
||||||
userRepo,
|
|
||||||
sessionRepo,
|
|
||||||
cfg.JWTSecret,
|
|
||||||
cfg.JWTAccessTTL,
|
|
||||||
cfg.JWTRefreshTTL,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
|
||||||
authHandler := handler.NewAuthHandler(authSvc)
|
|
||||||
|
|
||||||
r := handler.NewRouter(authMiddleware, authHandler)
|
|
||||||
|
|
||||||
slog.Info("starting server", "addr", cfg.ListenAddr)
|
|
||||||
if err := r.Run(cfg.ListenAddr); err != nil {
|
|
||||||
slog.Error("server error", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
module tanabata/backend
|
|
||||||
|
|
||||||
go 1.26
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
||||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
|
||||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
|
||||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
||||||
github.com/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=
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds all application configuration loaded from environment variables.
|
|
||||||
type Config struct {
|
|
||||||
// Server
|
|
||||||
ListenAddr string
|
|
||||||
JWTSecret string
|
|
||||||
JWTAccessTTL time.Duration
|
|
||||||
JWTRefreshTTL time.Duration
|
|
||||||
|
|
||||||
// Database
|
|
||||||
DatabaseURL string
|
|
||||||
|
|
||||||
// Storage
|
|
||||||
FilesPath string
|
|
||||||
ThumbsCachePath string
|
|
||||||
|
|
||||||
// Thumbnails
|
|
||||||
ThumbWidth int
|
|
||||||
ThumbHeight int
|
|
||||||
PreviewWidth int
|
|
||||||
PreviewHeight int
|
|
||||||
|
|
||||||
// Import
|
|
||||||
ImportPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load reads a .env file (if present) then loads all configuration from
|
|
||||||
// environment variables. Returns an error listing every missing or invalid var.
|
|
||||||
func Load() (*Config, error) {
|
|
||||||
// Non-fatal: .env may not exist in production.
|
|
||||||
_ = godotenv.Load()
|
|
||||||
|
|
||||||
var errs []error
|
|
||||||
|
|
||||||
requireStr := func(key string) string {
|
|
||||||
v := os.Getenv(key)
|
|
||||||
if v == "" {
|
|
||||||
errs = append(errs, fmt.Errorf("%s is required", key))
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultStr := func(key, def string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
parseDuration := func(key, def string) time.Duration {
|
|
||||||
raw := defaultStr(key, def)
|
|
||||||
d, err := time.ParseDuration(raw)
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("%s: invalid duration %q: %w", key, raw, err))
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
parseInt := func(key string, def int) int {
|
|
||||||
raw := os.Getenv(key)
|
|
||||||
if raw == "" {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(raw)
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("%s: invalid integer %q: %w", key, raw, err))
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &Config{
|
|
||||||
ListenAddr: defaultStr("LISTEN_ADDR", ":8080"),
|
|
||||||
JWTSecret: requireStr("JWT_SECRET"),
|
|
||||||
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
|
|
||||||
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
|
|
||||||
|
|
||||||
DatabaseURL: requireStr("DATABASE_URL"),
|
|
||||||
|
|
||||||
FilesPath: requireStr("FILES_PATH"),
|
|
||||||
ThumbsCachePath: requireStr("THUMBS_CACHE_PATH"),
|
|
||||||
|
|
||||||
ThumbWidth: parseInt("THUMB_WIDTH", 160),
|
|
||||||
ThumbHeight: parseInt("THUMB_HEIGHT", 160),
|
|
||||||
PreviewWidth: parseInt("PREVIEW_WIDTH", 1920),
|
|
||||||
PreviewHeight: parseInt("PREVIEW_HEIGHT", 1080),
|
|
||||||
|
|
||||||
ImportPath: requireStr("IMPORT_PATH"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(errs) > 0 {
|
|
||||||
return nil, errors.Join(errs...)
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// Package db provides shared helpers used by all database adapters.
|
|
||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
)
|
|
||||||
|
|
||||||
// txKey is the context key used to store an active transaction.
|
|
||||||
type txKey struct{}
|
|
||||||
|
|
||||||
// TxFromContext returns the pgx.Tx stored in ctx by the Transactor, along
|
|
||||||
// with a boolean indicating whether a transaction is active.
|
|
||||||
func TxFromContext(ctx context.Context) (pgx.Tx, bool) {
|
|
||||||
tx, ok := ctx.Value(txKey{}).(pgx.Tx)
|
|
||||||
return tx, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContextWithTx returns a copy of ctx that carries tx.
|
|
||||||
// Called by the Transactor before invoking the user function.
|
|
||||||
func ContextWithTx(ctx context.Context, tx pgx.Tx) context.Context {
|
|
||||||
return context.WithValue(ctx, txKey{}, tx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Querier is the common query interface satisfied by both *pgxpool.Pool and
|
|
||||||
// pgx.Tx, allowing repo helpers to work with either.
|
|
||||||
type Querier interface {
|
|
||||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
|
||||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
|
||||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScanRow executes a single-row query against q and scans the result using
|
|
||||||
// scan. It wraps pgx.ErrNoRows so callers can detect missing rows without
|
|
||||||
// importing pgx directly.
|
|
||||||
func ScanRow[T any](ctx context.Context, q Querier, sql string, args []any, scan func(pgx.Row) (T, error)) (T, error) {
|
|
||||||
row := q.QueryRow(ctx, sql, args...)
|
|
||||||
val, err := scan(row)
|
|
||||||
if err != nil {
|
|
||||||
var zero T
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return zero, fmt.Errorf("%w", pgx.ErrNoRows)
|
|
||||||
}
|
|
||||||
return zero, fmt.Errorf("ScanRow: %w", err)
|
|
||||||
}
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClampLimit enforces the [1, max] range on limit, returning def when limit
|
|
||||||
// is zero or negative.
|
|
||||||
func ClampLimit(limit, def, max int) int {
|
|
||||||
if limit <= 0 {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
if limit > max {
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
return limit
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClampOffset returns 0 for negative offsets.
|
|
||||||
func ClampOffset(offset int) int {
|
|
||||||
if offset < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return offset
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mimeRow struct {
|
|
||||||
ID int16 `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Extension string `db:"extension"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toMIMEType(r mimeRow) domain.MIMEType {
|
|
||||||
return domain.MIMEType{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Extension: r.Extension,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MimeRepo implements port.MimeRepo using PostgreSQL.
|
|
||||||
type MimeRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMimeRepo creates a MimeRepo backed by pool.
|
|
||||||
func NewMimeRepo(pool *pgxpool.Pool) *MimeRepo {
|
|
||||||
return &MimeRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.MimeRepo = (*MimeRepo)(nil)
|
|
||||||
|
|
||||||
func (r *MimeRepo) List(ctx context.Context) ([]domain.MIMEType, error) {
|
|
||||||
const sql = `SELECT id, name, extension FROM core.mime_types ORDER BY name`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MimeRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[mimeRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MimeRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]domain.MIMEType, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
result[i] = toMIMEType(row)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *MimeRepo) GetByID(ctx context.Context, id int16) (*domain.MIMEType, error) {
|
|
||||||
const sql = `SELECT id, name, extension FROM core.mime_types WHERE id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MimeRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[mimeRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("MimeRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
m := toMIMEType(row)
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *MimeRepo) GetByName(ctx context.Context, name string) (*domain.MIMEType, error) {
|
|
||||||
const sql = `SELECT id, name, extension FROM core.mime_types WHERE name = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MimeRepo.GetByName: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[mimeRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrUnsupportedMIME
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("MimeRepo.GetByName scan: %w", err)
|
|
||||||
}
|
|
||||||
m := toMIMEType(row)
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
// Package postgres provides the PostgreSQL implementations of the port interfaces.
|
|
||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewPool creates and validates a *pgxpool.Pool from the given connection URL.
|
|
||||||
// The pool is ready to use; the caller is responsible for closing it.
|
|
||||||
func NewPool(ctx context.Context, url string) (*pgxpool.Pool, error) {
|
|
||||||
pool, err := pgxpool.New(ctx, url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("pgxpool.New: %w", err)
|
|
||||||
}
|
|
||||||
if err := pool.Ping(ctx); err != nil {
|
|
||||||
pool.Close()
|
|
||||||
return nil, fmt.Errorf("postgres ping: %w", err)
|
|
||||||
}
|
|
||||||
return pool, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transactor implements port.Transactor using a pgxpool.Pool.
|
|
||||||
type Transactor struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTransactor creates a Transactor backed by pool.
|
|
||||||
func NewTransactor(pool *pgxpool.Pool) *Transactor {
|
|
||||||
return &Transactor{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithTx begins a transaction, stores it in ctx, and calls fn. If fn returns
|
|
||||||
// an error the transaction is rolled back; otherwise it is committed.
|
|
||||||
func (t *Transactor) WithTx(ctx context.Context, fn func(ctx context.Context) error) error {
|
|
||||||
tx, err := t.pool.BeginTx(ctx, pgx.TxOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("begin tx: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
txCtx := db.ContextWithTx(ctx, tx)
|
|
||||||
|
|
||||||
if err := fn(txCtx); err != nil {
|
|
||||||
_ = tx.Rollback(ctx)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return fmt.Errorf("commit tx: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// connOrTx returns the pgx.Tx stored in ctx by WithTx, or the pool itself when
|
|
||||||
// no transaction is active. The returned value satisfies db.Querier and can be
|
|
||||||
// used directly for queries and commands.
|
|
||||||
func connOrTx(ctx context.Context, pool *pgxpool.Pool) db.Querier {
|
|
||||||
if tx, ok := db.TxFromContext(ctx); ok {
|
|
||||||
return tx
|
|
||||||
}
|
|
||||||
return pool
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// sessionRow matches the columns stored in activity.sessions.
|
|
||||||
// IsCurrent is a service-layer concern and is not stored in the database.
|
|
||||||
type sessionRow struct {
|
|
||||||
ID int `db:"id"`
|
|
||||||
TokenHash string `db:"token_hash"`
|
|
||||||
UserID int16 `db:"user_id"`
|
|
||||||
UserAgent string `db:"user_agent"`
|
|
||||||
StartedAt time.Time `db:"started_at"`
|
|
||||||
ExpiresAt *time.Time `db:"expires_at"`
|
|
||||||
LastActivity time.Time `db:"last_activity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionRowWithTotal extends sessionRow with a window-function count for ListByUser.
|
|
||||||
type sessionRowWithTotal struct {
|
|
||||||
sessionRow
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toSession(r sessionRow) domain.Session {
|
|
||||||
return domain.Session{
|
|
||||||
ID: r.ID,
|
|
||||||
TokenHash: r.TokenHash,
|
|
||||||
UserID: r.UserID,
|
|
||||||
UserAgent: r.UserAgent,
|
|
||||||
StartedAt: r.StartedAt,
|
|
||||||
ExpiresAt: r.ExpiresAt,
|
|
||||||
LastActivity: r.LastActivity,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionRepo implements port.SessionRepo using PostgreSQL.
|
|
||||||
type SessionRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSessionRepo creates a SessionRepo backed by pool.
|
|
||||||
func NewSessionRepo(pool *pgxpool.Pool) *SessionRepo {
|
|
||||||
return &SessionRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.SessionRepo = (*SessionRepo)(nil)
|
|
||||||
|
|
||||||
func (r *SessionRepo) Create(ctx context.Context, s *domain.Session) (*domain.Session, error) {
|
|
||||||
const sql = `
|
|
||||||
INSERT INTO activity.sessions (token_hash, user_id, user_agent, expires_at)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, token_hash, user_id, user_agent, started_at, expires_at, last_activity`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, s.TokenHash, s.UserID, s.UserAgent, s.ExpiresAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toSession(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity
|
|
||||||
FROM activity.sessions
|
|
||||||
WHERE token_hash = $1 AND is_active = true`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, hash)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.GetByTokenHash: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("SessionRepo.GetByTokenHash scan: %w", err)
|
|
||||||
}
|
|
||||||
s := toSession(row)
|
|
||||||
return &s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM activity.sessions
|
|
||||||
WHERE user_id = $1 AND is_active = true
|
|
||||||
ORDER BY started_at DESC`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.ListByUser: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[sessionRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.ListByUser scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
list := &domain.SessionList{}
|
|
||||||
if len(collected) > 0 {
|
|
||||||
list.Total = collected[0].Total
|
|
||||||
}
|
|
||||||
list.Items = make([]domain.Session, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
list.Items[i] = toSession(row.sessionRow)
|
|
||||||
}
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) UpdateLastActivity(ctx context.Context, id int, t time.Time) error {
|
|
||||||
const sql = `UPDATE activity.sessions SET last_activity = $2 WHERE id = $1`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sql, id, t)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("SessionRepo.UpdateLastActivity: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) Delete(ctx context.Context, id int) error {
|
|
||||||
const sql = `UPDATE activity.sessions SET is_active = false WHERE id = $1 AND is_active = true`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sql, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("SessionRepo.Delete: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) DeleteByUserID(ctx context.Context, userID int16) error {
|
|
||||||
const sql = `UPDATE activity.sessions SET is_active = false WHERE user_id = $1 AND is_active = true`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
_, err := q.Exec(ctx, sql, userID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("SessionRepo.DeleteByUserID: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// userRow matches the columns returned by every user SELECT.
|
|
||||||
type userRow struct {
|
|
||||||
ID int16 `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Password string `db:"password"`
|
|
||||||
IsAdmin bool `db:"is_admin"`
|
|
||||||
CanCreate bool `db:"can_create"`
|
|
||||||
IsBlocked bool `db:"is_blocked"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// userRowWithTotal extends userRow with a window-function total for List.
|
|
||||||
type userRowWithTotal struct {
|
|
||||||
userRow
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toUser(r userRow) domain.User {
|
|
||||||
return domain.User{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Password: r.Password,
|
|
||||||
IsAdmin: r.IsAdmin,
|
|
||||||
CanCreate: r.CanCreate,
|
|
||||||
IsBlocked: r.IsBlocked,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// userSortColumn whitelists valid sort keys to prevent SQL injection.
|
|
||||||
var userSortColumn = map[string]string{
|
|
||||||
"name": "name",
|
|
||||||
"id": "id",
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserRepo implements port.UserRepo using PostgreSQL.
|
|
||||||
type UserRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUserRepo creates a UserRepo backed by pool.
|
|
||||||
func NewUserRepo(pool *pgxpool.Pool) *UserRepo {
|
|
||||||
return &UserRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.UserRepo = (*UserRepo)(nil)
|
|
||||||
|
|
||||||
func (r *UserRepo) List(ctx context.Context, params port.OffsetParams) (*domain.UserPage, error) {
|
|
||||||
col, ok := userSortColumn[params.Sort]
|
|
||||||
if !ok {
|
|
||||||
col = "id"
|
|
||||||
}
|
|
||||||
ord := "ASC"
|
|
||||||
if params.Order == "desc" {
|
|
||||||
ord = "DESC"
|
|
||||||
}
|
|
||||||
limit := db.ClampLimit(params.Limit, 50, 200)
|
|
||||||
offset := db.ClampOffset(params.Offset)
|
|
||||||
|
|
||||||
sql := fmt.Sprintf(`
|
|
||||||
SELECT id, name, password, is_admin, can_create, is_blocked,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM core.users
|
|
||||||
ORDER BY %s %s
|
|
||||||
LIMIT $1 OFFSET $2`, col, ord)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[userRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
page := &domain.UserPage{Offset: offset, Limit: limit}
|
|
||||||
if len(collected) > 0 {
|
|
||||||
page.Total = collected[0].Total
|
|
||||||
}
|
|
||||||
page.Items = make([]domain.User, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
page.Items[i] = toUser(row.userRow)
|
|
||||||
}
|
|
||||||
return page, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) GetByID(ctx context.Context, id int16) (*domain.User, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, name, password, is_admin, can_create, is_blocked
|
|
||||||
FROM core.users WHERE id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("UserRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
u := toUser(row)
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) GetByName(ctx context.Context, name string) (*domain.User, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, name, password, is_admin, can_create, is_blocked
|
|
||||||
FROM core.users WHERE name = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.GetByName: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("UserRepo.GetByName scan: %w", err)
|
|
||||||
}
|
|
||||||
u := toUser(row)
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) Create(ctx context.Context, u *domain.User) (*domain.User, error) {
|
|
||||||
const sql = `
|
|
||||||
INSERT INTO core.users (name, password, is_admin, can_create)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, name, password, is_admin, can_create, is_blocked`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, u.Name, u.Password, u.IsAdmin, u.CanCreate)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toUser(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) Update(ctx context.Context, id int16, u *domain.User) (*domain.User, error) {
|
|
||||||
const sql = `
|
|
||||||
UPDATE core.users
|
|
||||||
SET name = $2, password = $3, is_admin = $4, can_create = $5
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, name, password, is_admin, can_create, is_blocked`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, id, u.Name, u.Password, u.IsAdmin, u.CanCreate)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.Update: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("UserRepo.Update scan: %w", err)
|
|
||||||
}
|
|
||||||
updated := toUser(row)
|
|
||||||
return &updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) Delete(ctx context.Context, id int16) error {
|
|
||||||
const sql = `DELETE FROM core.users WHERE id = $1`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sql, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("UserRepo.Delete: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "github.com/google/uuid"
|
|
||||||
|
|
||||||
// ObjectType is a reference entity (file, tag, category, pool).
|
|
||||||
type ObjectType struct {
|
|
||||||
ID int16
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permission represents a per-object access entry for a user.
|
|
||||||
type Permission struct {
|
|
||||||
UserID int16
|
|
||||||
UserName string // denormalized
|
|
||||||
ObjectTypeID int16
|
|
||||||
ObjectID uuid.UUID
|
|
||||||
CanView bool
|
|
||||||
CanEdit bool
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ActionType is a reference entity for auditable user actions.
|
|
||||||
type ActionType struct {
|
|
||||||
ID int16
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditEntry is a single audit log record.
|
|
||||||
type AuditEntry struct {
|
|
||||||
ID int64
|
|
||||||
UserID int16
|
|
||||||
UserName string // denormalized
|
|
||||||
Action string // action type name, e.g. "file_create"
|
|
||||||
ObjectType *string
|
|
||||||
ObjectID *uuid.UUID
|
|
||||||
Details json.RawMessage
|
|
||||||
PerformedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditPage is an offset-based page of audit log entries.
|
|
||||||
type AuditPage struct {
|
|
||||||
Items []AuditEntry
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditFilter holds filter parameters for querying the audit log.
|
|
||||||
type AuditFilter struct {
|
|
||||||
UserID *int16
|
|
||||||
Action string
|
|
||||||
ObjectType string
|
|
||||||
ObjectID *uuid.UUID
|
|
||||||
From *time.Time
|
|
||||||
To *time.Time
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Category is a logical grouping of tags.
|
|
||||||
type Category struct {
|
|
||||||
ID uuid.UUID
|
|
||||||
Name string
|
|
||||||
Notes *string
|
|
||||||
Color *string // 6-char hex
|
|
||||||
Metadata json.RawMessage
|
|
||||||
CreatorID int16
|
|
||||||
CreatorName string // denormalized
|
|
||||||
IsPublic bool
|
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// CategoryOffsetPage is an offset-based page of categories.
|
|
||||||
type CategoryOffsetPage struct {
|
|
||||||
Items []Category
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
type ctxKey int
|
|
||||||
|
|
||||||
const userKey ctxKey = iota
|
|
||||||
|
|
||||||
type contextUser struct {
|
|
||||||
ID int16
|
|
||||||
IsAdmin bool
|
|
||||||
SessionID int
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithUser stores user identity and current session ID in ctx.
|
|
||||||
func WithUser(ctx context.Context, userID int16, isAdmin bool, sessionID int) context.Context {
|
|
||||||
return context.WithValue(ctx, userKey, contextUser{
|
|
||||||
ID: userID,
|
|
||||||
IsAdmin: isAdmin,
|
|
||||||
SessionID: sessionID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserFromContext retrieves user identity from ctx.
|
|
||||||
// Returns zero values if no user is stored.
|
|
||||||
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool, sessionID int) {
|
|
||||||
u, ok := ctx.Value(userKey).(contextUser)
|
|
||||||
if !ok {
|
|
||||||
return 0, false, 0
|
|
||||||
}
|
|
||||||
return u.ID, u.IsAdmin, u.SessionID
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
ErrNotFound = &DomainError{"not_found", "not found"}
|
|
||||||
ErrForbidden = &DomainError{"forbidden", "forbidden"}
|
|
||||||
ErrUnauthorized = &DomainError{"unauthorized", "unauthorized"}
|
|
||||||
ErrConflict = &DomainError{"conflict", "conflict"}
|
|
||||||
ErrValidation = &DomainError{"validation_error", "validation error"}
|
|
||||||
ErrUnsupportedMIME = &DomainError{"unsupported_mime", "unsupported MIME type"}
|
|
||||||
)
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MIMEType holds MIME whitelist data.
|
|
||||||
type MIMEType struct {
|
|
||||||
ID int16
|
|
||||||
Name string
|
|
||||||
Extension string
|
|
||||||
}
|
|
||||||
|
|
||||||
// File represents a managed file record.
|
|
||||||
type File struct {
|
|
||||||
ID uuid.UUID
|
|
||||||
OriginalName *string
|
|
||||||
MIMEType string // denormalized from core.mime_types
|
|
||||||
MIMEExtension string // denormalized from core.mime_types
|
|
||||||
ContentDatetime time.Time
|
|
||||||
Notes *string
|
|
||||||
Metadata json.RawMessage
|
|
||||||
EXIF json.RawMessage
|
|
||||||
PHash *int64
|
|
||||||
CreatorID int16
|
|
||||||
CreatorName string // denormalized from core.users
|
|
||||||
IsPublic bool
|
|
||||||
IsDeleted bool
|
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
|
||||||
Tags []Tag // loaded with the file
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileListParams holds all parameters for listing/filtering files.
|
|
||||||
type FileListParams struct {
|
|
||||||
// Pagination
|
|
||||||
Cursor string
|
|
||||||
Direction string // "forward" or "backward"
|
|
||||||
Anchor *uuid.UUID
|
|
||||||
Limit int
|
|
||||||
|
|
||||||
// Sorting
|
|
||||||
Sort string // "content_datetime" | "created" | "original_name" | "mime"
|
|
||||||
Order string // "asc" | "desc"
|
|
||||||
|
|
||||||
// Filtering
|
|
||||||
Filter string // filter DSL expression
|
|
||||||
Search string // substring match on original_name
|
|
||||||
Trash bool // if true, return only soft-deleted files
|
|
||||||
}
|
|
||||||
|
|
||||||
// FilePage is the result of a cursor-based file listing.
|
|
||||||
type FilePage struct {
|
|
||||||
Items []File
|
|
||||||
NextCursor *string
|
|
||||||
PrevCursor *string
|
|
||||||
}
|
|
||||||
|
|
||||||
// UUIDCreatedAt extracts the creation timestamp embedded in a UUID v7.
|
|
||||||
// UUID v7 stores Unix milliseconds in the most-significant 48 bits.
|
|
||||||
func UUIDCreatedAt(id uuid.UUID) time.Time {
|
|
||||||
ms := int64(id[0])<<40 | int64(id[1])<<32 | int64(id[2])<<24 |
|
|
||||||
int64(id[3])<<16 | int64(id[4])<<8 | int64(id[5])
|
|
||||||
return time.Unix(ms/1000, (ms%1000)*int64(time.Millisecond)).UTC()
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pool is an ordered collection of files.
|
|
||||||
type Pool struct {
|
|
||||||
ID uuid.UUID
|
|
||||||
Name string
|
|
||||||
Notes *string
|
|
||||||
Metadata json.RawMessage
|
|
||||||
CreatorID int16
|
|
||||||
CreatorName string // denormalized
|
|
||||||
IsPublic bool
|
|
||||||
FileCount int
|
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolFile is a File with its ordering position within a pool.
|
|
||||||
type PoolFile struct {
|
|
||||||
File
|
|
||||||
Position int
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolFilePage is the result of a cursor-based pool file listing.
|
|
||||||
type PoolFilePage struct {
|
|
||||||
Items []PoolFile
|
|
||||||
NextCursor *string
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolOffsetPage is an offset-based page of pools.
|
|
||||||
type PoolOffsetPage struct {
|
|
||||||
Items []Pool
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tag represents a file label.
|
|
||||||
type Tag struct {
|
|
||||||
ID uuid.UUID
|
|
||||||
Name string
|
|
||||||
Notes *string
|
|
||||||
Color *string // 6-char hex, e.g. "5DCAA5"
|
|
||||||
CategoryID *uuid.UUID
|
|
||||||
CategoryName *string // denormalized
|
|
||||||
CategoryColor *string // denormalized
|
|
||||||
Metadata json.RawMessage
|
|
||||||
CreatorID int16
|
|
||||||
CreatorName string // denormalized
|
|
||||||
IsPublic bool
|
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagRule defines an auto-tagging rule: when WhenTagID is applied,
|
|
||||||
// ThenTagID is automatically applied as well.
|
|
||||||
type TagRule struct {
|
|
||||||
WhenTagID uuid.UUID
|
|
||||||
ThenTagID uuid.UUID
|
|
||||||
ThenTagName string // denormalized
|
|
||||||
IsActive bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagOffsetPage is an offset-based page of tags.
|
|
||||||
type TagOffsetPage struct {
|
|
||||||
Items []Tag
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// User is an application user.
|
|
||||||
type User struct {
|
|
||||||
ID int16
|
|
||||||
Name string
|
|
||||||
Password string // bcrypt hash; only populated when needed for auth
|
|
||||||
IsAdmin bool
|
|
||||||
CanCreate bool
|
|
||||||
IsBlocked bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session is an active user session.
|
|
||||||
type Session struct {
|
|
||||||
ID int
|
|
||||||
TokenHash string
|
|
||||||
UserID int16
|
|
||||||
UserAgent string
|
|
||||||
StartedAt time.Time
|
|
||||||
ExpiresAt *time.Time
|
|
||||||
LastActivity time.Time
|
|
||||||
IsCurrent bool // true when this session matches the caller's token
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserPage is an offset-based page of users.
|
|
||||||
type UserPage struct {
|
|
||||||
Items []User
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionList is a list of sessions with a total count.
|
|
||||||
type SessionList struct {
|
|
||||||
Items []Session
|
|
||||||
Total int
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuthHandler handles all /auth endpoints.
|
|
||||||
type AuthHandler struct {
|
|
||||||
authSvc *service.AuthService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthHandler creates an AuthHandler backed by authSvc.
|
|
||||||
func NewAuthHandler(authSvc *service.AuthService) *AuthHandler {
|
|
||||||
return &AuthHandler{authSvc: authSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login handles POST /auth/login.
|
|
||||||
func (h *AuthHandler) Login(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Password string `json:"password" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pair, err := h.authSvc.Login(c.Request.Context(), req.Name, req.Password, c.GetHeader("User-Agent"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"access_token": pair.AccessToken,
|
|
||||||
"refresh_token": pair.RefreshToken,
|
|
||||||
"expires_in": pair.ExpiresIn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh handles POST /auth/refresh.
|
|
||||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pair, err := h.authSvc.Refresh(c.Request.Context(), req.RefreshToken, c.GetHeader("User-Agent"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"access_token": pair.AccessToken,
|
|
||||||
"refresh_token": pair.RefreshToken,
|
|
||||||
"expires_in": pair.ExpiresIn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout handles POST /auth/logout. Requires authentication.
|
|
||||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
|
||||||
_, _, sessionID := domain.UserFromContext(c.Request.Context())
|
|
||||||
|
|
||||||
if err := h.authSvc.Logout(c.Request.Context(), sessionID); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListSessions handles GET /auth/sessions. Requires authentication.
|
|
||||||
func (h *AuthHandler) ListSessions(c *gin.Context) {
|
|
||||||
userID, _, sessionID := domain.UserFromContext(c.Request.Context())
|
|
||||||
|
|
||||||
list, err := h.authSvc.ListSessions(c.Request.Context(), userID, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type sessionItem struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
UserAgent string `json:"user_agent"`
|
|
||||||
StartedAt string `json:"started_at"`
|
|
||||||
ExpiresAt any `json:"expires_at"`
|
|
||||||
LastActivity string `json:"last_activity"`
|
|
||||||
IsCurrent bool `json:"is_current"`
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]sessionItem, len(list.Items))
|
|
||||||
for i, s := range list.Items {
|
|
||||||
var expiresAt any
|
|
||||||
if s.ExpiresAt != nil {
|
|
||||||
expiresAt = s.ExpiresAt.Format("2006-01-02T15:04:05Z07:00")
|
|
||||||
}
|
|
||||||
items[i] = sessionItem{
|
|
||||||
ID: s.ID,
|
|
||||||
UserAgent: s.UserAgent,
|
|
||||||
StartedAt: s.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
LastActivity: s.LastActivity.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
IsCurrent: s.IsCurrent,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": list.Total,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TerminateSession handles DELETE /auth/sessions/:id. Requires authentication.
|
|
||||||
func (h *AuthHandler) TerminateSession(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := strconv.Atoi(idStr)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
|
|
||||||
|
|
||||||
if err := h.authSvc.TerminateSession(c.Request.Context(), userID, isAdmin, id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuthMiddleware validates Bearer JWTs and injects user identity into context.
|
|
||||||
type AuthMiddleware struct {
|
|
||||||
authSvc *service.AuthService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthMiddleware creates an AuthMiddleware backed by authSvc.
|
|
||||||
func NewAuthMiddleware(authSvc *service.AuthService) *AuthMiddleware {
|
|
||||||
return &AuthMiddleware{authSvc: authSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle returns a Gin handler function that enforces authentication.
|
|
||||||
// On success it calls c.Next(); on failure it aborts with 401 JSON.
|
|
||||||
func (m *AuthMiddleware) Handle() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
raw := c.GetHeader("Authorization")
|
|
||||||
if !strings.HasPrefix(raw, "Bearer ") {
|
|
||||||
c.JSON(http.StatusUnauthorized, errorBody{
|
|
||||||
Code: domain.ErrUnauthorized.Code(),
|
|
||||||
Message: "authorization header missing or malformed",
|
|
||||||
})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token := strings.TrimPrefix(raw, "Bearer ")
|
|
||||||
|
|
||||||
claims, err := m.authSvc.ParseAccessToken(token)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, errorBody{
|
|
||||||
Code: domain.ErrUnauthorized.Code(),
|
|
||||||
Message: "invalid or expired token",
|
|
||||||
})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
|
|
||||||
c.Request = c.Request.WithContext(ctx)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errorBody is the JSON shape returned for all error responses.
|
|
||||||
type errorBody struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func respondJSON(c *gin.Context, status int, data any) {
|
|
||||||
c.JSON(status, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// respondError maps a domain error to the appropriate HTTP status and writes
|
|
||||||
// a JSON error body. Unknown errors become 500.
|
|
||||||
func respondError(c *gin.Context, err error) {
|
|
||||||
var de *domain.DomainError
|
|
||||||
if errors.As(err, &de) {
|
|
||||||
c.JSON(domainStatus(de), errorBody{Code: de.Code(), Message: de.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, errorBody{
|
|
||||||
Code: "internal_error",
|
|
||||||
Message: "internal server error",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// domainStatus maps a DomainError sentinel to its HTTP status code per the
|
|
||||||
// error mapping table in docs/GO_PROJECT_STRUCTURE.md.
|
|
||||||
func domainStatus(de *domain.DomainError) int {
|
|
||||||
switch de {
|
|
||||||
case domain.ErrNotFound:
|
|
||||||
return http.StatusNotFound
|
|
||||||
case domain.ErrForbidden:
|
|
||||||
return http.StatusForbidden
|
|
||||||
case domain.ErrUnauthorized:
|
|
||||||
return http.StatusUnauthorized
|
|
||||||
case domain.ErrConflict:
|
|
||||||
return http.StatusConflict
|
|
||||||
case domain.ErrValidation:
|
|
||||||
return http.StatusBadRequest
|
|
||||||
case domain.ErrUnsupportedMIME:
|
|
||||||
return http.StatusUnsupportedMediaType
|
|
||||||
default:
|
|
||||||
return http.StatusInternalServerError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewRouter builds and returns a configured Gin engine.
|
|
||||||
// Additional handlers will be added here as they are implemented.
|
|
||||||
func NewRouter(auth *AuthMiddleware, authHandler *AuthHandler) *gin.Engine {
|
|
||||||
r := gin.New()
|
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
|
||||||
|
|
||||||
// Health check — no auth required.
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
v1 := r.Group("/api/v1")
|
|
||||||
|
|
||||||
// Auth endpoints — login and refresh are public; others require a valid token.
|
|
||||||
authGroup := v1.Group("/auth")
|
|
||||||
{
|
|
||||||
authGroup.POST("/login", authHandler.Login)
|
|
||||||
authGroup.POST("/refresh", authHandler.Refresh)
|
|
||||||
|
|
||||||
protected := authGroup.Group("", auth.Handle())
|
|
||||||
{
|
|
||||||
protected.POST("/logout", authHandler.Logout)
|
|
||||||
protected.GET("/sessions", authHandler.ListSessions)
|
|
||||||
protected.DELETE("/sessions/:id", authHandler.TerminateSession)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
package port
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Transactor executes fn inside a single database transaction.
|
|
||||||
// All repository calls made within fn receive the transaction via context.
|
|
||||||
type Transactor interface {
|
|
||||||
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// OffsetParams holds common offset-pagination and sort parameters.
|
|
||||||
type OffsetParams struct {
|
|
||||||
Sort string
|
|
||||||
Order string // "asc" | "desc"
|
|
||||||
Search string
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolFileListParams holds parameters for listing files inside a pool.
|
|
||||||
type PoolFileListParams struct {
|
|
||||||
Cursor string
|
|
||||||
Limit int
|
|
||||||
Filter string // filter DSL expression
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileRepo is the persistence interface for file records.
|
|
||||||
type FileRepo interface {
|
|
||||||
// List returns a cursor-based page of files.
|
|
||||||
List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error)
|
|
||||||
// GetByID returns the file with its tags loaded.
|
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.File, error)
|
|
||||||
// Create inserts a new file record and returns it.
|
|
||||||
Create(ctx context.Context, f *domain.File) (*domain.File, error)
|
|
||||||
// Update applies partial metadata changes and returns the updated record.
|
|
||||||
Update(ctx context.Context, id uuid.UUID, f *domain.File) (*domain.File, error)
|
|
||||||
// SoftDelete moves a file to trash (sets is_deleted = true).
|
|
||||||
SoftDelete(ctx context.Context, id uuid.UUID) error
|
|
||||||
// Restore moves a file out of trash (sets is_deleted = false).
|
|
||||||
Restore(ctx context.Context, id uuid.UUID) (*domain.File, error)
|
|
||||||
// DeletePermanent removes a file record. Only allowed when is_deleted = true.
|
|
||||||
DeletePermanent(ctx context.Context, id uuid.UUID) error
|
|
||||||
|
|
||||||
// ListTags returns all tags assigned to a file.
|
|
||||||
ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error)
|
|
||||||
// SetTags replaces all tags on a file (full replace semantics).
|
|
||||||
SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagRepo is the persistence interface for tags.
|
|
||||||
type TagRepo interface {
|
|
||||||
List(ctx context.Context, params OffsetParams) (*domain.TagOffsetPage, error)
|
|
||||||
// ListByCategory returns tags belonging to a specific category.
|
|
||||||
ListByCategory(ctx context.Context, categoryID uuid.UUID, params OffsetParams) (*domain.TagOffsetPage, error)
|
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.Tag, error)
|
|
||||||
Create(ctx context.Context, t *domain.Tag) (*domain.Tag, error)
|
|
||||||
Update(ctx context.Context, id uuid.UUID, t *domain.Tag) (*domain.Tag, error)
|
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagRuleRepo is the persistence interface for auto-tag rules.
|
|
||||||
type TagRuleRepo interface {
|
|
||||||
// ListByTag returns all rules where WhenTagID == tagID.
|
|
||||||
ListByTag(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error)
|
|
||||||
Create(ctx context.Context, r domain.TagRule) (*domain.TagRule, error)
|
|
||||||
// SetActive toggles a rule's is_active flag.
|
|
||||||
SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active bool) error
|
|
||||||
Delete(ctx context.Context, whenTagID, thenTagID uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// CategoryRepo is the persistence interface for categories.
|
|
||||||
type CategoryRepo interface {
|
|
||||||
List(ctx context.Context, params OffsetParams) (*domain.CategoryOffsetPage, error)
|
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.Category, error)
|
|
||||||
Create(ctx context.Context, c *domain.Category) (*domain.Category, error)
|
|
||||||
Update(ctx context.Context, id uuid.UUID, c *domain.Category) (*domain.Category, error)
|
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolRepo is the persistence interface for pools and pool–file membership.
|
|
||||||
type PoolRepo interface {
|
|
||||||
List(ctx context.Context, params OffsetParams) (*domain.PoolOffsetPage, error)
|
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.Pool, error)
|
|
||||||
Create(ctx context.Context, p *domain.Pool) (*domain.Pool, error)
|
|
||||||
Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*domain.Pool, error)
|
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
|
||||||
|
|
||||||
// ListFiles returns pool files ordered by position (cursor-based).
|
|
||||||
ListFiles(ctx context.Context, poolID uuid.UUID, params PoolFileListParams) (*domain.PoolFilePage, error)
|
|
||||||
// AddFiles appends files starting at position; nil position means append at end.
|
|
||||||
AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error
|
|
||||||
// RemoveFiles removes files from the pool.
|
|
||||||
RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
|
||||||
// Reorder sets the full ordered sequence of file IDs in the pool.
|
|
||||||
Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserRepo is the persistence interface for users.
|
|
||||||
type UserRepo interface {
|
|
||||||
List(ctx context.Context, params OffsetParams) (*domain.UserPage, error)
|
|
||||||
GetByID(ctx context.Context, id int16) (*domain.User, error)
|
|
||||||
// GetByName is used during login to look up credentials.
|
|
||||||
GetByName(ctx context.Context, name string) (*domain.User, error)
|
|
||||||
Create(ctx context.Context, u *domain.User) (*domain.User, error)
|
|
||||||
Update(ctx context.Context, id int16, u *domain.User) (*domain.User, error)
|
|
||||||
Delete(ctx context.Context, id int16) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionRepo is the persistence interface for auth sessions.
|
|
||||||
type SessionRepo interface {
|
|
||||||
// ListByUser returns all active sessions for a user.
|
|
||||||
ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error)
|
|
||||||
// GetByTokenHash looks up a session by the hashed refresh token.
|
|
||||||
GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error)
|
|
||||||
Create(ctx context.Context, s *domain.Session) (*domain.Session, error)
|
|
||||||
// UpdateLastActivity refreshes the last_activity timestamp.
|
|
||||||
UpdateLastActivity(ctx context.Context, id int, t time.Time) error
|
|
||||||
// Delete terminates a single session.
|
|
||||||
Delete(ctx context.Context, id int) error
|
|
||||||
// DeleteByUserID terminates all sessions for a user (logout everywhere).
|
|
||||||
DeleteByUserID(ctx context.Context, userID int16) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACLRepo is the persistence interface for per-object permissions.
|
|
||||||
type ACLRepo interface {
|
|
||||||
// List returns all permission entries for a given object.
|
|
||||||
List(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error)
|
|
||||||
// Get returns the permission entry for a specific user and object; returns
|
|
||||||
// ErrNotFound if no entry exists.
|
|
||||||
Get(ctx context.Context, userID int16, objectTypeID int16, objectID uuid.UUID) (*domain.Permission, error)
|
|
||||||
// Set replaces all permissions for an object (full replace semantics).
|
|
||||||
Set(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditRepo is the persistence interface for the audit log.
|
|
||||||
type AuditRepo interface {
|
|
||||||
Log(ctx context.Context, entry domain.AuditEntry) error
|
|
||||||
List(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MimeRepo is the persistence interface for the MIME type whitelist.
|
|
||||||
type MimeRepo interface {
|
|
||||||
// List returns all supported MIME types.
|
|
||||||
List(ctx context.Context) ([]domain.MIMEType, error)
|
|
||||||
// GetByName returns the MIME type record for a given MIME name (e.g. "image/jpeg").
|
|
||||||
// Returns ErrUnsupportedMIME if not in the whitelist.
|
|
||||||
GetByName(ctx context.Context, name string) (*domain.MIMEType, error)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package port
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileStorage abstracts disk (or object-store) operations for file content,
|
|
||||||
// thumbnails, and previews.
|
|
||||||
type FileStorage interface {
|
|
||||||
// Save writes the reader's content to storage and returns the number of
|
|
||||||
// bytes written. 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)
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Claims is the JWT payload for both access and refresh tokens.
|
|
||||||
type Claims struct {
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
UserID int16 `json:"uid"`
|
|
||||||
IsAdmin bool `json:"adm"`
|
|
||||||
SessionID int `json:"sid"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenPair holds an issued access/refresh token pair with the access TTL.
|
|
||||||
type TokenPair struct {
|
|
||||||
AccessToken string
|
|
||||||
RefreshToken string
|
|
||||||
ExpiresIn int // access token TTL in seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthService handles authentication and session lifecycle.
|
|
||||||
type AuthService struct {
|
|
||||||
users port.UserRepo
|
|
||||||
sessions port.SessionRepo
|
|
||||||
secret []byte
|
|
||||||
accessTTL time.Duration
|
|
||||||
refreshTTL time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthService creates an AuthService.
|
|
||||||
func NewAuthService(
|
|
||||||
users port.UserRepo,
|
|
||||||
sessions port.SessionRepo,
|
|
||||||
jwtSecret string,
|
|
||||||
accessTTL time.Duration,
|
|
||||||
refreshTTL time.Duration,
|
|
||||||
) *AuthService {
|
|
||||||
return &AuthService{
|
|
||||||
users: users,
|
|
||||||
sessions: sessions,
|
|
||||||
secret: []byte(jwtSecret),
|
|
||||||
accessTTL: accessTTL,
|
|
||||||
refreshTTL: refreshTTL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login validates credentials, creates a session, and returns a token pair.
|
|
||||||
func (s *AuthService) Login(ctx context.Context, name, password, userAgent string) (*TokenPair, error) {
|
|
||||||
user, err := s.users.GetByName(ctx, name)
|
|
||||||
if err != nil {
|
|
||||||
// Return ErrUnauthorized regardless of whether the user exists,
|
|
||||||
// to avoid username enumeration.
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.IsBlocked {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
var expiresAt *time.Time
|
|
||||||
if s.refreshTTL > 0 {
|
|
||||||
t := time.Now().Add(s.refreshTTL)
|
|
||||||
expiresAt = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue the refresh token first so we can store its hash.
|
|
||||||
refreshToken, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := s.sessions.Create(ctx, &domain.Session{
|
|
||||||
TokenHash: hashToken(refreshToken),
|
|
||||||
UserID: user.ID,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := s.issueToken(user.ID, user.IsAdmin, session.ID, s.accessTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue access token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-issue the refresh token with the real session ID now that we have it.
|
|
||||||
refreshToken, err = s.issueToken(user.ID, user.IsAdmin, session.ID, s.refreshTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TokenPair{
|
|
||||||
AccessToken: accessToken,
|
|
||||||
RefreshToken: refreshToken,
|
|
||||||
ExpiresIn: int(s.accessTTL.Seconds()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout deactivates the session identified by sessionID.
|
|
||||||
func (s *AuthService) Logout(ctx context.Context, sessionID int) error {
|
|
||||||
if err := s.sessions.Delete(ctx, sessionID); err != nil {
|
|
||||||
return fmt.Errorf("logout: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh validates a refresh token, issues a new token pair, and deactivates
|
|
||||||
// the old session.
|
|
||||||
func (s *AuthService) Refresh(ctx context.Context, refreshToken, userAgent string) (*TokenPair, error) {
|
|
||||||
claims, err := s.parseToken(refreshToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := s.sessions.GetByTokenHash(ctx, hashToken(refreshToken))
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
if session.ExpiresAt != nil && time.Now().After(*session.ExpiresAt) {
|
|
||||||
_ = s.sessions.Delete(ctx, session.ID)
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate: deactivate old session.
|
|
||||||
if err := s.sessions.Delete(ctx, session.ID); err != nil {
|
|
||||||
return nil, fmt.Errorf("deactivate old session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.users.GetByID(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.IsBlocked {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
var expiresAt *time.Time
|
|
||||||
if s.refreshTTL > 0 {
|
|
||||||
t := time.Now().Add(s.refreshTTL)
|
|
||||||
expiresAt = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
newRefresh, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
newSession, err := s.sessions.Create(ctx, &domain.Session{
|
|
||||||
TokenHash: hashToken(newRefresh),
|
|
||||||
UserID: user.ID,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := s.issueToken(user.ID, user.IsAdmin, newSession.ID, s.accessTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue access token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
newRefresh, err = s.issueToken(user.ID, user.IsAdmin, newSession.ID, s.refreshTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TokenPair{
|
|
||||||
AccessToken: accessToken,
|
|
||||||
RefreshToken: newRefresh,
|
|
||||||
ExpiresIn: int(s.accessTTL.Seconds()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListSessions returns all active sessions for the given user.
|
|
||||||
func (s *AuthService) ListSessions(ctx context.Context, userID int16, currentSessionID int) (*domain.SessionList, error) {
|
|
||||||
list, err := s.sessions.ListByUser(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list sessions: %w", err)
|
|
||||||
}
|
|
||||||
for i := range list.Items {
|
|
||||||
list.Items[i].IsCurrent = list.Items[i].ID == currentSessionID
|
|
||||||
}
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TerminateSession deactivates a specific session, enforcing ownership.
|
|
||||||
func (s *AuthService) TerminateSession(ctx context.Context, callerID int16, isAdmin bool, sessionID int) error {
|
|
||||||
if !isAdmin {
|
|
||||||
list, err := s.sessions.ListByUser(ctx, callerID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("terminate session: %w", err)
|
|
||||||
}
|
|
||||||
owned := false
|
|
||||||
for _, sess := range list.Items {
|
|
||||||
if sess.ID == sessionID {
|
|
||||||
owned = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !owned {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.sessions.Delete(ctx, sessionID); err != nil {
|
|
||||||
return fmt.Errorf("terminate session: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseAccessToken parses and validates an access token, returning its claims.
|
|
||||||
func (s *AuthService) ParseAccessToken(tokenStr string) (*Claims, error) {
|
|
||||||
claims, err := s.parseToken(tokenStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// issueToken signs a JWT with the given parameters.
|
|
||||||
func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl time.Duration) (string, error) {
|
|
||||||
now := time.Now()
|
|
||||||
claims := Claims{
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
|
||||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
|
||||||
},
|
|
||||||
UserID: userID,
|
|
||||||
IsAdmin: isAdmin,
|
|
||||||
SessionID: sessionID,
|
|
||||||
}
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
signed, err := token.SignedString(s.secret)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("sign token: %w", err)
|
|
||||||
}
|
|
||||||
return signed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseToken verifies the signature and parses claims from a token string.
|
|
||||||
func (s *AuthService) parseToken(tokenStr string) (*Claims, error) {
|
|
||||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
|
||||||
}
|
|
||||||
return s.secret, nil
|
|
||||||
})
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
claims, ok := token.Claims.(*Claims)
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hashToken returns the SHA-256 hex digest of a token string.
|
|
||||||
// The raw token is never stored; only the hash goes to the database.
|
|
||||||
func hashToken(token string) string {
|
|
||||||
sum := sha256.Sum256([]byte(token))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
||||||
|
|
||||||
CREATE SCHEMA IF NOT EXISTS core;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS data;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS acl;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS activity;
|
|
||||||
|
|
||||||
-- UUID v7 generator
|
|
||||||
-- +goose StatementBegin
|
|
||||||
CREATE OR REPLACE FUNCTION public.uuid_v7(cts timestamptz DEFAULT clock_timestamp())
|
|
||||||
RETURNS uuid LANGUAGE plpgsql AS $$
|
|
||||||
DECLARE
|
|
||||||
state text = current_setting('uuidv7.old_tp', true);
|
|
||||||
old_tp text = split_part(state, ':', 1);
|
|
||||||
base int = coalesce(nullif(split_part(state, ':', 4), '')::int, (random()*16777215/2-1)::int);
|
|
||||||
tp text;
|
|
||||||
entropy text;
|
|
||||||
seq text = base;
|
|
||||||
seqn int = split_part(state, ':', 2);
|
|
||||||
ver text = coalesce(split_part(state, ':', 3), to_hex(8+(random()*3)::int));
|
|
||||||
BEGIN
|
|
||||||
base = (random()*16777215/2-1)::int;
|
|
||||||
tp = lpad(to_hex(floor(extract(epoch from cts)*1000)::int8), 12, '0') || '7';
|
|
||||||
IF tp IS DISTINCT FROM old_tp THEN
|
|
||||||
old_tp = tp;
|
|
||||||
ver = to_hex(8+(random()*3)::int);
|
|
||||||
base = (random()*16777215/2-1)::int;
|
|
||||||
seqn = base;
|
|
||||||
ELSE
|
|
||||||
seqn = seqn + (random()*1000)::int;
|
|
||||||
END IF;
|
|
||||||
PERFORM set_config('uuidv7.old_tp', old_tp||':'||seqn||':'||ver||':'||base, false);
|
|
||||||
entropy = md5(gen_random_uuid()::text);
|
|
||||||
seq = lpad(to_hex(seqn), 6, '0');
|
|
||||||
RETURN (tp || substring(seq from 1 for 3) || ver || substring(seq from 4 for 3) ||
|
|
||||||
substring(entropy from 1 for 12))::uuid;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- Extract timestamp from UUID v7
|
|
||||||
-- +goose StatementBegin
|
|
||||||
CREATE OR REPLACE FUNCTION public.uuid_extract_timestamp(uuid_val uuid)
|
|
||||||
RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
|
||||||
SELECT to_timestamp(
|
|
||||||
('x' || left(replace(uuid_val::text, '-', ''), 12))::bit(48)::bigint / 1000.0
|
|
||||||
);
|
|
||||||
$$;
|
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DROP FUNCTION IF EXISTS public.uuid_extract_timestamp(uuid);
|
|
||||||
DROP FUNCTION IF EXISTS public.uuid_v7(timestamptz);
|
|
||||||
|
|
||||||
DROP SCHEMA IF EXISTS activity;
|
|
||||||
DROP SCHEMA IF EXISTS acl;
|
|
||||||
DROP SCHEMA IF EXISTS data;
|
|
||||||
DROP SCHEMA IF EXISTS core;
|
|
||||||
|
|
||||||
DROP EXTENSION IF EXISTS "uuid-ossp";
|
|
||||||
DROP EXTENSION IF EXISTS pgcrypto;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
CREATE TABLE core.users (
|
|
||||||
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
name varchar(32) NOT NULL,
|
|
||||||
password text NOT NULL, -- bcrypt hash via pgcrypto
|
|
||||||
is_admin boolean NOT NULL DEFAULT false,
|
|
||||||
can_create boolean NOT NULL DEFAULT false,
|
|
||||||
is_blocked boolean NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
CONSTRAINT uni__users__name UNIQUE (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE core.mime_types (
|
|
||||||
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
name varchar(127) NOT NULL,
|
|
||||||
extension varchar(16) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT uni__mime_types__name UNIQUE (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE core.object_types (
|
|
||||||
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
name varchar(32) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT uni__object_types__name UNIQUE (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE core.users IS 'Application users';
|
|
||||||
COMMENT ON TABLE core.mime_types IS 'Whitelist of supported MIME types';
|
|
||||||
COMMENT ON TABLE core.object_types IS 'Reference: entity types for ACL and audit log';
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS core.object_types;
|
|
||||||
DROP TABLE IF EXISTS core.mime_types;
|
|
||||||
DROP TABLE IF EXISTS core.users;
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
CREATE TABLE data.categories (
|
|
||||||
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
|
|
||||||
name varchar(256) NOT NULL,
|
|
||||||
notes text,
|
|
||||||
color char(6),
|
|
||||||
metadata jsonb,
|
|
||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
is_public boolean NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
CONSTRAINT uni__categories__name UNIQUE (name),
|
|
||||||
CONSTRAINT chk__categories__color_hex
|
|
||||||
CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$')
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE data.tags (
|
|
||||||
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
|
|
||||||
name varchar(256) NOT NULL,
|
|
||||||
notes text,
|
|
||||||
color char(6),
|
|
||||||
category_id uuid REFERENCES data.categories(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
|
||||||
metadata jsonb,
|
|
||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
is_public boolean NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
CONSTRAINT uni__tags__name UNIQUE (name),
|
|
||||||
CONSTRAINT chk__tags__color_hex
|
|
||||||
CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$')
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE data.tag_rules (
|
|
||||||
when_tag_id uuid NOT NULL REFERENCES data.tags(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
then_tag_id uuid NOT NULL REFERENCES data.tags(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
is_active boolean NOT NULL DEFAULT true,
|
|
||||||
|
|
||||||
PRIMARY KEY (when_tag_id, then_tag_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE data.files (
|
|
||||||
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
|
|
||||||
original_name varchar(256), -- original filename at upload time
|
|
||||||
mime_id smallint NOT NULL REFERENCES core.mime_types(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
content_datetime timestamptz NOT NULL DEFAULT clock_timestamp(), -- content datetime (e.g. photo taken)
|
|
||||||
notes text,
|
|
||||||
metadata jsonb, -- user-editable key-value data
|
|
||||||
exif jsonb NOT NULL DEFAULT '{}'::jsonb, -- EXIF data extracted at upload (immutable)
|
|
||||||
phash bigint, -- perceptual hash for duplicate detection (future)
|
|
||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
is_public boolean NOT NULL DEFAULT false,
|
|
||||||
is_deleted boolean NOT NULL DEFAULT false -- soft delete (trash)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE data.file_tag (
|
|
||||||
file_id uuid NOT NULL REFERENCES data.files(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
tag_id uuid NOT NULL REFERENCES data.tags(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
|
|
||||||
PRIMARY KEY (file_id, tag_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE data.pools (
|
|
||||||
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
|
|
||||||
name varchar(256) NOT NULL,
|
|
||||||
notes text,
|
|
||||||
metadata jsonb,
|
|
||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
is_public boolean NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
CONSTRAINT uni__pools__name UNIQUE (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow
|
|
||||||
-- insertions without renumbering. Compact when gaps get too small.
|
|
||||||
CREATE TABLE data.file_pool (
|
|
||||||
file_id uuid NOT NULL REFERENCES data.files(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
pool_id uuid NOT NULL REFERENCES data.pools(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
position integer NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
PRIMARY KEY (file_id, pool_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE data.categories IS 'Logical grouping of tags';
|
|
||||||
COMMENT ON TABLE data.tags IS 'File labels/tags';
|
|
||||||
COMMENT ON TABLE data.tag_rules IS 'Auto-tagging rules: when when_tag is assigned, then_tag follows';
|
|
||||||
COMMENT ON TABLE data.files IS 'Managed files; actual content stored on disk as {id}.{ext}';
|
|
||||||
COMMENT ON TABLE data.file_tag IS 'Many-to-many: files <-> tags';
|
|
||||||
COMMENT ON TABLE data.pools IS 'Ordered collections of files';
|
|
||||||
COMMENT ON TABLE data.file_pool IS 'Many-to-many: files <-> pools, with ordering';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN data.files.original_name IS 'Original filename at upload time';
|
|
||||||
COMMENT ON COLUMN data.files.content_datetime IS 'Content datetime (e.g. when photo was taken); falls back to EXIF DateTimeOriginal';
|
|
||||||
COMMENT ON COLUMN data.files.metadata IS 'User-editable key-value metadata';
|
|
||||||
COMMENT ON COLUMN data.files.exif IS 'EXIF data extracted at upload time (immutable, system-managed)';
|
|
||||||
COMMENT ON COLUMN data.files.phash IS 'Perceptual hash for image/video duplicate detection';
|
|
||||||
COMMENT ON COLUMN data.files.is_deleted IS 'Soft-deleted files (trash); true = in recycle bin';
|
|
||||||
COMMENT ON COLUMN data.file_pool.position IS 'Manual ordering within pool; uses gapped integers';
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS data.file_pool;
|
|
||||||
DROP TABLE IF EXISTS data.pools;
|
|
||||||
DROP TABLE IF EXISTS data.file_tag;
|
|
||||||
DROP TABLE IF EXISTS data.files;
|
|
||||||
DROP TABLE IF EXISTS data.tag_rules;
|
|
||||||
DROP TABLE IF EXISTS data.tags;
|
|
||||||
DROP TABLE IF EXISTS data.categories;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
-- If is_public=true on the object, it is accessible to everyone (ACL ignored).
|
|
||||||
-- If is_public=false, only creator and users with can_view=true see it.
|
|
||||||
-- Admins bypass all ACL checks.
|
|
||||||
CREATE TABLE acl.permissions (
|
|
||||||
user_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
object_type_id smallint NOT NULL REFERENCES core.object_types(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
object_id uuid NOT NULL,
|
|
||||||
can_view boolean NOT NULL DEFAULT true,
|
|
||||||
can_edit boolean NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
PRIMARY KEY (user_id, object_type_id, object_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE acl.permissions IS 'Per-object permissions (used when is_public=false)';
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS acl.permissions;
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
CREATE TABLE activity.action_types (
|
|
||||||
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
name varchar(64) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT uni__action_types__name UNIQUE (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE activity.sessions (
|
|
||||||
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
token_hash text NOT NULL, -- hashed session token
|
|
||||||
user_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
user_agent varchar(256) NOT NULL,
|
|
||||||
started_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
||||||
expires_at timestamptz,
|
|
||||||
last_activity timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
||||||
is_active boolean NOT NULL DEFAULT true,
|
|
||||||
|
|
||||||
CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE activity.file_views (
|
|
||||||
file_id uuid NOT NULL REFERENCES data.files(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
user_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
||||||
|
|
||||||
PRIMARY KEY (file_id, viewed_at, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE activity.pool_views (
|
|
||||||
pool_id uuid NOT NULL REFERENCES data.pools(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
user_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
||||||
|
|
||||||
PRIMARY KEY (pool_id, viewed_at, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE activity.tag_uses (
|
|
||||||
tag_id uuid NOT NULL REFERENCES data.tags(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
user_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
used_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
||||||
is_included boolean NOT NULL, -- true=included in filter, false=excluded
|
|
||||||
|
|
||||||
PRIMARY KEY (tag_id, used_at, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE activity.audit_log (
|
|
||||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
user_id smallint NOT NULL REFERENCES core.users(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
action_type_id smallint NOT NULL REFERENCES activity.action_types(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
object_type_id smallint REFERENCES core.object_types(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
|
||||||
object_id uuid,
|
|
||||||
details jsonb, -- action-specific payload
|
|
||||||
performed_at timestamptz NOT NULL DEFAULT statement_timestamp()
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE activity.action_types IS 'Reference: types of auditable user actions';
|
|
||||||
COMMENT ON TABLE activity.sessions IS 'Active user sessions';
|
|
||||||
COMMENT ON TABLE activity.file_views IS 'File view history';
|
|
||||||
COMMENT ON TABLE activity.pool_views IS 'Pool view history';
|
|
||||||
COMMENT ON TABLE activity.tag_uses IS 'Tag usage in filters';
|
|
||||||
COMMENT ON TABLE activity.audit_log IS 'Unified audit trail for all user actions';
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS activity.audit_log;
|
|
||||||
DROP TABLE IF EXISTS activity.tag_uses;
|
|
||||||
DROP TABLE IF EXISTS activity.pool_views;
|
|
||||||
DROP TABLE IF EXISTS activity.file_views;
|
|
||||||
DROP TABLE IF EXISTS activity.sessions;
|
|
||||||
DROP TABLE IF EXISTS activity.action_types;
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
-- core
|
|
||||||
CREATE INDEX idx__users__name ON core.users USING hash (name);
|
|
||||||
|
|
||||||
-- data.categories
|
|
||||||
CREATE INDEX idx__categories__creator_id ON data.categories USING hash (creator_id);
|
|
||||||
|
|
||||||
-- data.tags
|
|
||||||
CREATE INDEX idx__tags__category_id ON data.tags USING hash (category_id);
|
|
||||||
CREATE INDEX idx__tags__creator_id ON data.tags USING hash (creator_id);
|
|
||||||
|
|
||||||
-- data.tag_rules
|
|
||||||
CREATE INDEX idx__tag_rules__when ON data.tag_rules USING hash (when_tag_id);
|
|
||||||
CREATE INDEX idx__tag_rules__then ON data.tag_rules USING hash (then_tag_id);
|
|
||||||
|
|
||||||
-- data.files
|
|
||||||
CREATE INDEX idx__files__mime_id ON data.files USING hash (mime_id);
|
|
||||||
CREATE INDEX idx__files__creator_id ON data.files USING hash (creator_id);
|
|
||||||
CREATE INDEX idx__files__content_datetime ON data.files USING btree (content_datetime DESC NULLS LAST);
|
|
||||||
CREATE INDEX idx__files__is_deleted ON data.files USING btree (is_deleted) WHERE is_deleted = true;
|
|
||||||
CREATE INDEX idx__files__phash ON data.files USING btree (phash) WHERE phash IS NOT NULL;
|
|
||||||
|
|
||||||
-- data.file_tag
|
|
||||||
CREATE INDEX idx__file_tag__tag_id ON data.file_tag USING hash (tag_id);
|
|
||||||
CREATE INDEX idx__file_tag__file_id ON data.file_tag USING hash (file_id);
|
|
||||||
|
|
||||||
-- data.pools
|
|
||||||
CREATE INDEX idx__pools__creator_id ON data.pools USING hash (creator_id);
|
|
||||||
|
|
||||||
-- data.file_pool
|
|
||||||
CREATE INDEX idx__file_pool__pool_id ON data.file_pool USING hash (pool_id);
|
|
||||||
CREATE INDEX idx__file_pool__file_id ON data.file_pool USING hash (file_id);
|
|
||||||
|
|
||||||
-- acl.permissions
|
|
||||||
CREATE INDEX idx__acl__object ON acl.permissions USING btree (object_type_id, object_id);
|
|
||||||
CREATE INDEX idx__acl__user ON acl.permissions USING hash (user_id);
|
|
||||||
|
|
||||||
-- activity.sessions
|
|
||||||
CREATE INDEX idx__sessions__user_id ON activity.sessions USING hash (user_id);
|
|
||||||
CREATE INDEX idx__sessions__token_hash ON activity.sessions USING hash (token_hash);
|
|
||||||
|
|
||||||
-- activity.file_views
|
|
||||||
CREATE INDEX idx__file_views__user_id ON activity.file_views USING hash (user_id);
|
|
||||||
|
|
||||||
-- activity.pool_views
|
|
||||||
CREATE INDEX idx__pool_views__user_id ON activity.pool_views USING hash (user_id);
|
|
||||||
|
|
||||||
-- activity.tag_uses
|
|
||||||
CREATE INDEX idx__tag_uses__user_id ON activity.tag_uses USING hash (user_id);
|
|
||||||
|
|
||||||
-- activity.audit_log
|
|
||||||
CREATE INDEX idx__audit_log__user_id ON activity.audit_log USING hash (user_id);
|
|
||||||
CREATE INDEX idx__audit_log__action_type_id ON activity.audit_log USING hash (action_type_id);
|
|
||||||
CREATE INDEX idx__audit_log__object ON activity.audit_log USING btree (object_type_id, object_id)
|
|
||||||
WHERE object_id IS NOT NULL;
|
|
||||||
CREATE INDEX idx__audit_log__performed_at ON activity.audit_log USING btree (performed_at DESC);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS activity.idx__audit_log__performed_at;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__audit_log__object;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__audit_log__action_type_id;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__audit_log__user_id;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__tag_uses__user_id;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__pool_views__user_id;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__file_views__user_id;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__sessions__token_hash;
|
|
||||||
DROP INDEX IF EXISTS activity.idx__sessions__user_id;
|
|
||||||
DROP INDEX IF EXISTS acl.idx__acl__user;
|
|
||||||
DROP INDEX IF EXISTS acl.idx__acl__object;
|
|
||||||
DROP INDEX IF EXISTS data.idx__file_pool__file_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__file_pool__pool_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__pools__creator_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__file_tag__file_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__file_tag__tag_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__files__phash;
|
|
||||||
DROP INDEX IF EXISTS data.idx__files__is_deleted;
|
|
||||||
DROP INDEX IF EXISTS data.idx__files__content_datetime;
|
|
||||||
DROP INDEX IF EXISTS data.idx__files__creator_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__files__mime_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__tag_rules__then;
|
|
||||||
DROP INDEX IF EXISTS data.idx__tag_rules__when;
|
|
||||||
DROP INDEX IF EXISTS data.idx__tags__creator_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__tags__category_id;
|
|
||||||
DROP INDEX IF EXISTS data.idx__categories__creator_id;
|
|
||||||
DROP INDEX IF EXISTS core.idx__users__name;
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
|
|
||||||
INSERT INTO core.object_types (name) VALUES
|
|
||||||
('file'), ('tag'), ('category'), ('pool');
|
|
||||||
|
|
||||||
INSERT INTO activity.action_types (name) VALUES
|
|
||||||
-- Auth
|
|
||||||
('user_login'), ('user_logout'),
|
|
||||||
-- Files
|
|
||||||
('file_create'), ('file_edit'), ('file_delete'), ('file_restore'),
|
|
||||||
('file_permanent_delete'), ('file_replace'),
|
|
||||||
-- Tags
|
|
||||||
('tag_create'), ('tag_edit'), ('tag_delete'),
|
|
||||||
-- Categories
|
|
||||||
('category_create'), ('category_edit'), ('category_delete'),
|
|
||||||
-- Pools
|
|
||||||
('pool_create'), ('pool_edit'), ('pool_delete'),
|
|
||||||
-- Relations
|
|
||||||
('file_tag_add'), ('file_tag_remove'),
|
|
||||||
('file_pool_add'), ('file_pool_remove'),
|
|
||||||
-- ACL
|
|
||||||
('acl_change'),
|
|
||||||
-- Admin
|
|
||||||
('user_create'), ('user_delete'), ('user_block'), ('user_unblock'),
|
|
||||||
('user_role_change'),
|
|
||||||
-- Sessions
|
|
||||||
('session_terminate');
|
|
||||||
|
|
||||||
INSERT INTO core.users (name, password, is_admin, can_create) VALUES
|
|
||||||
('admin', '$2a$10$zk.VTFjRRxbkTE7cKfc7KOWeZfByk1VEkbkgZMJggI1fFf.yDEHZy', true, true);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
|
|
||||||
DELETE FROM core.users WHERE name = 'admin';
|
|
||||||
DELETE FROM activity.action_types;
|
|
||||||
DELETE FROM core.object_types;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import "embed"
|
|
||||||
|
|
||||||
// FS holds all goose migration SQL files, embedded at build time.
|
|
||||||
//
|
|
||||||
//go:embed *.sql
|
|
||||||
var FS embed.FS
|
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
#!../venv/bin/python3
|
||||||
|
|
||||||
|
import telebot
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import atexit
|
||||||
|
import signal
|
||||||
|
from time import sleep
|
||||||
|
from socket import getaddrinfo
|
||||||
|
from requests import get
|
||||||
|
from subprocess import check_output
|
||||||
|
import logging as log
|
||||||
|
from json import loads as ljson
|
||||||
|
from datetime import datetime
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
import api.tfm_api as tfm_api
|
||||||
|
|
||||||
|
# set logger
|
||||||
|
log.basicConfig(
|
||||||
|
level=log.INFO,
|
||||||
|
filename="/var/log/tfm/tfm-tb.log",
|
||||||
|
filemode="a",
|
||||||
|
format="%(asctime)s | %(threadName)s | %(levelname)s | %(message)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
# actions to do on exit
|
||||||
|
exit_actions = []
|
||||||
|
defer = exit_actions.append
|
||||||
|
def finalize(*args):
|
||||||
|
exec('\n'.join(exit_actions))
|
||||||
|
os._exit(0)
|
||||||
|
atexit.register(finalize)
|
||||||
|
signal.signal(signal.SIGTERM, finalize)
|
||||||
|
signal.signal(signal.SIGINT, finalize)
|
||||||
|
|
||||||
|
# initialize TFM API
|
||||||
|
try:
|
||||||
|
tfm_api.Initialize()
|
||||||
|
tfm = tfm_api.TSession("af6dde9b-7be1-46f2-872e-9a06ce3d3358")
|
||||||
|
except Exception as e:
|
||||||
|
log.critical(f"failed to initialize TFM API: {str(e)}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# initialize bot
|
||||||
|
tfm_tb = telebot.TeleBot(tfm_api.conf["Telebot"]["Token"])
|
||||||
|
|
||||||
|
TZ = pytz.timezone("Europe/Moscow")
|
||||||
|
|
||||||
|
|
||||||
|
# check if user is authorized and if chat exists in db
|
||||||
|
def check_chat(message):
|
||||||
|
return message.from_user.id == 924090228
|
||||||
|
|
||||||
|
|
||||||
|
# file handler
|
||||||
|
@tfm_tb.message_handler(content_types=['document', 'photo', 'audio', 'video', 'voice', 'animation'])
|
||||||
|
def file_handler(message):
|
||||||
|
if not check_chat(message):
|
||||||
|
return
|
||||||
|
notes = None
|
||||||
|
orig_name = None
|
||||||
|
if message.forward_from_chat:
|
||||||
|
notes = f"Telegram origin: \"{message.forward_from_chat.title}\" ({message.forward_from_chat.username})"
|
||||||
|
if message.photo:
|
||||||
|
fname = f"{message.photo[-1].file_unique_id}"
|
||||||
|
log.info(f"got photo '{fname}'")
|
||||||
|
file_info = tfm_tb.get_file(message.photo[-1].file_id)
|
||||||
|
file_path = os.path.join(tfm_api.conf["Telebot"]["SaveFolder"], fname)
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
f.write(tfm_tb.download_file(file_info.file_path))
|
||||||
|
elif message.video:
|
||||||
|
fname = f"{message.video.file_unique_id}"
|
||||||
|
log.info(f"got video '{fname}'")
|
||||||
|
file_info = tfm_tb.get_file(message.video.file_id)
|
||||||
|
file_path = os.path.join(tfm_api.conf["Telebot"]["SaveFolder"], fname)
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
f.write(tfm_tb.download_file(file_info.file_path))
|
||||||
|
else:
|
||||||
|
file = None
|
||||||
|
if message.document:
|
||||||
|
file = message.document
|
||||||
|
elif message.animation:
|
||||||
|
file = message.animation
|
||||||
|
else:
|
||||||
|
tfm_tb.reply_to(message, "Unsupported file type.")
|
||||||
|
return
|
||||||
|
log.info(f"got file '{file.file_name}'")
|
||||||
|
orig_name = file.file_name
|
||||||
|
file_info = tfm_tb.get_file(file.file_id)
|
||||||
|
file_path = os.path.join(tfm_api.conf["Telebot"]["SaveFolder"], f"{file.file_unique_id}")
|
||||||
|
try:
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
f.write(tfm_tb.download_file(file_info.file_path))
|
||||||
|
log.info(f"downloaded file '{file_path}'")
|
||||||
|
exif = ljson(os.popen(f"exiftool -json \"{file_path}\"").read())[0]
|
||||||
|
dt = exif["FileModifyDate"]
|
||||||
|
if "SubSecCreateDate" in exif.keys():
|
||||||
|
dt = exif["SubSecCreateDate"]
|
||||||
|
if '.' in dt:
|
||||||
|
dt = datetime.strptime(dt, "%Y:%m:%d %H:%M:%S.%f%z")
|
||||||
|
else:
|
||||||
|
dt = datetime.strptime(dt, "%Y:%m:%d %H:%M:%S%z")
|
||||||
|
file_id, ext = tfm.add_file(file_path, dt, notes, orig_name=orig_name)
|
||||||
|
tfm.add_file_to_tag(file_id, "d6d8129a-984d-4451-8c83-d04523ced8a8")
|
||||||
|
except Exception as e:
|
||||||
|
tfm_tb.reply_to(message, "Error: %s" % str(e).split('\n')[0])
|
||||||
|
log.info(f"Error: %s" % str(e).split('\n')[0])
|
||||||
|
os.remove(file_path)
|
||||||
|
log.info(f"removed file '{file_path}'")
|
||||||
|
else:
|
||||||
|
tfm_tb.reply_to(message, "File saved.")
|
||||||
|
log.info(f"imported file '{file_path}'")
|
||||||
|
|
||||||
|
|
||||||
|
# folder scanner
|
||||||
|
@tfm_tb.message_handler(commands=['scan'])
|
||||||
|
def scan(message):
|
||||||
|
tfm_tb.reply_to(message, "Scanning...")
|
||||||
|
log.info("Scanning...")
|
||||||
|
scan_dir = "/srv/share/hfs/misc/tfm_temp/scan"
|
||||||
|
files = []
|
||||||
|
for file in os.listdir(scan_dir):
|
||||||
|
new_file = {"name": file}
|
||||||
|
file = os.path.join(scan_dir, file)
|
||||||
|
if not os.path.isfile(file):
|
||||||
|
continue
|
||||||
|
new_file["path"] = file
|
||||||
|
try:
|
||||||
|
exif = ljson(os.popen(f"exiftool -json \"{file}\"").read())[0]
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error while parsing EXIF for file '%s': %s" % (file, e))
|
||||||
|
continue
|
||||||
|
dt = exif["FileModifyDate"]
|
||||||
|
if "SubSecDateTimeOriginal" in exif.keys():
|
||||||
|
dt = exif["SubSecDateTimeOriginal"]
|
||||||
|
elif "DateTimeOriginal" in exif.keys():
|
||||||
|
dt = exif["DateTimeOriginal"]
|
||||||
|
if '.' in dt:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(dt, "%Y:%m:%d %H:%M:%S.%f%z")
|
||||||
|
except:
|
||||||
|
dt = TZ.localize(datetime.strptime(dt, "%Y:%m:%d %H:%M:%S.%f"))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(dt, "%Y:%m:%d %H:%M:%S%z")
|
||||||
|
except:
|
||||||
|
dt = TZ.localize(datetime.strptime(dt[:19], "%Y:%m:%d %H:%M:%S"))
|
||||||
|
except:
|
||||||
|
log.error("Broken date: %s\t%s" % (new_file, dt))
|
||||||
|
continue
|
||||||
|
new_file["datetime"] = dt
|
||||||
|
files.append(new_file)
|
||||||
|
tfm_tb.reply_to(message, f"{len(files)} files found.")
|
||||||
|
log.info(f"{len(files)} files found.")
|
||||||
|
files.sort(key=lambda f: f["datetime"])
|
||||||
|
for file in files:
|
||||||
|
try:
|
||||||
|
file_id, ext = tfm.add_file(file["path"], file["datetime"])
|
||||||
|
tfm.add_file_to_tag(file_id, "d6d8129a-984d-4451-8c83-d04523ced8a8")
|
||||||
|
except Exception as e:
|
||||||
|
tfm_tb.reply_to(message, f"Error adding file '{file['name']}': {str(e)}")
|
||||||
|
log.error(f"Error adding file '{file['name']}': {str(e)}")
|
||||||
|
tfm_tb.reply_to(message, "Files added.")
|
||||||
|
log.info("Files added.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
log.info("tfm-tb started")
|
||||||
|
defer("log.info(\"tfm-tb stopped\")")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
tfm_tb.polling(none_stop=True)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("exception on polling")
|
||||||
|
sleep(1)
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
# Tanabata File Manager — Frontend Structure
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Framework**: SvelteKit (SPA mode, `ssr: false`)
|
|
||||||
- **Language**: TypeScript
|
|
||||||
- **CSS**: Tailwind CSS + CSS custom properties (hybrid)
|
|
||||||
- **API types**: Auto-generated via openapi-typescript
|
|
||||||
- **PWA**: Service worker + web manifest
|
|
||||||
- **Font**: Epilogue (variable weight)
|
|
||||||
- **Package manager**: npm
|
|
||||||
|
|
||||||
## Monorepo Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
tanabata/
|
|
||||||
├── backend/ ← Go project (go.mod in here)
|
|
||||||
│ ├── cmd/
|
|
||||||
│ ├── internal/
|
|
||||||
│ ├── migrations/
|
|
||||||
│ ├── go.mod
|
|
||||||
│ └── go.sum
|
|
||||||
│
|
|
||||||
├── frontend/ ← SvelteKit project (package.json in here)
|
|
||||||
│ └── (see below)
|
|
||||||
│
|
|
||||||
├── openapi.yaml ← Shared API contract (root level)
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Dockerfile
|
|
||||||
├── .env.example
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
`openapi.yaml` lives at repository root — both backend and frontend
|
|
||||||
reference it. The frontend generates types from it; the backend
|
|
||||||
validates its handlers against it.
|
|
||||||
|
|
||||||
## Frontend Directory Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/
|
|
||||||
├── package.json
|
|
||||||
├── svelte.config.js
|
|
||||||
├── vite.config.ts
|
|
||||||
├── tsconfig.json
|
|
||||||
├── tailwind.config.ts
|
|
||||||
├── postcss.config.js
|
|
||||||
│
|
|
||||||
├── src/
|
|
||||||
│ ├── app.html # Shell HTML (PWA meta, font preload)
|
|
||||||
│ ├── app.css # Tailwind directives + CSS custom properties
|
|
||||||
│ ├── hooks.server.ts # Server hooks (not used in SPA mode)
|
|
||||||
│ ├── hooks.client.ts # Client hooks (global error handling)
|
|
||||||
│ │
|
|
||||||
│ ├── lib/ # Shared code ($lib/ alias)
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── api/ # API client layer
|
|
||||||
│ │ │ ├── client.ts # Base fetch wrapper: auth headers, token refresh,
|
|
||||||
│ │ │ │ # error parsing, base URL
|
|
||||||
│ │ │ ├── files.ts # listFiles, getFile, uploadFile, deleteFile, etc.
|
|
||||||
│ │ │ ├── tags.ts # listTags, createTag, getTag, updateTag, etc.
|
|
||||||
│ │ │ ├── categories.ts # Category API functions
|
|
||||||
│ │ │ ├── pools.ts # Pool API functions
|
|
||||||
│ │ │ ├── auth.ts # login, logout, refresh, listSessions
|
|
||||||
│ │ │ ├── acl.ts # getPermissions, setPermissions
|
|
||||||
│ │ │ ├── users.ts # getMe, updateMe, admin user CRUD
|
|
||||||
│ │ │ ├── audit.ts # queryAuditLog
|
|
||||||
│ │ │ ├── schema.ts # AUTO-GENERATED from openapi.yaml (do not edit)
|
|
||||||
│ │ │ └── types.ts # Friendly type aliases:
|
|
||||||
│ │ │ # export type File = components["schemas"]["File"]
|
|
||||||
│ │ │ # export type Tag = components["schemas"]["Tag"]
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── components/ # Reusable UI components
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── layout/ # App shell
|
|
||||||
│ │ │ │ ├── Navbar.svelte # Bottom navigation bar (mobile-first)
|
|
||||||
│ │ │ │ ├── Header.svelte # Section header with sorting controls
|
|
||||||
│ │ │ │ ├── SelectionBar.svelte # Floating bar for multi-select actions
|
|
||||||
│ │ │ │ └── Loader.svelte # Full-screen loading overlay
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── file/ # File-related components
|
|
||||||
│ │ │ │ ├── FileGrid.svelte # Thumbnail grid with infinite scroll
|
|
||||||
│ │ │ │ ├── FileCard.svelte # Single thumbnail (160×160, selectable)
|
|
||||||
│ │ │ │ ├── FileViewer.svelte # Full-screen preview with prev/next navigation
|
|
||||||
│ │ │ │ ├── FileUpload.svelte # Upload form + drag-and-drop zone
|
|
||||||
│ │ │ │ ├── FileDetail.svelte # Metadata editor (notes, datetime, tags)
|
|
||||||
│ │ │ │ └── FilterBar.svelte # DSL filter builder UI
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── tag/ # Tag-related components
|
|
||||||
│ │ │ │ ├── TagBadge.svelte # Colored pill with tag name
|
|
||||||
│ │ │ │ ├── TagPicker.svelte # Searchable tag selector (add/remove)
|
|
||||||
│ │ │ │ ├── TagList.svelte # Tag grid for section view
|
|
||||||
│ │ │ │ └── TagRuleEditor.svelte # Auto-tag rule management
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── pool/ # Pool-related components
|
|
||||||
│ │ │ │ ├── PoolCard.svelte # Pool preview card
|
|
||||||
│ │ │ │ ├── PoolFileList.svelte # Ordered file list with drag reorder
|
|
||||||
│ │ │ │ └── PoolDetail.svelte # Pool metadata editor
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── acl/ # Access control components
|
|
||||||
│ │ │ │ └── PermissionEditor.svelte # User permission grid
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ └── common/ # Shared primitives
|
|
||||||
│ │ │ ├── Button.svelte
|
|
||||||
│ │ │ ├── Modal.svelte
|
|
||||||
│ │ │ ├── ConfirmDialog.svelte
|
|
||||||
│ │ │ ├── Toast.svelte
|
|
||||||
│ │ │ ├── InfiniteScroll.svelte
|
|
||||||
│ │ │ ├── Pagination.svelte
|
|
||||||
│ │ │ ├── SortDropdown.svelte
|
|
||||||
│ │ │ ├── SearchInput.svelte
|
|
||||||
│ │ │ ├── ColorPicker.svelte
|
|
||||||
│ │ │ ├── Checkbox.svelte # Three-state: checked, unchecked, partial
|
|
||||||
│ │ │ └── EmptyState.svelte
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── stores/ # Svelte stores (global state)
|
|
||||||
│ │ │ ├── auth.ts # Current user, JWT tokens, isAuthenticated
|
|
||||||
│ │ │ ├── selection.ts # Selected item IDs, selection mode toggle
|
|
||||||
│ │ │ ├── sorting.ts # Per-section sort key + order (persisted to localStorage)
|
|
||||||
│ │ │ ├── theme.ts # Dark/light mode (persisted, respects prefers-color-scheme)
|
|
||||||
│ │ │ └── toast.ts # Notification queue (success, error, info)
|
|
||||||
│ │ │
|
|
||||||
│ │ └── utils/ # Pure helper functions
|
|
||||||
│ │ ├── format.ts # formatDate, formatFileSize, formatDuration
|
|
||||||
│ │ ├── dsl.ts # Filter DSL builder: UI state → query string
|
|
||||||
│ │ ├── pwa.ts # PWA reset, cache clear, update prompt
|
|
||||||
│ │ └── keyboard.ts # Keyboard shortcut helpers (Ctrl+A, Escape, etc.)
|
|
||||||
│ │
|
|
||||||
│ ├── routes/ # SvelteKit file-based routing
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── +layout.svelte # Root layout: Navbar, theme wrapper, toast container
|
|
||||||
│ │ ├── +layout.ts # Root load: auth guard → redirect to /login if no token
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── +page.svelte # / → redirect to /files
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── login/
|
|
||||||
│ │ │ └── +page.svelte # Login form (decorative Tanabata images)
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── files/
|
|
||||||
│ │ │ ├── +page.svelte # File grid: filter bar, sort, multi-select, upload
|
|
||||||
│ │ │ ├── +page.ts # Load: initial file list (cursor page)
|
|
||||||
│ │ │ ├── [id]/
|
|
||||||
│ │ │ │ ├── +page.svelte # File view: preview, metadata, tags, ACL
|
|
||||||
│ │ │ │ └── +page.ts # Load: file detail + tags
|
|
||||||
│ │ │ └── trash/
|
|
||||||
│ │ │ ├── +page.svelte # Trash: restore / permanent delete
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── tags/
|
|
||||||
│ │ │ ├── +page.svelte # Tag list: search, sort, multi-select
|
|
||||||
│ │ │ ├── +page.ts
|
|
||||||
│ │ │ ├── new/
|
|
||||||
│ │ │ │ └── +page.svelte # Create tag form
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ │ ├── +page.svelte # Tag detail: edit, category, rules, parent tags
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── categories/
|
|
||||||
│ │ │ ├── +page.svelte # Category list
|
|
||||||
│ │ │ ├── +page.ts
|
|
||||||
│ │ │ ├── new/
|
|
||||||
│ │ │ │ └── +page.svelte
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ │ ├── +page.svelte # Category detail: edit, view tags
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── pools/
|
|
||||||
│ │ │ ├── +page.svelte # Pool list
|
|
||||||
│ │ │ ├── +page.ts
|
|
||||||
│ │ │ ├── new/
|
|
||||||
│ │ │ │ └── +page.svelte
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ │ ├── +page.svelte # Pool detail: files (reorderable), filter, edit
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── settings/
|
|
||||||
│ │ │ ├── +page.svelte # Profile: name, password, active sessions
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ └── admin/
|
|
||||||
│ │ ├── +layout.svelte # Admin layout: restrict to is_admin
|
|
||||||
│ │ ├── users/
|
|
||||||
│ │ │ ├── +page.svelte # User management list
|
|
||||||
│ │ │ ├── +page.ts
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ │ ├── +page.svelte # User detail: role, block/unblock
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ └── audit/
|
|
||||||
│ │ ├── +page.svelte # Audit log with filters
|
|
||||||
│ │ └── +page.ts
|
|
||||||
│ │
|
|
||||||
│ └── service-worker.ts # PWA: offline cache for pinned files, app shell caching
|
|
||||||
│
|
|
||||||
└── static/
|
|
||||||
├── favicon.png
|
|
||||||
├── favicon.ico
|
|
||||||
├── manifest.webmanifest # PWA manifest (name, icons, theme_color)
|
|
||||||
├── images/
|
|
||||||
│ ├── tanabata-left.png # Login page decorations (from current design)
|
|
||||||
│ ├── tanabata-right.png
|
|
||||||
│ └── icons/ # PWA icons (192×192, 512×512, etc.)
|
|
||||||
└── fonts/
|
|
||||||
└── Epilogue-VariableFont_wght.ttf
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Architecture Decisions
|
|
||||||
|
|
||||||
### CSS Hybrid: Tailwind + Custom Properties
|
|
||||||
|
|
||||||
Theme colors defined as CSS custom properties in `app.css`:
|
|
||||||
|
|
||||||
```css
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--color-bg-primary: #312F45;
|
|
||||||
--color-bg-secondary: #181721;
|
|
||||||
--color-bg-elevated: #111118;
|
|
||||||
--color-accent: #9592B5;
|
|
||||||
--color-accent-hover: #7D7AA4;
|
|
||||||
--color-text-primary: #f0f0f0;
|
|
||||||
--color-text-muted: #9999AD;
|
|
||||||
--color-danger: #DB6060;
|
|
||||||
--color-info: #4DC7ED;
|
|
||||||
--color-warning: #F5E872;
|
|
||||||
--color-tag-default: #444455;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
|
||||||
--color-bg-primary: #f5f5f5;
|
|
||||||
--color-bg-secondary: #ffffff;
|
|
||||||
/* ... */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Tailwind references them in `tailwind.config.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export default {
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
bg: {
|
|
||||||
primary: 'var(--color-bg-primary)',
|
|
||||||
secondary: 'var(--color-bg-secondary)',
|
|
||||||
elevated: 'var(--color-bg-elevated)',
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: 'var(--color-accent)',
|
|
||||||
hover: 'var(--color-accent-hover)',
|
|
||||||
},
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Epilogue', 'sans-serif'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
darkMode: 'class', // controlled via data-theme attribute
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Usage in components: `<div class="bg-bg-primary text-text-primary rounded-xl p-4">`.
|
|
||||||
Complex cases use scoped `<style>` inside `.svelte` files.
|
|
||||||
|
|
||||||
### API Client Pattern
|
|
||||||
|
|
||||||
`client.ts` — thin wrapper around fetch:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// $lib/api/client.ts
|
|
||||||
import { authStore } from '$lib/stores/auth';
|
|
||||||
|
|
||||||
const BASE = '/api/v1';
|
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
const token = get(authStore).accessToken;
|
|
||||||
const res = await fetch(BASE + path, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
|
||||||
...init?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (res.status === 401) {
|
|
||||||
// attempt refresh, retry once
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json();
|
|
||||||
throw new ApiError(res.status, err.code, err.message, err.details);
|
|
||||||
}
|
|
||||||
if (res.status === 204) return undefined as T;
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
get: <T>(path: string) => request<T>(path),
|
|
||||||
post: <T>(path: string, body?: unknown) =>
|
|
||||||
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
|
||||||
patch: <T>(path: string, body?: unknown) =>
|
|
||||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
|
||||||
put: <T>(path: string, body?: unknown) =>
|
|
||||||
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
|
||||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
|
||||||
upload: <T>(path: string, formData: FormData) =>
|
|
||||||
request<T>(path, { method: 'POST', body: formData, headers: {} }),
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Domain-specific modules use it:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// $lib/api/files.ts
|
|
||||||
import { api } from './client';
|
|
||||||
import type { File, FileCursorPage } from './types';
|
|
||||||
|
|
||||||
export function listFiles(params: Record<string, string>) {
|
|
||||||
const qs = new URLSearchParams(params).toString();
|
|
||||||
return api.get<FileCursorPage>(`/files?${qs}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function uploadFile(formData: FormData) {
|
|
||||||
return api.upload<File>('/files', formData);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type Generation
|
|
||||||
|
|
||||||
Script in `package.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"scripts": {
|
|
||||||
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
|
|
||||||
"dev": "npm run generate:types && vite dev",
|
|
||||||
"build": "npm run generate:types && vite build"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Friendly aliases in `types.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { components } from './schema';
|
|
||||||
|
|
||||||
export type File = components['schemas']['File'];
|
|
||||||
export type Tag = components['schemas']['Tag'];
|
|
||||||
export type Category = components['schemas']['Category'];
|
|
||||||
export type Pool = components['schemas']['Pool'];
|
|
||||||
export type FileCursorPage = components['schemas']['FileCursorPage'];
|
|
||||||
export type TagOffsetPage = components['schemas']['TagOffsetPage'];
|
|
||||||
export type Error = components['schemas']['Error'];
|
|
||||||
// ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### SPA Mode
|
|
||||||
|
|
||||||
`svelte.config.js`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
import adapter from '@sveltejs/adapter-static';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
kit: {
|
|
||||||
adapter: adapter({ fallback: 'index.html' }),
|
|
||||||
// SPA: all routes handled client-side
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
The Go backend serves `index.html` for all non-API routes (SPA fallback).
|
|
||||||
In development, Vite dev server proxies `/api` to the Go backend.
|
|
||||||
|
|
||||||
### PWA
|
|
||||||
|
|
||||||
`service-worker.ts` handles:
|
|
||||||
- App shell caching (HTML, CSS, JS, fonts)
|
|
||||||
- User-pinned file caching (explicit, via UI button)
|
|
||||||
- Cache versioning and cleanup on update
|
|
||||||
- Reset function (clear all caches except pinned files)
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
# Tanabata File Manager — Go Project Structure
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Router**: Gin
|
|
||||||
- **Database**: pgx v5 (pgxpool)
|
|
||||||
- **Migrations**: goose v3 + go:embed (auto-migrate on startup)
|
|
||||||
- **Auth**: JWT (golang-jwt/jwt/v5)
|
|
||||||
- **Config**: environment variables via .env (joho/godotenv)
|
|
||||||
- **Logging**: slog (stdlib, Go 1.21+)
|
|
||||||
- **Validation**: go-playground/validator/v10
|
|
||||||
- **EXIF**: rwcarlsen/goexif or dsoprea/go-exif
|
|
||||||
- **Image processing**: disintegration/imaging (thumbnails, previews)
|
|
||||||
- **Architecture**: Clean Architecture (domain → service → repository/handler)
|
|
||||||
|
|
||||||
## Monorepo Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
tanabata/
|
|
||||||
├── backend/ ← Go project
|
|
||||||
├── frontend/ ← SvelteKit project
|
|
||||||
├── openapi.yaml ← Shared API contract
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Dockerfile
|
|
||||||
├── .env.example
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backend Directory Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
├── cmd/
|
|
||||||
│ └── server/
|
|
||||||
│ └── main.go # Entrypoint: config → DB → migrate → wire → run
|
|
||||||
│
|
|
||||||
├── internal/
|
|
||||||
│ │
|
|
||||||
│ ├── domain/ # Pure business entities & value objects
|
|
||||||
│ │ ├── file.go # File, FileFilter, FilePage
|
|
||||||
│ │ ├── tag.go # Tag, TagRule
|
|
||||||
│ │ ├── category.go # Category
|
|
||||||
│ │ ├── pool.go # Pool, PoolFile
|
|
||||||
│ │ ├── user.go # User, Session
|
|
||||||
│ │ ├── acl.go # Permission, ObjectType
|
|
||||||
│ │ ├── audit.go # AuditEntry, ActionType
|
|
||||||
│ │ └── errors.go # Domain error types (ErrNotFound, ErrForbidden, etc.)
|
|
||||||
│ │
|
|
||||||
│ ├── port/ # Interfaces (ports) — contracts between layers
|
|
||||||
│ │ ├── repository.go # FileRepo, TagRepo, CategoryRepo, PoolRepo,
|
|
||||||
│ │ │ # UserRepo, SessionRepo, ACLRepo, AuditRepo,
|
|
||||||
│ │ │ # MimeRepo, TagRuleRepo
|
|
||||||
│ │ └── storage.go # FileStorage interface (disk operations)
|
|
||||||
│ │
|
|
||||||
│ ├── service/ # Business logic (use cases)
|
|
||||||
│ │ ├── file_service.go # Upload, update, delete, trash/restore, replace,
|
|
||||||
│ │ │ # import, filter/list, duplicate detection
|
|
||||||
│ │ ├── tag_service.go # CRUD + auto-tag application logic
|
|
||||||
│ │ ├── category_service.go # CRUD (thin, delegates to repo + ACL + audit)
|
|
||||||
│ │ ├── pool_service.go # CRUD + file ordering, add/remove files
|
|
||||||
│ │ ├── auth_service.go # Login, logout, JWT issue/refresh, session management
|
|
||||||
│ │ ├── acl_service.go # Permission checks, grant/revoke
|
|
||||||
│ │ ├── audit_service.go # Log actions, query audit log
|
|
||||||
│ │ └── user_service.go # Profile update, admin CRUD, block/unblock
|
|
||||||
│ │
|
|
||||||
│ ├── handler/ # HTTP layer (Gin handlers)
|
|
||||||
│ │ ├── router.go # Route registration, middleware wiring
|
|
||||||
│ │ ├── middleware.go # Auth middleware (JWT extraction → context)
|
|
||||||
│ │ ├── request.go # Common request parsing helpers
|
|
||||||
│ │ ├── response.go # Error/success response builders
|
|
||||||
│ │ ├── file_handler.go # /files endpoints
|
|
||||||
│ │ ├── tag_handler.go # /tags endpoints
|
|
||||||
│ │ ├── category_handler.go # /categories endpoints
|
|
||||||
│ │ ├── pool_handler.go # /pools endpoints
|
|
||||||
│ │ ├── auth_handler.go # /auth endpoints
|
|
||||||
│ │ ├── acl_handler.go # /acl endpoints
|
|
||||||
│ │ ├── user_handler.go # /users endpoints
|
|
||||||
│ │ └── audit_handler.go # /audit endpoints
|
|
||||||
│ │
|
|
||||||
│ ├── db/ # Database adapters
|
|
||||||
│ │ ├── db.go # Common helpers: pagination, repo factory, transactor base
|
|
||||||
│ │ └── postgres/ # PostgreSQL implementation
|
|
||||||
│ │ ├── postgres.go # pgxpool init, tx-from-context helpers
|
|
||||||
│ │ ├── file_repo.go # FileRepo implementation
|
|
||||||
│ │ ├── tag_repo.go # TagRepo + TagRuleRepo implementation
|
|
||||||
│ │ ├── category_repo.go # CategoryRepo implementation
|
|
||||||
│ │ ├── pool_repo.go # PoolRepo implementation
|
|
||||||
│ │ ├── user_repo.go # UserRepo implementation
|
|
||||||
│ │ ├── session_repo.go # SessionRepo implementation
|
|
||||||
│ │ ├── acl_repo.go # ACLRepo implementation
|
|
||||||
│ │ ├── audit_repo.go # AuditRepo implementation
|
|
||||||
│ │ ├── mime_repo.go # MimeRepo implementation
|
|
||||||
│ │ └── filter_parser.go # DSL → SQL WHERE clause builder
|
|
||||||
│ │
|
|
||||||
│ ├── storage/ # File storage adapter
|
|
||||||
│ │ └── disk.go # FileStorage implementation (read/write/delete on disk)
|
|
||||||
│ │
|
|
||||||
│ └── config/ # Configuration
|
|
||||||
│ └── config.go # Struct + loader from env vars
|
|
||||||
│
|
|
||||||
├── migrations/ # SQL migration files (goose format)
|
|
||||||
│ ├── 001_init_schemas.sql
|
|
||||||
│ ├── 002_core_tables.sql
|
|
||||||
│ ├── 003_data_tables.sql
|
|
||||||
│ ├── 004_acl_tables.sql
|
|
||||||
│ ├── 005_activity_tables.sql
|
|
||||||
│ ├── 006_indexes.sql
|
|
||||||
│ └── 007_seed_data.sql
|
|
||||||
│
|
|
||||||
├── go.mod
|
|
||||||
└── go.sum
|
|
||||||
```
|
|
||||||
|
|
||||||
## Layer Dependency Rules
|
|
||||||
|
|
||||||
```
|
|
||||||
handler → service → port (interfaces) ← db/postgres / storage
|
|
||||||
↓
|
|
||||||
domain (entities, value objects, errors)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **domain/**: zero imports from other internal packages. Only stdlib.
|
|
||||||
- **port/**: imports only domain/. Defines interfaces.
|
|
||||||
- **service/**: imports domain/ and port/. Never imports db/ or handler/.
|
|
||||||
- **handler/**: imports domain/ and service/. Never imports db/.
|
|
||||||
- **db/postgres/**: imports domain/, port/, and db/ (common helpers). Implements port interfaces.
|
|
||||||
- **db/**: imports domain/ and port/. Shared utilities for all DB adapters.
|
|
||||||
- **storage/**: imports domain/ and port/. Implements FileStorage.
|
|
||||||
|
|
||||||
No layer may import a layer above it. No circular dependencies.
|
|
||||||
|
|
||||||
## Key Design Decisions
|
|
||||||
|
|
||||||
### Dependency Injection (Wiring)
|
|
||||||
|
|
||||||
Manual wiring in `cmd/server/main.go`. No DI frameworks.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Pseudocode
|
|
||||||
pool := postgres.NewPool(cfg.DatabaseURL)
|
|
||||||
goose.Up(pool, migrations)
|
|
||||||
|
|
||||||
// Repos (all from internal/db/postgres/)
|
|
||||||
fileRepo := postgres.NewFileRepo(pool)
|
|
||||||
tagRepo := postgres.NewTagRepo(pool)
|
|
||||||
// ...
|
|
||||||
|
|
||||||
// Storage
|
|
||||||
diskStore := storage.NewDiskStorage(cfg.FilesPath)
|
|
||||||
|
|
||||||
// Services
|
|
||||||
aclSvc := service.NewACLService(aclRepo, objectTypeRepo)
|
|
||||||
auditSvc := service.NewAuditService(auditRepo, actionTypeRepo)
|
|
||||||
fileSvc := service.NewFileService(fileRepo, mimeRepo, tagRepo, diskStore, aclSvc, auditSvc)
|
|
||||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc)
|
|
||||||
// ...
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
|
||||||
// ...
|
|
||||||
|
|
||||||
router := handler.NewRouter(cfg, fileHandler, tagHandler, ...)
|
|
||||||
router.Run(cfg.ListenAddr)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Context Propagation
|
|
||||||
|
|
||||||
Every service method receives `context.Context` as the first argument.
|
|
||||||
The handler extracts user info from JWT (via middleware) and puts it
|
|
||||||
into context. Services read the current user from context for ACL checks
|
|
||||||
and audit logging.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// middleware.go
|
|
||||||
func (m *AuthMiddleware) Handle(c *gin.Context) {
|
|
||||||
claims := parseJWT(c.GetHeader("Authorization"))
|
|
||||||
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin)
|
|
||||||
c.Request = c.Request.WithContext(ctx)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// domain/context.go
|
|
||||||
type ctxKey int
|
|
||||||
const userKey ctxKey = iota
|
|
||||||
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context { ... }
|
|
||||||
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Transaction Management
|
|
||||||
|
|
||||||
Repository interfaces include a `Transactor`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// port/repository.go
|
|
||||||
type Transactor interface {
|
|
||||||
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The postgres implementation wraps `pgxpool.Pool.BeginTx`. Inside `fn`,
|
|
||||||
all repo calls use the transaction from context. This allows services
|
|
||||||
to compose multiple repo calls in a single transaction:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// service/file_service.go
|
|
||||||
func (s *FileService) Upload(ctx context.Context, input UploadInput) (*domain.File, error) {
|
|
||||||
return s.tx.WithTx(ctx, func(ctx context.Context) error {
|
|
||||||
file, err := s.fileRepo.Create(ctx, ...) // uses tx
|
|
||||||
if err != nil { return err }
|
|
||||||
for _, tagID := range input.TagIDs {
|
|
||||||
s.tagRepo.AddFileTag(ctx, file.ID, tagID) // same tx
|
|
||||||
}
|
|
||||||
s.auditRepo.Log(ctx, ...) // same tx
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ACL Check Pattern
|
|
||||||
|
|
||||||
ACL logic is centralized in `ACLService`. Other services call it before
|
|
||||||
any data mutation or retrieval:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// service/acl_service.go
|
|
||||||
func (s *ACLService) CanView(ctx context.Context, objectType string, objectID uuid.UUID) error {
|
|
||||||
userID, isAdmin := domain.UserFromContext(ctx)
|
|
||||||
if isAdmin { return nil }
|
|
||||||
// Check is_public on the object
|
|
||||||
// If not public, check creator_id == userID
|
|
||||||
// If not creator, check acl.permissions
|
|
||||||
// Return domain.ErrForbidden if none match
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Mapping
|
|
||||||
|
|
||||||
Domain errors → HTTP status codes (handled in handler/response.go):
|
|
||||||
|
|
||||||
| Domain Error | HTTP Status | Error Code |
|
|
||||||
|-----------------------|-------------|-------------------|
|
|
||||||
| ErrNotFound | 404 | not_found |
|
|
||||||
| ErrForbidden | 403 | forbidden |
|
|
||||||
| ErrUnauthorized | 401 | unauthorized |
|
|
||||||
| ErrConflict | 409 | conflict |
|
|
||||||
| ErrValidation | 400 | validation_error |
|
|
||||||
| ErrUnsupportedMIME | 415 | unsupported_mime |
|
|
||||||
| (unexpected) | 500 | internal_error |
|
|
||||||
|
|
||||||
### Filter DSL
|
|
||||||
|
|
||||||
The DSL parser lives in `db/postgres/filter_parser.go` because it produces
|
|
||||||
SQL WHERE clauses — it is a PostgreSQL-specific adapter concern.
|
|
||||||
The service layer passes the raw DSL string to the repository; the
|
|
||||||
repository parses it and builds the query.
|
|
||||||
|
|
||||||
For a different DBMS, a corresponding parser would live in
|
|
||||||
`db/<dbms>/filter_parser.go`.
|
|
||||||
|
|
||||||
The interface:
|
|
||||||
```go
|
|
||||||
// port/repository.go
|
|
||||||
type FileRepo interface {
|
|
||||||
List(ctx context.Context, params FileListParams) (*domain.FilePage, error)
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// domain/file.go
|
|
||||||
type FileListParams struct {
|
|
||||||
Filter string // raw DSL string
|
|
||||||
Sort string
|
|
||||||
Order string
|
|
||||||
Cursor string
|
|
||||||
Anchor *uuid.UUID
|
|
||||||
Direction string // "forward" or "backward"
|
|
||||||
Limit int
|
|
||||||
Trash bool
|
|
||||||
Search string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### JWT Structure
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Claims struct {
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
UserID int16 `json:"uid"`
|
|
||||||
IsAdmin bool `json:"adm"`
|
|
||||||
SessionID int `json:"sid"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Access token: short-lived (15 min). Refresh token: long-lived (30 days),
|
|
||||||
stored as hash in `activity.sessions.token_hash`.
|
|
||||||
|
|
||||||
### Configuration (.env)
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Server
|
|
||||||
LISTEN_ADDR=:8080
|
|
||||||
JWT_SECRET=<random-32-bytes>
|
|
||||||
JWT_ACCESS_TTL=15m
|
|
||||||
JWT_REFRESH_TTL=720h
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL=postgres://user:pass@host:5432/tanabata?sslmode=disable
|
|
||||||
|
|
||||||
# Storage
|
|
||||||
FILES_PATH=/data/files
|
|
||||||
THUMBS_CACHE_PATH=/data/thumbs
|
|
||||||
|
|
||||||
# Thumbnails
|
|
||||||
THUMB_WIDTH=160
|
|
||||||
THUMB_HEIGHT=160
|
|
||||||
PREVIEW_WIDTH=1920
|
|
||||||
PREVIEW_HEIGHT=1080
|
|
||||||
|
|
||||||
# Import
|
|
||||||
IMPORT_PATH=/data/import
|
|
||||||
```
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"tanabata/internal/storage/postgres"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
postgres.InitDB("postgres://hiko:taikibansei@192.168.0.25/Tanabata_new?application_name=Tanabata%20testing")
|
|
||||||
// test_json := json.RawMessage([]byte("{\"valery\": \"ponosoff\"}"))
|
|
||||||
// data, statusCode, err := db.FileGetSlice(1, "", "+2", -2, 0)
|
|
||||||
// data, statusCode, err := db.FileGet(1, "0197d056-cfb0-76b5-97e0-bd588826393c")
|
|
||||||
// data, statusCode, err := db.FileAdd(1, "ABOBA.png", "image/png", time.Now(), "slkdfjsldkflsdkfj;sldkf", test_json)
|
|
||||||
// statusCode, err := db.FileUpdate(2, "0197d159-bf3a-7617-a3a8-a4a9fc39eca6", map[string]interface{}{
|
|
||||||
// "name": "ponos.png",
|
|
||||||
// })
|
|
||||||
// statusCode, err := db.FileDelete(1, "0197d155-848f-7221-ba4a-4660f257c7d5")
|
|
||||||
// v, e, err := postgres.FileGetAccess(1, "0197d15a-57f9-712c-991e-c512290e774f")
|
|
||||||
// fmt.Printf("V: %s, E: %s\n", v, e)
|
|
||||||
// fmt.Printf("Status: %d\n", statusCode)
|
|
||||||
// fmt.Printf("Error: %s\n", err)
|
|
||||||
// fmt.Printf("%+v\n", data)
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"tanabata/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
db.InitDB("postgres://hiko:taikibansei@192.168.0.25/Tanabata_new?application_name=Tanabata%20testing")
|
|
||||||
// test_json := json.RawMessage([]byte("{\"valery\": \"ponosoff\"}"))
|
|
||||||
// data, statusCode, err := db.FileGetSlice(2, "", "+2", -2, 0)
|
|
||||||
// data, statusCode, err := db.FileGet(1, "0197d056-cfb0-76b5-97e0-bd588826393c")
|
|
||||||
// data, statusCode, err := db.FileAdd(1, "ABOBA.png", "image/png", time.Now(), "slkdfjsldkflsdkfj;sldkf", test_json)
|
|
||||||
// statusCode, err := db.FileUpdate(2, "0197d159-bf3a-7617-a3a8-a4a9fc39eca6", map[string]interface{}{
|
|
||||||
// "name": "ponos.png",
|
|
||||||
// })
|
|
||||||
statusCode, err := db.FileDelete(1, "0197d155-848f-7221-ba4a-4660f257c7d5")
|
|
||||||
fmt.Printf("Status: %d\n", statusCode)
|
|
||||||
fmt.Printf("Error: %s\n", err)
|
|
||||||
// fmt.Printf("%+v\n", data)
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
var connPool *pgxpool.Pool
|
|
||||||
|
|
||||||
func InitDB(connString string) error {
|
|
||||||
poolConfig, err := pgxpool.ParseConfig(connString)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error while parsing connection string: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
poolConfig.MaxConns = 100
|
|
||||||
poolConfig.MinConns = 0
|
|
||||||
poolConfig.MaxConnLifetime = time.Hour
|
|
||||||
poolConfig.HealthCheckPeriod = 30 * time.Second
|
|
||||||
|
|
||||||
connPool, err = pgxpool.NewWithConfig(context.Background(), poolConfig)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error while initializing DB connections pool: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func transaction(handler func(context.Context, pgx.Tx) (statusCode int, err error)) (statusCode int, err error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
tx, err := connPool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusCode, err = handler(ctx, tx)
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback(ctx)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = tx.Commit(ctx)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle database error
|
|
||||||
func handleDBError(errIn error) (statusCode int, err error) {
|
|
||||||
if errIn == nil {
|
|
||||||
statusCode = http.StatusOK
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if errors.Is(errIn, pgx.ErrNoRows) {
|
|
||||||
err = fmt.Errorf("not found")
|
|
||||||
statusCode = http.StatusNotFound
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(errIn, &pgErr) {
|
|
||||||
switch pgErr.Code {
|
|
||||||
case "22P02", "22007": // Invalid data format
|
|
||||||
err = fmt.Errorf("%s", pgErr.Message)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
case "23505": // Unique constraint violation
|
|
||||||
err = fmt.Errorf("already exists")
|
|
||||||
statusCode = http.StatusConflict
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return http.StatusInternalServerError, errIn
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Convert "filter" URL param to SQL "WHERE" condition
|
|
||||||
func filterToSQL(filter string) (sql string, statusCode int, err error) {
|
|
||||||
// filterTokens := strings.Split(string(filter), ";")
|
|
||||||
sql = "(true)"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert "sort" URL param to SQL "ORDER BY"
|
|
||||||
func sortToSQL(sort string) (sql string, statusCode int, err error) {
|
|
||||||
if sort == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sortOptions := strings.Split(sort, ",")
|
|
||||||
sql = " ORDER BY "
|
|
||||||
for i, sortOption := range sortOptions {
|
|
||||||
sortOrder := sortOption[:1]
|
|
||||||
sortColumn := sortOption[1:]
|
|
||||||
// parse sorting order marker
|
|
||||||
switch sortOrder {
|
|
||||||
case "+":
|
|
||||||
sortOrder = "ASC"
|
|
||||||
case "-":
|
|
||||||
sortOrder = "DESC"
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("invalid sorting order mark: %q", sortOrder)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// validate sorting column
|
|
||||||
var n int
|
|
||||||
n, err = strconv.Atoi(sortColumn)
|
|
||||||
if err != nil || n < 0 {
|
|
||||||
err = fmt.Errorf("invalid sorting column: %q", sortColumn)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// add sorting option to query
|
|
||||||
if i > 0 {
|
|
||||||
sql += ","
|
|
||||||
}
|
|
||||||
sql += fmt.Sprintf("%s %s NULLS LAST", sortColumn, sortOrder)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
module tanabata
|
|
||||||
|
|
||||||
go 1.23.0
|
|
||||||
|
|
||||||
toolchain go1.23.10
|
|
||||||
|
|
||||||
require github.com/jackc/pgx/v5 v5.7.5
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/pgx v3.6.2+incompatible // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
|
||||||
github.com/stretchr/testify v1.9.0 // indirect
|
|
||||||
golang.org/x/crypto v0.37.0 // indirect
|
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
|
||||||
golang.org/x/text v0.24.0 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=
|
|
||||||
github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
|
||||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
|
||||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
|
||||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
IsAdmin bool `json:"isAdmin"`
|
|
||||||
CanCreate bool `json:"canCreate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MIME struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Extension string `json:"extension"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type (
|
|
||||||
CategoryCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Color pgtype.Text `json:"color"`
|
|
||||||
}
|
|
||||||
CategoryItem struct {
|
|
||||||
CategoryCore
|
|
||||||
}
|
|
||||||
CategoryFull struct {
|
|
||||||
CategoryCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes pgtype.Text `json:"notes"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
FileCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name pgtype.Text `json:"name"`
|
|
||||||
MIME MIME `json:"mime"`
|
|
||||||
}
|
|
||||||
FileItem struct {
|
|
||||||
FileCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
}
|
|
||||||
FileFull struct {
|
|
||||||
FileCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes pgtype.Text `json:"notes"`
|
|
||||||
Metadata json.RawMessage `json:"metadata"`
|
|
||||||
Tags []TagCore `json:"tags"`
|
|
||||||
Viewed int `json:"viewed"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
TagCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Color pgtype.Text `json:"color"`
|
|
||||||
}
|
|
||||||
TagItem struct {
|
|
||||||
TagCore
|
|
||||||
Category CategoryCore `json:"category"`
|
|
||||||
}
|
|
||||||
TagFull struct {
|
|
||||||
TagCore
|
|
||||||
Category CategoryCore `json:"category"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes pgtype.Text `json:"notes"`
|
|
||||||
UsedIncl int `json:"usedIncl"`
|
|
||||||
UsedExcl int `json:"usedExcl"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type Autotag struct {
|
|
||||||
TriggerTag TagCore `json:"triggerTag"`
|
|
||||||
AddTag TagCore `json:"addTag"`
|
|
||||||
IsActive bool `json:"isActive"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type (
|
|
||||||
PoolCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
PoolItem struct {
|
|
||||||
PoolCore
|
|
||||||
}
|
|
||||||
PoolFull struct {
|
|
||||||
PoolCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes pgtype.Text `json:"notes"`
|
|
||||||
Viewed int `json:"viewed"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type Session struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
UserAgent string `json:"userAgent"`
|
|
||||||
StartedAt time.Time `json:"startedAt"`
|
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
|
||||||
LastActivity time.Time `json:"lastActivity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Pagination struct {
|
|
||||||
Total int `json:"total"`
|
|
||||||
Offset int `json:"offset"`
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Slice[T any] struct {
|
|
||||||
Pagination Pagination `json:"pagination"`
|
|
||||||
Data []T `json:"data"`
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
func UserLogin(ctx context.Context, name, password string) (user_id int, err error) {
|
|
||||||
row := connPool.QueryRow(ctx, "SELECT id FROM users WHERE name=$1 AND password=crypt($2, password)", name, password)
|
|
||||||
err = row.Scan(&user_id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func UserAuth(ctx context.Context, user_id int) (ok, isAdmin bool) {
|
|
||||||
row := connPool.QueryRow(ctx, "SELECT is_admin FROM users WHERE id=$1", user_id)
|
|
||||||
err := row.Scan(&isAdmin)
|
|
||||||
ok = (err == nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FileStore struct {
|
|
||||||
db *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFileStore(db *pgxpool.Pool) *FileStore {
|
|
||||||
return &FileStore{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user's access rights to file
|
|
||||||
func (s *FileStore) getAccess(user_id int, file_id string) (canView, canEdit bool, err error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
row := connPool.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
COALESCE(a.view, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE),
|
|
||||||
COALESCE(a.edit, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE)
|
|
||||||
FROM data.files f
|
|
||||||
LEFT JOIN acl.files a ON a.file_id=f.id AND a.user_id=$1
|
|
||||||
LEFT JOIN system.users u ON u.id=$1
|
|
||||||
WHERE f.id=$2
|
|
||||||
`, user_id, file_id)
|
|
||||||
err = row.Scan(&canView, &canEdit)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a set of files
|
|
||||||
func (s *FileStore) GetSlice(user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], statusCode int, err error) {
|
|
||||||
filterCond, statusCode, err := filterToSQL(filter)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sortExpr, statusCode, err := sortToSQL(sort)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// prepare query
|
|
||||||
query := `
|
|
||||||
SELECT
|
|
||||||
f.id,
|
|
||||||
f.name,
|
|
||||||
m.name,
|
|
||||||
m.extension,
|
|
||||||
uuid_extract_timestamp(f.id),
|
|
||||||
u.name,
|
|
||||||
u.is_admin
|
|
||||||
FROM data.files f
|
|
||||||
JOIN system.mime m ON m.id=f.mime_id
|
|
||||||
JOIN system.users u ON u.id=f.creator_id
|
|
||||||
WHERE NOT f.is_deleted AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=f.id AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1)) AND
|
|
||||||
`
|
|
||||||
query += filterCond
|
|
||||||
queryCount := query
|
|
||||||
query += sortExpr
|
|
||||||
if limit >= 0 {
|
|
||||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
|
||||||
}
|
|
||||||
if offset > 0 {
|
|
||||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
|
||||||
}
|
|
||||||
// execute query
|
|
||||||
statusCode, err = transaction(func(ctx context.Context, tx pgx.Tx) (statusCode int, err error) {
|
|
||||||
rows, err := tx.Query(ctx, query, user_id)
|
|
||||||
if err != nil {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
count := 0
|
|
||||||
for rows.Next() {
|
|
||||||
var file domain.FileItem
|
|
||||||
err = rows.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
files.Data = append(files.Data, file)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
files.Pagination.Limit = limit
|
|
||||||
files.Pagination.Offset = offset
|
|
||||||
files.Pagination.Count = count
|
|
||||||
row := tx.QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM (%s) tmp", queryCount), user_id)
|
|
||||||
err = row.Scan(&files.Pagination.Total)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
}
|
|
||||||
return
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
statusCode = http.StatusOK
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file
|
|
||||||
func (s *FileStore) Get(user_id int, file_id string) (file domain.FileFull, statusCode int, err error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
row := connPool.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
f.id,
|
|
||||||
f.name,
|
|
||||||
m.name,
|
|
||||||
m.extension,
|
|
||||||
uuid_extract_timestamp(f.id),
|
|
||||||
u.name,
|
|
||||||
u.is_admin,
|
|
||||||
f.notes,
|
|
||||||
f.metadata,
|
|
||||||
(SELECT COUNT(*) FROM activity.file_views fv WHERE fv.file_id=$2 AND fv.user_id=$1)
|
|
||||||
FROM data.files f
|
|
||||||
JOIN system.mime m ON m.id=f.mime_id
|
|
||||||
JOIN system.users u ON u.id=f.creator_id
|
|
||||||
WHERE NOT f.is_deleted AND f.id=$2 AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))
|
|
||||||
`, user_id, file_id)
|
|
||||||
err = row.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin, &file.Notes, &file.Metadata, &file.Viewed)
|
|
||||||
if err != nil {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rows, err := connPool.Query(ctx, `
|
|
||||||
SELECT
|
|
||||||
t.id,
|
|
||||||
t.name,
|
|
||||||
COALESCE(t.color, c.color)
|
|
||||||
FROM data.tags t
|
|
||||||
LEFT JOIN data.categories c ON c.id=t.category_id
|
|
||||||
JOIN data.file_tag ft ON ft.tag_id=t.id
|
|
||||||
WHERE ft.file_id=$1
|
|
||||||
`, file_id)
|
|
||||||
if err != nil {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var tag domain.TagCore
|
|
||||||
err = rows.Scan(&tag.ID, &tag.Name, &tag.Color)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file.Tags = append(file.Tags, tag)
|
|
||||||
}
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusCode = http.StatusOK
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add file
|
|
||||||
func (s *FileStore) Add(user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, statusCode int, err error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
var mime_id int
|
|
||||||
var extension string
|
|
||||||
row := connPool.QueryRow(ctx, "SELECT id, extension FROM system.mime WHERE name=$1", mime)
|
|
||||||
err = row.Scan(&mime_id, &extension)
|
|
||||||
if err != nil {
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
err = fmt.Errorf("unsupported file type: %q", mime)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
} else {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
row = connPool.QueryRow(ctx, `
|
|
||||||
INSERT INTO data.files (name, mime_id, datetime, creator_id, notes, metadata)
|
|
||||||
VALUES (NULLIF($1, ''), $2, $3, $4, NULLIF($5 ,''), $6)
|
|
||||||
RETURNING id
|
|
||||||
`, name, mime_id, datetime, user_id, notes, metadata)
|
|
||||||
err = row.Scan(&file.ID)
|
|
||||||
if err != nil {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file.Name.String = name
|
|
||||||
file.Name.Valid = (name != "")
|
|
||||||
file.MIME.Name = mime
|
|
||||||
file.MIME.Extension = extension
|
|
||||||
statusCode = http.StatusOK
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update file
|
|
||||||
func (s *FileStore) Update(user_id int, file_id string, updates map[string]interface{}) (statusCode int, err error) {
|
|
||||||
if len(updates) == 0 {
|
|
||||||
err = fmt.Errorf("no fields provided for update")
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writableFields := map[string]bool{
|
|
||||||
"name": true,
|
|
||||||
"datetime": true,
|
|
||||||
"notes": true,
|
|
||||||
"metadata": true,
|
|
||||||
}
|
|
||||||
query := "UPDATE data.files SET"
|
|
||||||
newValues := []interface{}{user_id}
|
|
||||||
count := 2
|
|
||||||
for field, value := range updates {
|
|
||||||
if !writableFields[field] {
|
|
||||||
err = fmt.Errorf("invalid field: %q", field)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
}
|
|
||||||
query += fmt.Sprintf(" %s=NULLIF($%d, '')", field, count)
|
|
||||||
newValues = append(newValues, value)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
query += fmt.Sprintf(
|
|
||||||
" WHERE id=$%d AND (creator_id=$1 OR (SELECT edit FROM acl.files WHERE file_id=$%d AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))",
|
|
||||||
count, count)
|
|
||||||
newValues = append(newValues, file_id)
|
|
||||||
ctx := context.Background()
|
|
||||||
commandTag, err := connPool.Exec(ctx, query, newValues...)
|
|
||||||
if err != nil {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if commandTag.RowsAffected() == 0 {
|
|
||||||
err = fmt.Errorf("not found")
|
|
||||||
statusCode = http.StatusNotFound
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusCode = http.StatusNoContent
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete file
|
|
||||||
func (s *FileStore) Delete(user_id int, file_id string) (statusCode int, err error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
commandTag, err := connPool.Exec(ctx,
|
|
||||||
"DELETE FROM data.files WHERE id=$2 AND (creator_id=$1 OR (SELECT edit FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))",
|
|
||||||
user_id, file_id)
|
|
||||||
if err != nil {
|
|
||||||
statusCode, err = handleDBError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if commandTag.RowsAffected() == 0 {
|
|
||||||
err = fmt.Errorf("not found")
|
|
||||||
statusCode = http.StatusNotFound
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusCode = http.StatusNoContent
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Storage struct {
|
|
||||||
db *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
var connPool *pgxpool.Pool
|
|
||||||
|
|
||||||
// Initialize new database storage
|
|
||||||
func New(dbURL string) (*Storage, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
config, err := pgxpool.ParseConfig(dbURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse DB URL: %w", err)
|
|
||||||
}
|
|
||||||
config.MaxConns = 10
|
|
||||||
config.MinConns = 2
|
|
||||||
config.HealthCheckPeriod = time.Minute
|
|
||||||
db, err := pgxpool.NewWithConfig(ctx, config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
|
||||||
}
|
|
||||||
err = db.Ping(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("database ping failed: %w", err)
|
|
||||||
}
|
|
||||||
return &Storage{db: db}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close database storage
|
|
||||||
func (s *Storage) Close() {
|
|
||||||
s.db.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run handler inside transaction
|
|
||||||
func (s *Storage) transaction(ctx context.Context, handler func(context.Context, pgx.Tx) (statusCode int, err error)) (statusCode int, err error) {
|
|
||||||
tx, err := connPool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusCode, err = handler(ctx, tx)
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback(ctx)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = tx.Commit(ctx)
|
|
||||||
if err != nil {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle database error
|
|
||||||
func (s *Storage) handleDBError(errIn error) (statusCode int, err error) {
|
|
||||||
if errIn == nil {
|
|
||||||
statusCode = http.StatusOK
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if errors.Is(errIn, pgx.ErrNoRows) {
|
|
||||||
err = fmt.Errorf("not found")
|
|
||||||
statusCode = http.StatusNotFound
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(errIn, &pgErr) {
|
|
||||||
switch pgErr.Code {
|
|
||||||
case "22P02", "22007": // Invalid data format
|
|
||||||
err = fmt.Errorf("%s", pgErr.Message)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
case "23505": // Unique constraint violation
|
|
||||||
err = fmt.Errorf("already exists")
|
|
||||||
statusCode = http.StatusConflict
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return http.StatusInternalServerError, errIn
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Convert "filter" URL param to SQL "WHERE" condition
|
|
||||||
func filterToSQL(filter string) (sql string, statusCode int, err error) {
|
|
||||||
// filterTokens := strings.Split(string(filter), ";")
|
|
||||||
sql = "(true)"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert "sort" URL param to SQL "ORDER BY"
|
|
||||||
func sortToSQL(sort string) (sql string, statusCode int, err error) {
|
|
||||||
if sort == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sortOptions := strings.Split(sort, ",")
|
|
||||||
sql = " ORDER BY "
|
|
||||||
for i, sortOption := range sortOptions {
|
|
||||||
sortOrder := sortOption[:1]
|
|
||||||
sortColumn := sortOption[1:]
|
|
||||||
// parse sorting order marker
|
|
||||||
switch sortOrder {
|
|
||||||
case "+":
|
|
||||||
sortOrder = "ASC"
|
|
||||||
case "-":
|
|
||||||
sortOrder = "DESC"
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("invalid sorting order mark: %q", sortOrder)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// validate sorting column
|
|
||||||
var n int
|
|
||||||
n, err = strconv.Atoi(sortColumn)
|
|
||||||
if err != nil || n < 0 {
|
|
||||||
err = fmt.Errorf("invalid sorting column: %q", sortColumn)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// add sorting option to query
|
|
||||||
if i > 0 {
|
|
||||||
sql += ","
|
|
||||||
}
|
|
||||||
sql += fmt.Sprintf("%s %s NULLS LAST", sortColumn, sortOrder)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"tanabata/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Storage interface {
|
|
||||||
FileRepository
|
|
||||||
Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileRepository interface {
|
|
||||||
GetSlice(user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], statusCode int, err error)
|
|
||||||
Get(user_id int, file_id string) (file domain.FileFull, statusCode int, err error)
|
|
||||||
Add(user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, statusCode int, err error)
|
|
||||||
Update(user_id int, file_id string, updates map[string]interface{}) (statusCode int, err error)
|
|
||||||
Delete(user_id int, file_id string) (statusCode int, err error)
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
IsAdmin bool `json:"isAdmin"`
|
|
||||||
CanCreate bool `json:"canCreate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MIME struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Extension string `json:"extension"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type (
|
|
||||||
CategoryCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Color pgtype.Text `json:"color"`
|
|
||||||
}
|
|
||||||
CategoryItem struct {
|
|
||||||
CategoryCore
|
|
||||||
}
|
|
||||||
CategoryFull struct {
|
|
||||||
CategoryCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes pgtype.Text `json:"notes"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
FileCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name pgtype.Text `json:"name"`
|
|
||||||
MIME MIME `json:"mime"`
|
|
||||||
}
|
|
||||||
FileItem struct {
|
|
||||||
FileCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
}
|
|
||||||
FileFull struct {
|
|
||||||
FileCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes pgtype.Text `json:"notes"`
|
|
||||||
Metadata json.RawMessage `json:"metadata"`
|
|
||||||
Tags []TagCore `json:"tags"`
|
|
||||||
Viewed int `json:"viewed"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
TagCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Color pgtype.Text `json:"color"`
|
|
||||||
}
|
|
||||||
TagItem struct {
|
|
||||||
TagCore
|
|
||||||
Category CategoryCore `json:"category"`
|
|
||||||
}
|
|
||||||
TagFull struct {
|
|
||||||
TagCore
|
|
||||||
Category CategoryCore `json:"category"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes pgtype.Text `json:"notes"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type Autotag struct {
|
|
||||||
TriggerTag TagCore `json:"triggerTag"`
|
|
||||||
AddTag TagCore `json:"addTag"`
|
|
||||||
IsActive bool `json:"isActive"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type (
|
|
||||||
PoolCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
PoolItem struct {
|
|
||||||
PoolCore
|
|
||||||
}
|
|
||||||
PoolFull struct {
|
|
||||||
PoolCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes pgtype.Text `json:"notes"`
|
|
||||||
Viewed int `json:"viewed"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type Session struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
UserAgent string `json:"userAgent"`
|
|
||||||
StartedAt time.Time `json:"startedAt"`
|
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
|
||||||
LastActivity time.Time `json:"lastActivity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Pagination struct {
|
|
||||||
Total int `json:"total"`
|
|
||||||
Offset int `json:"offset"`
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Slice[T any] struct {
|
|
||||||
Pagination Pagination `json:"pagination"`
|
|
||||||
Data []T `json:"data"`
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
## О проекте
|
|
||||||
|
|
||||||
Tanabata File Manager или сокращенно TFM — многопользовательский веб-файловый менеджер, организующий файлы по тегам. Работает на клиент-серверной архитектуре, управляется через веб-интерфейс. Главная цель проекта — обеспечить централизованное хранение файлов на сервере, доступ к ним и управление ими через веб как с компьютера, так и со смартфона. В первую очередь данное приложение ориентировано на изображения и видео.
|
|
||||||
|
|
||||||
## Общая архитектура
|
|
||||||
|
|
||||||
- File storage
|
|
||||||
- Relational database (PostgreSQL)
|
|
||||||
- REST API service (Go)
|
|
||||||
- Frontend (SvelteKit)
|
|
||||||
|
|
||||||
Приложение предполагается разворачивать внутри контейнера Docker. Фронтенд и бэкенд - в одном контейнере, СУБД - отдельно (на моем сервере планируется подключать к СУБД на хосте). Все файлы, управляемые Танабатой, будут храниться кучей в одной папке. Имя файла на диске совпадает с его UUID в БД.
|
|
||||||
|
|
||||||
Приложение является PWA, которое можно установить на компьютер или смартфон.
|
|
||||||
|
|
||||||
В будущих версиях планируется введение поддержки других СУБД.
|
|
||||||
|
|
||||||
## Основные понятия
|
|
||||||
|
|
||||||
**Файл** — один файл на сервере. Может иметь сколько угодно тегов, может принадлежать скольким угодно пулам. Имеет автора, а также может иметь настройки доступа (пользователь (может быть null - таким образом можно делать файл публичным), флаг права на чтение, флаг права на изменение). Имеет оригинальное название и метаданные (ключ-значение, в том числе все данные EXIF).
|
|
||||||
|
|
||||||
**Тег** — метка файла. Может быть привязан к скольким угодно файлам, может быть привязан к одной категории. Имеет название, описание, метаданные (ключ-значение). Может иметь автотеги.
|
|
||||||
|
|
||||||
**Автотег** — правило, согласно которому при привязке к файлу условного тега А к этому же файлу автоматически привязывается условный тег Б.
|
|
||||||
|
|
||||||
**Категория** — сущность, логически объединяющая собой несколько тегов. Имеет название, описание, метаданные (ключ-значение).
|
|
||||||
|
|
||||||
**Пул** — логическое объединение файлов. Имеет название, описание, метаданные (ключ-значение). Файлы внутри могут быть как отсортированы автоматически, так и расположены в порядке, заданном пользователем вручную.
|
|
||||||
|
|
||||||
## Функциональные требования
|
|
||||||
|
|
||||||
1. Управление файлами
|
|
||||||
1. Просмотр списка файлов (lazy load, pagination)
|
|
||||||
2. Фильтрация файлов по тегам и метаданным
|
|
||||||
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
|
|
||||||
4. Выделение нескольких файлов (Ctrl, Shift) и действия с ними
|
|
||||||
1. Привязка/отвязка тегов
|
|
||||||
2. Копирование/вставка тегов
|
|
||||||
3. Добавление в пул
|
|
||||||
4. Просмотр и редактирование настроек доступа
|
|
||||||
5. Удаление (с запросом подтверждения)
|
|
||||||
5. Просмотр одного файла
|
|
||||||
6. Действия с одним файлом
|
|
||||||
1. Привязка/отвязка тегов
|
|
||||||
2. Копирование/вставка тегов
|
|
||||||
3. Добавление в пул
|
|
||||||
4. Просмотр и редактирование настроек доступа
|
|
||||||
5. Замена файла (загрузка нового под таким же ID)
|
|
||||||
6. Удаление (с запросом подтверждения)
|
|
||||||
7. Листание файлов, как в галерее
|
|
||||||
8. Загрузка новых файлов через веб-интерфейс (через форму или drag-n-drop прямо на список)
|
|
||||||
9. Импорт новых файлов из папки на сервере
|
|
||||||
10. Выявление дубликатов, в частности, изображений и видео
|
|
||||||
1. Отображение групп дубликатов
|
|
||||||
2. Возможность отвязывания фальшивых дубликатов (чтобы приложение запомнило, что изображение А не является дубликатом изображения Б)
|
|
||||||
3. Возможность выбора дубликата для удаления/сохранения
|
|
||||||
4. Возможность выбора, какие поля от какого дубликата подтягивать
|
|
||||||
11. Корзина
|
|
||||||
1. Просмотр файлов в корзине
|
|
||||||
2. Восстановление из корзины
|
|
||||||
3. Окончательное удаление
|
|
||||||
2. Управление тегами
|
|
||||||
1. Просмотр списка тегов (lazy load, pagination)
|
|
||||||
2. Поиск по названию
|
|
||||||
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
|
|
||||||
4. Выделение нескольких тегов (Ctrl, Shift) и действия с ними
|
|
||||||
1. Назначение автотегов
|
|
||||||
2. Изменение категории
|
|
||||||
3. Удаление (с запросом подтверждения)
|
|
||||||
5. Просмотр одного тега
|
|
||||||
6. Действия с одним тегом
|
|
||||||
1. Редактирование названия, описания и метаданных (ключ-значение)
|
|
||||||
2. Изменение категории
|
|
||||||
3. Назначение автотегов
|
|
||||||
4. Удаление (с запросом подтверждения)
|
|
||||||
7. Создание тега
|
|
||||||
1. Внесение названия, описания и метаданных (ключ-значение)
|
|
||||||
2. Назначение категории (опционально)
|
|
||||||
3. Назначение автотегов
|
|
||||||
3. Управление категориями
|
|
||||||
1. Просмотр списка категорий (lazy load, pagination)
|
|
||||||
2. Поиск по названию
|
|
||||||
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
|
|
||||||
4. Выделение нескольких категорий (Ctrl, Shift) и действия с ними
|
|
||||||
1. Просмотр привязанных общих тегов и тегов, привязанных к некоторым, но не ко всем
|
|
||||||
2. Привязка/отвязка тегов
|
|
||||||
3. Удаление (с запросом подтверждения)
|
|
||||||
5. Просмотр одной категории
|
|
||||||
6. Действия с одной категорией
|
|
||||||
1. Редактирование названия, описания и метаданных (ключ-значение)
|
|
||||||
2. Просмотр привязанных тегов
|
|
||||||
3. Привязка/отвязка тегов
|
|
||||||
4. Удаление (с запросом подтверждения)
|
|
||||||
7. Создание категории
|
|
||||||
1. Внесение названия, описания и метаданных (ключ-значение)
|
|
||||||
2. Привязка тегов
|
|
||||||
4. Управление пулами
|
|
||||||
1. Просмотр списка пулов (lazy load, pagination)
|
|
||||||
2. Поиск по названию
|
|
||||||
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
|
|
||||||
4. Выделение нескольких пулов (Ctrl, Shift) и действия с ними
|
|
||||||
1. Просмотр и редактирование настроек доступа
|
|
||||||
2. Удаление (с запросом подтверждения)
|
|
||||||
5. Просмотр одного пула
|
|
||||||
6. Действия с одним пулом
|
|
||||||
1. Редактирование названия, описания и метаданных (ключ-значение)
|
|
||||||
2. Просмотр и редактирование настроек доступа
|
|
||||||
3. Просмотр всех файлов, входящих в пул
|
|
||||||
4. Фильтрация файлов по тегам
|
|
||||||
5. Изменение настройки сортировки файлов (в том числе можно отключить автоматическую сортировку)
|
|
||||||
6. Ручное изменение порядка файлов (при отключенной сортировке)
|
|
||||||
7. Удаление (с запросом подтверждения)
|
|
||||||
7. Создание категории
|
|
||||||
1. Внесение названия, описания и метаданных (ключ-значение)
|
|
||||||
2. Привязка тегов
|
|
||||||
5. Управление пользовательскими настройками
|
|
||||||
1. Имя пользователя
|
|
||||||
2. Пароль
|
|
||||||
3. Сессии
|
|
||||||
1. Завершение сессии
|
|
||||||
4. Путь к папке на сервере, которая будет сканироваться при импорта файлов
|
|
||||||
6. Управление настройками сервера (админка)
|
|
||||||
1. Пользователи
|
|
||||||
1. Просмотр списка
|
|
||||||
2. Просмотр одного
|
|
||||||
3. Создание
|
|
||||||
4. Удаление
|
|
||||||
5. Блокировка/разблокировка
|
|
||||||
6. Установка роли (читатель/редактор)
|
|
||||||
7. Журналирование пользовательских действий в БД
|
|
||||||
1. Просмотры файлов
|
|
||||||
2. Смены настроек доступа к файлам
|
|
||||||
3. Создание/редактирование/удаление файла, тега, категории, пула, связи файл-тег
|
|
||||||
4. Создание/блокировка/разблокировка/удаление пользователя
|
|
||||||
5. Смена роли пользователя
|
|
||||||
6. Авторизация/логаут пользователя
|
|
||||||
7. Завершение сессии
|
|
||||||
|
|
||||||
## Нефункциональные требования
|
|
||||||
|
|
||||||
1. Интерфейс должен быть максимально простым и удобным, все необходимое должно быть под рукой, доступным за минимальное количество действий
|
|
||||||
2. Интерфейс должен быть адаптирован под десктоп и под мобильные устройства
|
|
||||||
3. Интерфейс должен иметь темную и светлую темы
|
|
||||||
4. Использование технологии PWA (также должна быть кнопка, при нажатии которой PWA будет полностью сбрасываться (кроме кэша) и заново загружаться с сервера)
|
|
||||||
5. Возможность сохранять некоторые файлы в кэш и просматривать их оффлайн при использовании установленного PWA
|
|
||||||
6. При первичном запуске приложение должно требовать минимума действий: автоматическая миграция БД, заранее готовый файл docker compose, файл .env с настраиваемыми параметрами установки
|
|
||||||
7. Использование подхода DDD для сервера API
|
|
||||||
8. Не принимать файлы, чей MIME отсутствует в БД (нет в БД — нет поддержки)
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
engine-strict=true
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["svelte.svelte-vscode"]
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// 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 {};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1 +0,0 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<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()}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<h1>Welcome to SvelteKit</h1>
|
|
||||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# allow crawling everything by default
|
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [tailwindcss(), sveltekit()]
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
blinker==1.9.0
|
||||||
|
certifi==2024.12.14
|
||||||
|
charset-normalizer==3.4.1
|
||||||
|
click==8.1.8
|
||||||
|
ffmpeg-python==0.2.0
|
||||||
|
filelock==3.16.1
|
||||||
|
Flask==3.1.0
|
||||||
|
Flask-Cors==5.0.0
|
||||||
|
future==1.0.0
|
||||||
|
idna==3.10
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.5
|
||||||
|
MarkupSafe==3.0.2
|
||||||
|
preview_generator==0.29
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
|
pyexifinfo==0.4.0
|
||||||
|
pyTelegramBotAPI==4.25.0
|
||||||
|
python-magic==0.4.27
|
||||||
|
pytz==2024.2
|
||||||
|
requests==2.32.3
|
||||||
|
ua-parser==1.0.0
|
||||||
|
ua-parser-builtins==0.18.0.post1
|
||||||
|
urllib3==2.3.0
|
||||||
|
Wand==0.6.13
|
||||||
|
Werkzeug==3.1.3
|
||||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |