Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 780f85de59 |
@@ -0,0 +1,55 @@
|
|||||||
|
# Tanabata File Manager
|
||||||
|
|
||||||
|
Multi-user, tag-based web file manager for images and video.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Monorepo: `backend/` (Go) + `frontend/` (SvelteKit).
|
||||||
|
|
||||||
|
- Backend: Go + Gin + pgx v5 + goose migrations. Clean Architecture.
|
||||||
|
- Frontend: SvelteKit SPA + Tailwind CSS + CSS custom properties.
|
||||||
|
- DB: PostgreSQL 14+.
|
||||||
|
- Auth: JWT Bearer tokens.
|
||||||
|
|
||||||
|
## Key documents (read before coding)
|
||||||
|
|
||||||
|
- `openapi.yaml` — full REST API specification (36 paths, 58 operations)
|
||||||
|
- `docs/GO_PROJECT_STRUCTURE.md` — backend architecture, layer rules, DI pattern
|
||||||
|
- `docs/FRONTEND_STRUCTURE.md` — frontend architecture, CSS approach, API client
|
||||||
|
- `docs/Описание.md` — product requirements in Russian
|
||||||
|
- `backend/migrations/001_init.sql` — database schema (4 schemas, 16 tables)
|
||||||
|
|
||||||
|
## Design reference
|
||||||
|
|
||||||
|
The `docs/reference/` directory contains the previous Python/Flask version.
|
||||||
|
Use its visual design as the basis for the new frontend:
|
||||||
|
- Color palette: #312F45 (bg), #9592B5 (accent), #444455 (tag default), #111118 (elevated)
|
||||||
|
- Font: Epilogue (variable weight)
|
||||||
|
- Dark theme is primary
|
||||||
|
- Mobile-first layout with bottom navbar
|
||||||
|
- 160×160 thumbnail grid for files
|
||||||
|
- Colored tag pills
|
||||||
|
- Floating selection bar for multi-select
|
||||||
|
|
||||||
|
## Backend commands
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go run ./cmd/server # run dev server
|
||||||
|
go test ./... # run all tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend commands
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev # vite dev server
|
||||||
|
npm run build # production build
|
||||||
|
npm run generate:types # regenerate API types from openapi.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Go: gofmt, no global state, context.Context as first param in all service methods
|
||||||
|
- TypeScript: strict mode, named exports
|
||||||
|
- SQL: snake_case, all migrations via goose
|
||||||
|
- API errors: { code, message, details? }
|
||||||
|
- Git: conventional commits (feat:, fix:, docs:, refactor:)
|
||||||
@@ -1,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,119 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
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 *string `json:"color"`
|
|
||||||
}
|
|
||||||
CategoryItem struct {
|
|
||||||
CategoryCore
|
|
||||||
}
|
|
||||||
CategoryFull struct {
|
|
||||||
CategoryCore
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Creator User `json:"creator"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
FileCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name *string `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 *string `json:"notes"`
|
|
||||||
Metadata json.RawMessage `json:"metadata"`
|
|
||||||
Viewed int `json:"viewed"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
TagCore struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Color *string `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 *string `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 *string `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,65 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
type ErrorCode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// File errors
|
|
||||||
ErrCodeFileNotFound ErrorCode = "FILE_NOT_FOUND"
|
|
||||||
ErrCodeMIMENotSupported ErrorCode = "MIME_NOT_SUPPORTED"
|
|
||||||
|
|
||||||
// Tag errors
|
|
||||||
ErrCodeTagNotFound ErrorCode = "TAG_NOT_FOUND"
|
|
||||||
|
|
||||||
// General errors
|
|
||||||
ErrCodeBadRequest ErrorCode = "BAD_REQUEST"
|
|
||||||
ErrCodeInternal ErrorCode = "INTERNAL_SERVER_ERROR"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DomainError struct {
|
|
||||||
Err error `json:"-"`
|
|
||||||
Code ErrorCode `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Details []any `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *DomainError) Wrap(err error) *DomainError {
|
|
||||||
e.Err = err
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewErrorFileNotFound(file_id string) *DomainError {
|
|
||||||
return &DomainError{
|
|
||||||
Code: ErrCodeFileNotFound,
|
|
||||||
Message: fmt.Sprintf("File not found: %q", file_id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewErrorMIMENotSupported(mime string) *DomainError {
|
|
||||||
return &DomainError{
|
|
||||||
Code: ErrCodeMIMENotSupported,
|
|
||||||
Message: fmt.Sprintf("MIME not supported: %q", mime),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewErrorTagNotFound(tag_id string) *DomainError {
|
|
||||||
return &DomainError{
|
|
||||||
Code: ErrCodeTagNotFound,
|
|
||||||
Message: fmt.Sprintf("Tag not found: %q", tag_id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewErrorBadRequest(message string) *DomainError {
|
|
||||||
return &DomainError{
|
|
||||||
Code: ErrCodeBadRequest,
|
|
||||||
Message: message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewErrorUnexpected() *DomainError {
|
|
||||||
return &DomainError{
|
|
||||||
Code: ErrCodeInternal,
|
|
||||||
Message: "An unexpected error occured",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FileRepository interface {
|
|
||||||
GetAccess(ctx context.Context, user_id int, file_id string) (canView, canEdit bool, domainErr *DomainError)
|
|
||||||
GetSlice(ctx context.Context, user_id int, filter, sort string, limit, offset int) (files Slice[FileItem], domainErr *DomainError)
|
|
||||||
Get(ctx context.Context, user_id int, file_id string) (file FileFull, domainErr *DomainError)
|
|
||||||
Add(ctx context.Context, user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file FileCore, domainErr *DomainError)
|
|
||||||
Update(ctx context.Context, file_id string, updates map[string]interface{}) (domainErr *DomainError)
|
|
||||||
Delete(ctx context.Context, file_id string) (domainErr *DomainError)
|
|
||||||
GetTags(ctx context.Context, user_id int, file_id string) (tags []TagItem, domainErr *DomainError)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Initialize PostgreSQL database driver
|
|
||||||
func New(dbURL string) (*pgxpool.Pool, error) {
|
|
||||||
poolConfig, err := pgxpool.ParseConfig(dbURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse connection string: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
poolConfig.MaxConns = 100
|
|
||||||
poolConfig.MinConns = 0
|
|
||||||
poolConfig.MaxConnLifetime = time.Hour
|
|
||||||
poolConfig.HealthCheckPeriod = 30 * time.Second
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
db, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to initialize DB connections pool: %w", err)
|
|
||||||
}
|
|
||||||
if err = db.Ping(ctx); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
|
||||||
}
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transaction wrapper
|
|
||||||
func transaction(ctx context.Context, db *pgxpool.Pool, handler func(context.Context, pgx.Tx) *domain.DomainError) (domainErr *domain.DomainError) {
|
|
||||||
tx, err := db.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
domainErr = handler(ctx, tx)
|
|
||||||
if domainErr != nil {
|
|
||||||
tx.Rollback(ctx)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = tx.Commit(ctx)
|
|
||||||
if err != nil {
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FileRepository struct {
|
|
||||||
db *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFileRepository(db *pgxpool.Pool) *FileRepository {
|
|
||||||
return &FileRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user permissions on file
|
|
||||||
func (s *FileRepository) GetAccess(ctx context.Context, user_id int, file_id string) (canView, canEdit bool, domainErr *domain.DomainError) {
|
|
||||||
row := s.db.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
COALESCE(a.view, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE),
|
|
||||||
COALESCE(a.edit, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE)
|
|
||||||
FROM data.files f
|
|
||||||
LEFT JOIN acl.files a ON a.file_id=f.id AND a.user_id=$1
|
|
||||||
LEFT JOIN system.users u ON u.id=$1
|
|
||||||
WHERE f.id=$2
|
|
||||||
`, user_id, file_id)
|
|
||||||
err := row.Scan(&canView, &canEdit)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) {
|
|
||||||
switch pgErr.Code {
|
|
||||||
case "22P02":
|
|
||||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid file id: %q", file_id)).Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a set of files
|
|
||||||
func (s *FileRepository) GetSlice(ctx context.Context, user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], domainErr *domain.DomainError) {
|
|
||||||
filterCond, err := filterToSQL(filter)
|
|
||||||
if err != nil {
|
|
||||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid filter string: %q", filter)).Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sortExpr, err := sortToSQL(sort)
|
|
||||||
if err != nil {
|
|
||||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid sorting parameter: %q", sort)).Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// prepare query
|
|
||||||
query := `
|
|
||||||
SELECT
|
|
||||||
f.id,
|
|
||||||
f.name,
|
|
||||||
m.name,
|
|
||||||
m.extension,
|
|
||||||
uuid_extract_timestamp(f.id),
|
|
||||||
u.name,
|
|
||||||
u.is_admin
|
|
||||||
FROM data.files f
|
|
||||||
JOIN system.mime m ON m.id=f.mime_id
|
|
||||||
JOIN system.users u ON u.id=f.creator_id
|
|
||||||
WHERE f.is_deleted IS FALSE AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=f.id AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1)) AND
|
|
||||||
`
|
|
||||||
query += filterCond
|
|
||||||
queryCount := query
|
|
||||||
query += sortExpr
|
|
||||||
if limit >= 0 {
|
|
||||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
|
||||||
}
|
|
||||||
if offset > 0 {
|
|
||||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
|
||||||
}
|
|
||||||
// execute query
|
|
||||||
domainErr = transaction(ctx, s.db, func(ctx context.Context, tx pgx.Tx) (domainErr *domain.DomainError) {
|
|
||||||
rows, err := tx.Query(ctx, query, user_id)
|
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) {
|
|
||||||
switch pgErr.Code {
|
|
||||||
case "42P10":
|
|
||||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid sorting field: %q", sort[1:])).Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
count := 0
|
|
||||||
for rows.Next() {
|
|
||||||
var file domain.FileItem
|
|
||||||
err = rows.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin)
|
|
||||||
if err != nil {
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
files.Data = append(files.Data, file)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
files.Pagination.Limit = limit
|
|
||||||
files.Pagination.Offset = offset
|
|
||||||
files.Pagination.Count = count
|
|
||||||
row := tx.QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM (%s) tmp", queryCount), user_id)
|
|
||||||
err = row.Scan(&files.Pagination.Total)
|
|
||||||
if err != nil {
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file
|
|
||||||
func (s *FileRepository) Get(ctx context.Context, user_id int, file_id string) (file domain.FileFull, domainErr *domain.DomainError) {
|
|
||||||
row := s.db.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
f.id,
|
|
||||||
f.name,
|
|
||||||
m.name,
|
|
||||||
m.extension,
|
|
||||||
uuid_extract_timestamp(f.id),
|
|
||||||
u.name,
|
|
||||||
u.is_admin,
|
|
||||||
f.notes,
|
|
||||||
f.metadata,
|
|
||||||
(SELECT COUNT(*) FROM activity.file_views fv WHERE fv.file_id=$2 AND fv.user_id=$1)
|
|
||||||
FROM data.files f
|
|
||||||
JOIN system.mime m ON m.id=f.mime_id
|
|
||||||
JOIN system.users u ON u.id=f.creator_id
|
|
||||||
WHERE f.is_deleted IS FALSE
|
|
||||||
`, user_id, file_id)
|
|
||||||
err := row.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin, &file.Notes, &file.Metadata, &file.Viewed)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) {
|
|
||||||
switch pgErr.Code {
|
|
||||||
case "22P02":
|
|
||||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid file id: %q", file_id)).Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add file
|
|
||||||
func (s *FileRepository) Add(ctx context.Context, user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, domainErr *domain.DomainError) {
|
|
||||||
var mime_id int
|
|
||||||
var extension string
|
|
||||||
row := s.db.QueryRow(ctx, "SELECT id, extension FROM system.mime WHERE name=$1", mime)
|
|
||||||
err := row.Scan(&mime_id, &extension)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
domainErr = domain.NewErrorMIMENotSupported(mime).Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
row = s.db.QueryRow(ctx, `
|
|
||||||
INSERT INTO data.files (name, mime_id, datetime, creator_id, notes, metadata)
|
|
||||||
VALUES (NULLIF($1, ''), $2, $3, $4, NULLIF($5 ,''), $6)
|
|
||||||
RETURNING id
|
|
||||||
`, name, mime_id, datetime, user_id, notes, metadata)
|
|
||||||
err = row.Scan(&file.ID)
|
|
||||||
if err != nil {
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) {
|
|
||||||
switch pgErr.Code {
|
|
||||||
case "22007":
|
|
||||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid datetime: %q", datetime)).Wrap(err)
|
|
||||||
return
|
|
||||||
case "23502":
|
|
||||||
domainErr = domain.NewErrorBadRequest("Unable to set NULL to some fields").Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file.Name = &name
|
|
||||||
file.MIME.Name = mime
|
|
||||||
file.MIME.Extension = extension
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update file
|
|
||||||
func (s *FileRepository) Update(ctx context.Context, file_id string, updates map[string]interface{}) (domainErr *domain.DomainError) {
|
|
||||||
if len(updates) == 0 {
|
|
||||||
// domainErr = domain.NewErrorBadRequest(nil, "No fields provided for update")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
query := "UPDATE data.files SET"
|
|
||||||
newValues := []interface{}{file_id}
|
|
||||||
count := 2
|
|
||||||
for field, value := range updates {
|
|
||||||
switch field {
|
|
||||||
case "name", "notes":
|
|
||||||
query += fmt.Sprintf(" %s=NULLIF($%d, '')", field, count)
|
|
||||||
case "datetime":
|
|
||||||
query += fmt.Sprintf(" %s=NULLIF($%d, '')::timestamptz", field, count)
|
|
||||||
case "metadata":
|
|
||||||
query += fmt.Sprintf(" %s=NULLIF($%d, '')::jsonb", field, count)
|
|
||||||
default:
|
|
||||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Unknown field: %q", field))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
newValues = append(newValues, value)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
query += fmt.Sprintf(" WHERE id=$1 AND is_deleted IS FALSE")
|
|
||||||
commandTag, err := s.db.Exec(ctx, query, newValues...)
|
|
||||||
if err != nil {
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) {
|
|
||||||
switch pgErr.Code {
|
|
||||||
case "22P02":
|
|
||||||
domainErr = domain.NewErrorBadRequest("Invalid format of some values").Wrap(err)
|
|
||||||
return
|
|
||||||
case "22007":
|
|
||||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid datetime: %q", updates["datetime"])).Wrap(err)
|
|
||||||
return
|
|
||||||
case "23502":
|
|
||||||
domainErr = domain.NewErrorBadRequest("Some fields cannot be empty").Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if commandTag.RowsAffected() == 0 {
|
|
||||||
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete file
|
|
||||||
func (s *FileRepository) Delete(ctx context.Context, file_id string) (domainErr *domain.DomainError) {
|
|
||||||
commandTag, err := s.db.Exec(ctx,
|
|
||||||
"UPDATE data.files SET is_deleted=true WHERE id=$1 AND is_deleted IS FALSE",
|
|
||||||
file_id)
|
|
||||||
if err != nil {
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) {
|
|
||||||
switch pgErr.Code {
|
|
||||||
case "22P02":
|
|
||||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid file id: %q", file_id)).Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if commandTag.RowsAffected() == 0 {
|
|
||||||
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get list of tags of file
|
|
||||||
func (s *FileRepository) GetTags(ctx context.Context, user_id int, file_id string) (tags []domain.TagItem, domainErr *domain.DomainError) {
|
|
||||||
rows, err := s.db.Query(ctx, `
|
|
||||||
SELECT
|
|
||||||
t.id,
|
|
||||||
t.name,
|
|
||||||
t.color,
|
|
||||||
c.id,
|
|
||||||
c.name,
|
|
||||||
c.color
|
|
||||||
FROM data.tags t
|
|
||||||
LEFT JOIN data.categories c ON c.id=t.category_id
|
|
||||||
JOIN data.file_tag ft ON ft.tag_id=t.id AND ft.file_id=$2
|
|
||||||
JOIN data.files f ON f.id=$2
|
|
||||||
WHERE NOT f.is_deleted AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))
|
|
||||||
`, user_id, file_id)
|
|
||||||
if err != nil {
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) && (pgErr.Code == "22P02" || pgErr.Code == "22007") {
|
|
||||||
domainErr = domain.NewErrorBadRequest(pgErr.Message).Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var tag domain.TagItem
|
|
||||||
err = rows.Scan(&tag.ID, &tag.Name, &tag.Color, &tag.Category.ID, &tag.Category.Name, &tag.Category.Color)
|
|
||||||
if err != nil {
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tags = append(tags, tag)
|
|
||||||
}
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handle database error
|
|
||||||
func handleDBError(errIn error) (statusCode int, err error) {
|
|
||||||
if errIn == nil {
|
|
||||||
statusCode = http.StatusOK
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if errors.Is(errIn, pgx.ErrNoRows) {
|
|
||||||
err = fmt.Errorf("not found")
|
|
||||||
statusCode = http.StatusNotFound
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(errIn, &pgErr) {
|
|
||||||
switch pgErr.Code {
|
|
||||||
case "22P02", "22007": // Invalid data format
|
|
||||||
err = fmt.Errorf("%s", pgErr.Message)
|
|
||||||
statusCode = http.StatusBadRequest
|
|
||||||
return
|
|
||||||
case "23505": // Unique constraint violation
|
|
||||||
err = fmt.Errorf("already exists")
|
|
||||||
statusCode = http.StatusConflict
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return http.StatusInternalServerError, errIn
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert "filter" URL param to SQL "WHERE" condition
|
|
||||||
func filterToSQL(filter string) (sql string, err error) {
|
|
||||||
// filterTokens := strings.Split(string(filter), ";")
|
|
||||||
sql = "(true)"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert "sort" URL param to SQL "ORDER BY"
|
|
||||||
func sortToSQL(sort string) (sql string, 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)
|
|
||||||
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)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// add sorting option to query
|
|
||||||
if i > 0 {
|
|
||||||
sql += ","
|
|
||||||
}
|
|
||||||
sql += fmt.Sprintf("%s %s NULLS LAST", sortColumn, sortOrder)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package rest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"tanabata/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ErrorResponse struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
Code string `json:"code,omitempty"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ErrorMapper struct{}
|
|
||||||
|
|
||||||
func (m *ErrorMapper) MapError(err domain.DomainError) (int, ErrorResponse) {
|
|
||||||
switch err.Code {
|
|
||||||
case domain.ErrCodeFileNotFound:
|
|
||||||
return http.StatusNotFound, ErrorResponse{
|
|
||||||
Error: "Not Found",
|
|
||||||
Code: string(err.Code),
|
|
||||||
Message: err.Message,
|
|
||||||
}
|
|
||||||
case domain.ErrCodeMIMENotSupported:
|
|
||||||
return http.StatusNotFound, ErrorResponse{
|
|
||||||
Error: "MIME not supported",
|
|
||||||
Code: string(err.Code),
|
|
||||||
Message: err.Message,
|
|
||||||
}
|
|
||||||
case domain.ErrCodeBadRequest:
|
|
||||||
return http.StatusNotFound, ErrorResponse{
|
|
||||||
Error: "Bad Request",
|
|
||||||
Code: string(err.Code),
|
|
||||||
Message: err.Message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return http.StatusInternalServerError, ErrorResponse{
|
|
||||||
Error: "Internal Server Error",
|
|
||||||
Code: string(err.Code),
|
|
||||||
Message: err.Message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Tanabata File Manager — Database Schema v2
|
||||||
|
-- =============================================================================
|
||||||
|
-- PostgreSQL 14+
|
||||||
|
--
|
||||||
|
-- Design decisions:
|
||||||
|
-- • Business logic lives in Go (DDD), no stored procedures
|
||||||
|
-- • UUID v7 for entity PKs (created_at extracted from UUID, no separate column)
|
||||||
|
-- • ACL: is_public flag on objects + acl.permissions table for granular control
|
||||||
|
-- • Schemas: core, data, acl, activity
|
||||||
|
-- • Flat pools (no hierarchy)
|
||||||
|
-- • Soft delete for files only (trash/recycle bin)
|
||||||
|
-- • phash field for future duplicate detection
|
||||||
|
-- • metadata jsonb on all entities
|
||||||
|
-- • Unified audit log with reference tables instead of enums
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Extensions
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Schemas
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS core;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS data;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS acl;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS activity;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Utility functions
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- UUID v7 generator
|
||||||
|
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;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Extract timestamp from UUID v7
|
||||||
|
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
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- SCHEMA: core
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Users
|
||||||
|
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,
|
||||||
|
|
||||||
|
CONSTRAINT uni__users__name UNIQUE (name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- MIME types (whitelist of supported file types)
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Object types (file, tag, category, pool — used in ACL and audit log)
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- SCHEMA: data
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Categories (logical grouping of tags)
|
||||||
|
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}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tags
|
||||||
|
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}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tag rules (when when_tag is added to a file, then_tag is also added)
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Files
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- File ↔ Tag (many-to-many)
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pools (ordered collections of files)
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- File ↔ Pool (many-to-many, with ordering)
|
||||||
|
-- `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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- SCHEMA: acl
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Granular permissions
|
||||||
|
-- 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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- SCHEMA: activity
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Action types (reference table for audit log)
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sessions
|
||||||
|
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(),
|
||||||
|
|
||||||
|
CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- File views (analytics)
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pool views (analytics)
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tag usage tracking (when tag is used as filter)
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Audit log (unified journal for all user actions)
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- SEED DATA
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Object types
|
||||||
|
INSERT INTO core.object_types (name) VALUES
|
||||||
|
('file'), ('tag'), ('category'), ('pool');
|
||||||
|
|
||||||
|
-- Action types
|
||||||
|
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');
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- INDEXES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- 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);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- COMMENTS
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
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';
|
||||||
|
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 TABLE acl.permissions IS 'Per-object permissions (used when is_public=false)';
|
||||||
|
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';
|
||||||
|
|
||||||
|
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';
|
||||||
@@ -1,592 +0,0 @@
|
|||||||
--
|
|
||||||
-- PostgreSQL database dump
|
|
||||||
--
|
|
||||||
|
|
||||||
-- Dumped from database version 14.18 (Ubuntu 14.18-0ubuntu0.22.04.1)
|
|
||||||
-- Dumped by pg_dump version 17.4
|
|
||||||
|
|
||||||
SET statement_timeout = 0;
|
|
||||||
SET lock_timeout = 0;
|
|
||||||
SET idle_in_transaction_session_timeout = 0;
|
|
||||||
SET transaction_timeout = 0;
|
|
||||||
SET client_encoding = 'UTF8';
|
|
||||||
SET standard_conforming_strings = on;
|
|
||||||
SELECT pg_catalog.set_config('search_path', '', false);
|
|
||||||
SET check_function_bodies = false;
|
|
||||||
SET xmloption = content;
|
|
||||||
SET client_min_messages = warning;
|
|
||||||
SET row_security = off;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: acl; Type: SCHEMA; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA acl;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: activity; Type: SCHEMA; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA activity;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: data; Type: SCHEMA; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA data;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
-- *not* creating schema, since initdb creates it
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: system; Type: SCHEMA; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA system;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: add_file_to_tag_recursive(uuid, uuid); Type: FUNCTION; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE FUNCTION data.add_file_to_tag_recursive(f_id uuid, t_id uuid) RETURNS SETOF uuid
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
tmp uuid;
|
|
||||||
tt_id uuid;
|
|
||||||
ttt_id uuid;
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO data.file_tag VALUES (f_id, t_id) ON CONFLICT DO NOTHING RETURNING tag_id INTO tmp;
|
|
||||||
IF tmp IS NULL THEN
|
|
||||||
RETURN;
|
|
||||||
END IF;
|
|
||||||
RETURN NEXT t_id;
|
|
||||||
FOR tt_id IN
|
|
||||||
SELECT a.add_tag_id FROM data.autotags a WHERE a.trigger_tag_id=t_id AND a.is_active
|
|
||||||
LOOP
|
|
||||||
FOR ttt_id IN SELECT data.add_file_to_tag_recursive(f_id, tt_id)
|
|
||||||
LOOP
|
|
||||||
RETURN NEXT ttt_id;
|
|
||||||
END LOOP;
|
|
||||||
END LOOP;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: uuid_extract_timestamp(uuid); Type: FUNCTION; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE FUNCTION public.uuid_extract_timestamp(uuid_val uuid) RETURNS timestamp with time zone
|
|
||||||
LANGUAGE sql IMMUTABLE
|
|
||||||
AS $$
|
|
||||||
SELECT to_timestamp(
|
|
||||||
('x' || LEFT(REPLACE(uuid_val::TEXT, '-', ''), 12))::BIT(48)::BIGINT
|
|
||||||
/ 1000.0
|
|
||||||
);
|
|
||||||
$$;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: uuid_v7(timestamp with time zone); Type: FUNCTION; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE FUNCTION public.uuid_v7(cts timestamp with time zone DEFAULT clock_timestamp()) RETURNS uuid
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
state text = current_setting('uuidv7.old_tp',true);
|
|
||||||
old_tp text = split_part(state, ':',1);
|
|
||||||
base int = coalesce(nullif(split_part(state,':',4),'')::int,(random()*16777215/2-1)::int);
|
|
||||||
tp text;
|
|
||||||
entropy text;
|
|
||||||
seq text=base;
|
|
||||||
seqn int=split_part(state,':',2);
|
|
||||||
ver text = coalesce(split_part(state,':',3),to_hex(8+(random()*3)::int));
|
|
||||||
BEGIN
|
|
||||||
base = (random()*16777215/2-1)::int;
|
|
||||||
tp = lpad(to_hex(floor(extract(epoch from cts)*1000)::int8),12,'0')||'7';
|
|
||||||
if tp is distinct from old_tp then
|
|
||||||
old_tp = tp;
|
|
||||||
ver = to_hex(8+(random()*3)::int);
|
|
||||||
base = (random()*16777215/2-1)::int;
|
|
||||||
seqn = base;
|
|
||||||
else
|
|
||||||
seqn = seqn+(random()*1000)::int;
|
|
||||||
end if;
|
|
||||||
perform set_config('uuidv7.old_tp',old_tp||':'||seqn||':'||ver||':'||base, false);
|
|
||||||
entropy = md5(gen_random_uuid()::text);
|
|
||||||
seq = lpad(to_hex(seqn),6,'0');
|
|
||||||
return (tp || substring(seq from 1 for 3) || ver || substring(seq from 4 for 3) ||
|
|
||||||
substring(entropy from 1 for 12))::uuid;
|
|
||||||
END
|
|
||||||
$$;
|
|
||||||
|
|
||||||
|
|
||||||
SET default_table_access_method = heap;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: categories; Type: TABLE; Schema: acl; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE acl.categories (
|
|
||||||
user_id smallint NOT NULL,
|
|
||||||
category_id uuid NOT NULL,
|
|
||||||
view boolean NOT NULL,
|
|
||||||
edit boolean NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: files; Type: TABLE; Schema: acl; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE acl.files (
|
|
||||||
user_id smallint NOT NULL,
|
|
||||||
file_id uuid NOT NULL,
|
|
||||||
view boolean NOT NULL,
|
|
||||||
edit boolean NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: pools; Type: TABLE; Schema: acl; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE acl.pools (
|
|
||||||
user_id smallint NOT NULL,
|
|
||||||
pool_id uuid NOT NULL,
|
|
||||||
view boolean NOT NULL,
|
|
||||||
edit boolean NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tags; Type: TABLE; Schema: acl; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE acl.tags (
|
|
||||||
user_id smallint NOT NULL,
|
|
||||||
tag_id uuid NOT NULL,
|
|
||||||
view boolean NOT NULL,
|
|
||||||
edit boolean NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: file_views; Type: TABLE; Schema: activity; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE activity.file_views (
|
|
||||||
file_id uuid NOT NULL,
|
|
||||||
"timestamp" timestamp with time zone NOT NULL,
|
|
||||||
user_id smallint NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: pool_views; Type: TABLE; Schema: activity; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE activity.pool_views (
|
|
||||||
pool_id uuid NOT NULL,
|
|
||||||
"timestamp" timestamp with time zone NOT NULL,
|
|
||||||
user_id smallint NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: sessions; Type: TABLE; Schema: activity; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE activity.sessions (
|
|
||||||
id integer NOT NULL,
|
|
||||||
token text NOT NULL,
|
|
||||||
user_id smallint NOT NULL,
|
|
||||||
user_agent character varying(256) NOT NULL,
|
|
||||||
started_at timestamp with time zone DEFAULT statement_timestamp() NOT NULL,
|
|
||||||
expires_at timestamp with time zone,
|
|
||||||
last_activity timestamp with time zone DEFAULT statement_timestamp() NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: sessions_id_seq; Type: SEQUENCE; Schema: activity; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE activity.sessions_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: sessions_id_seq; Type: SEQUENCE OWNED BY; Schema: activity; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE activity.sessions_id_seq OWNED BY activity.sessions.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tag_uses; Type: TABLE; Schema: activity; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE activity.tag_uses (
|
|
||||||
tag_id uuid NOT NULL,
|
|
||||||
"timestamp" timestamp with time zone NOT NULL,
|
|
||||||
user_id smallint NOT NULL,
|
|
||||||
included boolean NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: autotags; Type: TABLE; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE data.autotags (
|
|
||||||
trigger_tag_id uuid NOT NULL,
|
|
||||||
add_tag_id uuid NOT NULL,
|
|
||||||
is_active boolean DEFAULT true NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: categories; Type: TABLE; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE data.categories (
|
|
||||||
id uuid DEFAULT public.uuid_v7() NOT NULL,
|
|
||||||
name character varying(256) NOT NULL,
|
|
||||||
notes text DEFAULT ''::text NOT NULL,
|
|
||||||
color character(6),
|
|
||||||
creator_id smallint NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: file_pool; Type: TABLE; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE data.file_pool (
|
|
||||||
file_id uuid NOT NULL,
|
|
||||||
pool_id uuid NOT NULL,
|
|
||||||
number smallint NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: file_tag; Type: TABLE; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE data.file_tag (
|
|
||||||
file_id uuid NOT NULL,
|
|
||||||
tag_id uuid NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: files; Type: TABLE; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE data.files (
|
|
||||||
id uuid DEFAULT public.uuid_v7() NOT NULL,
|
|
||||||
name character varying(256),
|
|
||||||
mime_id smallint NOT NULL,
|
|
||||||
datetime timestamp with time zone DEFAULT clock_timestamp() NOT NULL,
|
|
||||||
notes text,
|
|
||||||
metadata jsonb NOT NULL,
|
|
||||||
creator_id smallint NOT NULL,
|
|
||||||
is_deleted boolean DEFAULT false NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: pools; Type: TABLE; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE data.pools (
|
|
||||||
id uuid DEFAULT public.uuid_v7() NOT NULL,
|
|
||||||
name character varying(256) NOT NULL,
|
|
||||||
notes text,
|
|
||||||
creator_id smallint NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tags; Type: TABLE; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE data.tags (
|
|
||||||
id uuid DEFAULT public.uuid_v7() NOT NULL,
|
|
||||||
name character varying(256) NOT NULL,
|
|
||||||
notes text,
|
|
||||||
color character(6),
|
|
||||||
category_id uuid,
|
|
||||||
creator_id smallint NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: mime; Type: TABLE; Schema: system; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE system.mime (
|
|
||||||
id smallint NOT NULL,
|
|
||||||
name character varying(127) NOT NULL,
|
|
||||||
extension character varying(16) NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: mime_id_seq; Type: SEQUENCE; Schema: system; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE system.mime_id_seq
|
|
||||||
AS smallint
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: mime_id_seq; Type: SEQUENCE OWNED BY; Schema: system; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE system.mime_id_seq OWNED BY system.mime.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: users; Type: TABLE; Schema: system; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE system.users (
|
|
||||||
id smallint NOT NULL,
|
|
||||||
name character varying(32) NOT NULL,
|
|
||||||
password text NOT NULL,
|
|
||||||
is_admin boolean DEFAULT false NOT NULL,
|
|
||||||
can_create boolean DEFAULT false NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: users_id_seq; Type: SEQUENCE; Schema: system; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE system.users_id_seq
|
|
||||||
AS smallint
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: system; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE system.users_id_seq OWNED BY system.users.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: sessions id; Type: DEFAULT; Schema: activity; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY activity.sessions ALTER COLUMN id SET DEFAULT nextval('activity.sessions_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: mime id; Type: DEFAULT; Schema: system; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY system.mime ALTER COLUMN id SET DEFAULT nextval('system.mime_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: users id; Type: DEFAULT; Schema: system; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY system.users ALTER COLUMN id SET DEFAULT nextval('system.users_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: categories categories_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY acl.categories
|
|
||||||
ADD CONSTRAINT categories_pkey PRIMARY KEY (user_id, category_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: files files_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY acl.files
|
|
||||||
ADD CONSTRAINT files_pkey PRIMARY KEY (user_id, file_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: pools pools_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY acl.pools
|
|
||||||
ADD CONSTRAINT pools_pkey PRIMARY KEY (user_id, pool_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY acl.tags
|
|
||||||
ADD CONSTRAINT tags_pkey PRIMARY KEY (user_id, tag_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: file_views file_views_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY activity.file_views
|
|
||||||
ADD CONSTRAINT file_views_pkey PRIMARY KEY (file_id, "timestamp", user_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: pool_views pool_views_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY activity.pool_views
|
|
||||||
ADD CONSTRAINT pool_views_pkey PRIMARY KEY (pool_id, "timestamp", user_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY activity.sessions
|
|
||||||
ADD CONSTRAINT sessions_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tag_uses tag_uses_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY activity.tag_uses
|
|
||||||
ADD CONSTRAINT tag_uses_pkey PRIMARY KEY (tag_id, "timestamp", user_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: autotags autotags_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY data.autotags
|
|
||||||
ADD CONSTRAINT autotags_pkey PRIMARY KEY (trigger_tag_id, add_tag_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: categories categories_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY data.categories
|
|
||||||
ADD CONSTRAINT categories_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: file_pool file_pool_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY data.file_pool
|
|
||||||
ADD CONSTRAINT file_pool_pkey PRIMARY KEY (file_id, pool_id, number);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: file_tag file_tag_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY data.file_tag
|
|
||||||
ADD CONSTRAINT file_tag_pkey PRIMARY KEY (file_id, tag_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: files files_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY data.files
|
|
||||||
ADD CONSTRAINT files_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: pools pools_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY data.pools
|
|
||||||
ADD CONSTRAINT pools_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY data.tags
|
|
||||||
ADD CONSTRAINT tags_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: mime mime_pkey; Type: CONSTRAINT; Schema: system; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY system.mime
|
|
||||||
ADD CONSTRAINT mime_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: users users_pkey; Type: CONSTRAINT; Schema: system; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY system.users
|
|
||||||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- PostgreSQL database dump complete
|
|
||||||
--
|
|
||||||
|
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
# Tanabata File Manager — Go Project Structure
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Router**: Gin
|
||||||
|
- **Database**: pgx v5 (pgxpool)
|
||||||
|
- **Migrations**: goose v3 + go:embed (auto-migrate on startup)
|
||||||
|
- **Auth**: JWT (golang-jwt/jwt/v5)
|
||||||
|
- **Config**: environment variables via .env (joho/godotenv)
|
||||||
|
- **Logging**: slog (stdlib, Go 1.21+)
|
||||||
|
- **Validation**: go-playground/validator/v10
|
||||||
|
- **EXIF**: rwcarlsen/goexif or dsoprea/go-exif
|
||||||
|
- **Image processing**: disintegration/imaging (thumbnails, previews)
|
||||||
|
- **Architecture**: Clean Architecture (domain → service → repository/handler)
|
||||||
|
|
||||||
|
## Monorepo Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
tanabata/
|
||||||
|
├── backend/ ← Go project
|
||||||
|
├── frontend/ ← SvelteKit project
|
||||||
|
├── openapi.yaml ← Shared API contract
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── Dockerfile
|
||||||
|
├── .env.example
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── cmd/
|
||||||
|
│ └── server/
|
||||||
|
│ └── main.go # Entrypoint: config → DB → migrate → wire → run
|
||||||
|
│
|
||||||
|
├── internal/
|
||||||
|
│ │
|
||||||
|
│ ├── domain/ # Pure business entities & value objects
|
||||||
|
│ │ ├── file.go # File, FileFilter, FilePage
|
||||||
|
│ │ ├── tag.go # Tag, TagRule
|
||||||
|
│ │ ├── category.go # Category
|
||||||
|
│ │ ├── pool.go # Pool, PoolFile
|
||||||
|
│ │ ├── user.go # User, Session
|
||||||
|
│ │ ├── acl.go # Permission, ObjectType
|
||||||
|
│ │ ├── audit.go # AuditEntry, ActionType
|
||||||
|
│ │ └── errors.go # Domain error types (ErrNotFound, ErrForbidden, etc.)
|
||||||
|
│ │
|
||||||
|
│ ├── port/ # Interfaces (ports) — contracts between layers
|
||||||
|
│ │ ├── repository.go # FileRepo, TagRepo, CategoryRepo, PoolRepo,
|
||||||
|
│ │ │ # UserRepo, SessionRepo, ACLRepo, AuditRepo,
|
||||||
|
│ │ │ # MimeRepo, TagRuleRepo
|
||||||
|
│ │ └── storage.go # FileStorage interface (disk operations)
|
||||||
|
│ │
|
||||||
|
│ ├── service/ # Business logic (use cases)
|
||||||
|
│ │ ├── file_service.go # Upload, update, delete, trash/restore, replace,
|
||||||
|
│ │ │ # import, filter/list, duplicate detection
|
||||||
|
│ │ ├── tag_service.go # CRUD + auto-tag application logic
|
||||||
|
│ │ ├── category_service.go # CRUD (thin, delegates to repo + ACL + audit)
|
||||||
|
│ │ ├── pool_service.go # CRUD + file ordering, add/remove files
|
||||||
|
│ │ ├── auth_service.go # Login, logout, JWT issue/refresh, session management
|
||||||
|
│ │ ├── acl_service.go # Permission checks, grant/revoke
|
||||||
|
│ │ ├── audit_service.go # Log actions, query audit log
|
||||||
|
│ │ └── user_service.go # Profile update, admin CRUD, block/unblock
|
||||||
|
│ │
|
||||||
|
│ ├── handler/ # HTTP layer (Gin handlers)
|
||||||
|
│ │ ├── router.go # Route registration, middleware wiring
|
||||||
|
│ │ ├── middleware.go # Auth middleware (JWT extraction → context)
|
||||||
|
│ │ ├── request.go # Common request parsing helpers
|
||||||
|
│ │ ├── response.go # Error/success response builders
|
||||||
|
│ │ ├── file_handler.go # /files endpoints
|
||||||
|
│ │ ├── tag_handler.go # /tags endpoints
|
||||||
|
│ │ ├── category_handler.go # /categories endpoints
|
||||||
|
│ │ ├── pool_handler.go # /pools endpoints
|
||||||
|
│ │ ├── auth_handler.go # /auth endpoints
|
||||||
|
│ │ ├── acl_handler.go # /acl endpoints
|
||||||
|
│ │ ├── user_handler.go # /users endpoints
|
||||||
|
│ │ └── audit_handler.go # /audit endpoints
|
||||||
|
│ │
|
||||||
|
│ ├── db/ # Database adapters
|
||||||
|
│ │ ├── db.go # Common helpers: pagination, repo factory, transactor base
|
||||||
|
│ │ └── postgres/ # PostgreSQL implementation
|
||||||
|
│ │ ├── postgres.go # pgxpool init, tx-from-context helpers
|
||||||
|
│ │ ├── file_repo.go # FileRepo implementation
|
||||||
|
│ │ ├── tag_repo.go # TagRepo + TagRuleRepo implementation
|
||||||
|
│ │ ├── category_repo.go # CategoryRepo implementation
|
||||||
|
│ │ ├── pool_repo.go # PoolRepo implementation
|
||||||
|
│ │ ├── user_repo.go # UserRepo implementation
|
||||||
|
│ │ ├── session_repo.go # SessionRepo implementation
|
||||||
|
│ │ ├── acl_repo.go # ACLRepo implementation
|
||||||
|
│ │ ├── audit_repo.go # AuditRepo implementation
|
||||||
|
│ │ ├── mime_repo.go # MimeRepo implementation
|
||||||
|
│ │ └── filter_parser.go # DSL → SQL WHERE clause builder
|
||||||
|
│ │
|
||||||
|
│ ├── storage/ # File storage adapter
|
||||||
|
│ │ └── disk.go # FileStorage implementation (read/write/delete on disk)
|
||||||
|
│ │
|
||||||
|
│ └── config/ # Configuration
|
||||||
|
│ └── config.go # Struct + loader from env vars
|
||||||
|
│
|
||||||
|
├── migrations/ # SQL migration files (goose format)
|
||||||
|
│ ├── 001_init_schemas.sql
|
||||||
|
│ ├── 002_core_tables.sql
|
||||||
|
│ ├── 003_data_tables.sql
|
||||||
|
│ ├── 004_acl_tables.sql
|
||||||
|
│ ├── 005_activity_tables.sql
|
||||||
|
│ ├── 006_indexes.sql
|
||||||
|
│ └── 007_seed_data.sql
|
||||||
|
│
|
||||||
|
├── go.mod
|
||||||
|
└── go.sum
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layer Dependency Rules
|
||||||
|
|
||||||
|
```
|
||||||
|
handler → service → port (interfaces) ← db/postgres / storage
|
||||||
|
↓
|
||||||
|
domain (entities, value objects, errors)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **domain/**: zero imports from other internal packages. Only stdlib.
|
||||||
|
- **port/**: imports only domain/. Defines interfaces.
|
||||||
|
- **service/**: imports domain/ and port/. Never imports db/ or handler/.
|
||||||
|
- **handler/**: imports domain/ and service/. Never imports db/.
|
||||||
|
- **db/postgres/**: imports domain/, port/, and db/ (common helpers). Implements port interfaces.
|
||||||
|
- **db/**: imports domain/ and port/. Shared utilities for all DB adapters.
|
||||||
|
- **storage/**: imports domain/ and port/. Implements FileStorage.
|
||||||
|
|
||||||
|
No layer may import a layer above it. No circular dependencies.
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### Dependency Injection (Wiring)
|
||||||
|
|
||||||
|
Manual wiring in `cmd/server/main.go`. No DI frameworks.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Pseudocode
|
||||||
|
pool := postgres.NewPool(cfg.DatabaseURL)
|
||||||
|
goose.Up(pool, migrations)
|
||||||
|
|
||||||
|
// Repos (all from internal/db/postgres/)
|
||||||
|
fileRepo := postgres.NewFileRepo(pool)
|
||||||
|
tagRepo := postgres.NewTagRepo(pool)
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
diskStore := storage.NewDiskStorage(cfg.FilesPath)
|
||||||
|
|
||||||
|
// Services
|
||||||
|
aclSvc := service.NewACLService(aclRepo, objectTypeRepo)
|
||||||
|
auditSvc := service.NewAuditService(auditRepo, actionTypeRepo)
|
||||||
|
fileSvc := service.NewFileService(fileRepo, mimeRepo, tagRepo, diskStore, aclSvc, auditSvc)
|
||||||
|
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc)
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
||||||
|
// ...
|
||||||
|
|
||||||
|
router := handler.NewRouter(cfg, fileHandler, tagHandler, ...)
|
||||||
|
router.Run(cfg.ListenAddr)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context Propagation
|
||||||
|
|
||||||
|
Every service method receives `context.Context` as the first argument.
|
||||||
|
The handler extracts user info from JWT (via middleware) and puts it
|
||||||
|
into context. Services read the current user from context for ACL checks
|
||||||
|
and audit logging.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// middleware.go
|
||||||
|
func (m *AuthMiddleware) Handle(c *gin.Context) {
|
||||||
|
claims := parseJWT(c.GetHeader("Authorization"))
|
||||||
|
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin)
|
||||||
|
c.Request = c.Request.WithContext(ctx)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// domain/context.go
|
||||||
|
type ctxKey int
|
||||||
|
const userKey ctxKey = iota
|
||||||
|
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context { ... }
|
||||||
|
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transaction Management
|
||||||
|
|
||||||
|
Repository interfaces include a `Transactor`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// port/repository.go
|
||||||
|
type Transactor interface {
|
||||||
|
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The postgres implementation wraps `pgxpool.Pool.BeginTx`. Inside `fn`,
|
||||||
|
all repo calls use the transaction from context. This allows services
|
||||||
|
to compose multiple repo calls in a single transaction:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// service/file_service.go
|
||||||
|
func (s *FileService) Upload(ctx context.Context, input UploadInput) (*domain.File, error) {
|
||||||
|
return s.tx.WithTx(ctx, func(ctx context.Context) error {
|
||||||
|
file, err := s.fileRepo.Create(ctx, ...) // uses tx
|
||||||
|
if err != nil { return err }
|
||||||
|
for _, tagID := range input.TagIDs {
|
||||||
|
s.tagRepo.AddFileTag(ctx, file.ID, tagID) // same tx
|
||||||
|
}
|
||||||
|
s.auditRepo.Log(ctx, ...) // same tx
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ACL Check Pattern
|
||||||
|
|
||||||
|
ACL logic is centralized in `ACLService`. Other services call it before
|
||||||
|
any data mutation or retrieval:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// service/acl_service.go
|
||||||
|
func (s *ACLService) CanView(ctx context.Context, objectType string, objectID uuid.UUID) error {
|
||||||
|
userID, isAdmin := domain.UserFromContext(ctx)
|
||||||
|
if isAdmin { return nil }
|
||||||
|
// Check is_public on the object
|
||||||
|
// If not public, check creator_id == userID
|
||||||
|
// If not creator, check acl.permissions
|
||||||
|
// Return domain.ErrForbidden if none match
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Mapping
|
||||||
|
|
||||||
|
Domain errors → HTTP status codes (handled in handler/response.go):
|
||||||
|
|
||||||
|
| Domain Error | HTTP Status | Error Code |
|
||||||
|
|-----------------------|-------------|-------------------|
|
||||||
|
| ErrNotFound | 404 | not_found |
|
||||||
|
| ErrForbidden | 403 | forbidden |
|
||||||
|
| ErrUnauthorized | 401 | unauthorized |
|
||||||
|
| ErrConflict | 409 | conflict |
|
||||||
|
| ErrValidation | 400 | validation_error |
|
||||||
|
| ErrUnsupportedMIME | 415 | unsupported_mime |
|
||||||
|
| (unexpected) | 500 | internal_error |
|
||||||
|
|
||||||
|
### Filter DSL
|
||||||
|
|
||||||
|
The DSL parser lives in `db/postgres/filter_parser.go` because it produces
|
||||||
|
SQL WHERE clauses — it is a PostgreSQL-specific adapter concern.
|
||||||
|
The service layer passes the raw DSL string to the repository; the
|
||||||
|
repository parses it and builds the query.
|
||||||
|
|
||||||
|
For a different DBMS, a corresponding parser would live in
|
||||||
|
`db/<dbms>/filter_parser.go`.
|
||||||
|
|
||||||
|
The interface:
|
||||||
|
```go
|
||||||
|
// port/repository.go
|
||||||
|
type FileRepo interface {
|
||||||
|
List(ctx context.Context, params FileListParams) (*domain.FilePage, error)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// domain/file.go
|
||||||
|
type FileListParams struct {
|
||||||
|
Filter string // raw DSL string
|
||||||
|
Sort string
|
||||||
|
Order string
|
||||||
|
Cursor string
|
||||||
|
Anchor *uuid.UUID
|
||||||
|
Direction string // "forward" or "backward"
|
||||||
|
Limit int
|
||||||
|
Trash bool
|
||||||
|
Search string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JWT Structure
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Claims struct {
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
UserID int16 `json:"uid"`
|
||||||
|
IsAdmin bool `json:"adm"`
|
||||||
|
SessionID int `json:"sid"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Access token: short-lived (15 min). Refresh token: long-lived (30 days),
|
||||||
|
stored as hash in `activity.sessions.token_hash`.
|
||||||
|
|
||||||
|
### Configuration (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Server
|
||||||
|
LISTEN_ADDR=:8080
|
||||||
|
JWT_SECRET=<random-32-bytes>
|
||||||
|
JWT_ACCESS_TTL=15m
|
||||||
|
JWT_REFRESH_TTL=720h
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgres://user:pass@host:5432/tanabata?sslmode=disable
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
FILES_PATH=/data/files
|
||||||
|
THUMBS_CACHE_PATH=/data/thumbs
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
THUMB_WIDTH=160
|
||||||
|
THUMB_HEIGHT=160
|
||||||
|
PREVIEW_WIDTH=1920
|
||||||
|
PREVIEW_HEIGHT=1080
|
||||||
|
|
||||||
|
# Import
|
||||||
|
IMPORT_PATH=/data/import
|
||||||
|
```
|
||||||
-212
@@ -1,212 +0,0 @@
|
|||||||
@startuml Tanabata File Manager entity relationship diagram
|
|
||||||
|
|
||||||
' skinparam linetype ortho
|
|
||||||
|
|
||||||
|
|
||||||
' ========== SYSTEM ==========
|
|
||||||
|
|
||||||
entity "system.users" as usr {
|
|
||||||
* id : smallserial <<generated>>
|
|
||||||
--
|
|
||||||
* name : varchar(32)
|
|
||||||
* password : text
|
|
||||||
* is_admin : boolean
|
|
||||||
* can_create : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
entity "system.mime" as mime {
|
|
||||||
* id : smallserial <<generated>>
|
|
||||||
--
|
|
||||||
* name : varchar(127)
|
|
||||||
* extension : varchar(16)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
' ========== DATA ==========
|
|
||||||
|
|
||||||
entity "data.categories" as cty {
|
|
||||||
* id : uuid <<generated>>
|
|
||||||
--
|
|
||||||
* name : varchar(256)
|
|
||||||
notes : text
|
|
||||||
color : char(6)
|
|
||||||
' * created_at : timestamptz <<generated>>
|
|
||||||
* creator_id : smallint
|
|
||||||
' * is_private : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
cty::creator_id }o--|| usr::id
|
|
||||||
|
|
||||||
entity "data.files" as fle {
|
|
||||||
* id : uuid <<generated>>
|
|
||||||
--
|
|
||||||
name : varchar(256)
|
|
||||||
* mime_id : smallint
|
|
||||||
* datetime : timestamptz
|
|
||||||
notes : text
|
|
||||||
* metadata : jsonb
|
|
||||||
' * created_at : timestamptz <<generated>>
|
|
||||||
* creator_id : smallint
|
|
||||||
' * is_private : boolean
|
|
||||||
* is_deleted : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
fle::mime_id }o--|| mime::id
|
|
||||||
fle::creator_id }o--|| usr::id
|
|
||||||
|
|
||||||
entity "data.tags" as tag {
|
|
||||||
* id : uuid <<generated>>
|
|
||||||
--
|
|
||||||
* name : varchar(256)
|
|
||||||
notes : text
|
|
||||||
color : char(6)
|
|
||||||
category_id : uuid
|
|
||||||
' * created_at : timestamptz <<generated>>
|
|
||||||
* creator_id : smallint
|
|
||||||
' * is_private : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
tag::category_id }o--o| cty::id
|
|
||||||
tag::creator_id }o--|| usr::id
|
|
||||||
|
|
||||||
entity "data.file_tag" as ft {
|
|
||||||
* file_id : uuid
|
|
||||||
* tag_id : uuid
|
|
||||||
}
|
|
||||||
|
|
||||||
ft::file_id }o--|| fle::id
|
|
||||||
ft::tag_id }o--|| tag::id
|
|
||||||
|
|
||||||
entity "data.autotags" as atg {
|
|
||||||
* trigger_tag_id : uuid
|
|
||||||
* add_tag_id : uuid
|
|
||||||
--
|
|
||||||
* is_active : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
atg::trigger_tag_id }o--|| tag::id
|
|
||||||
atg::add_tag_id }o--|| tag::id
|
|
||||||
|
|
||||||
entity "data.pools" as pool {
|
|
||||||
* id : uuid <<generated>>
|
|
||||||
--
|
|
||||||
* name : varchar(256)
|
|
||||||
notes : text
|
|
||||||
' parent_id : uuid
|
|
||||||
' * created_at : timestamptz
|
|
||||||
* creator_id : smallint
|
|
||||||
' * is_private : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
pool::creator_id }o--|| usr::id
|
|
||||||
' pool::parent_id }o--o| pool::id
|
|
||||||
|
|
||||||
entity "data.file_pool" as fp {
|
|
||||||
* file_id : uuid
|
|
||||||
* pool_id : uuid
|
|
||||||
* number : smallint
|
|
||||||
}
|
|
||||||
|
|
||||||
fp::file_id }o--|| fle::id
|
|
||||||
fp::pool_id }o--|| pool::id
|
|
||||||
|
|
||||||
|
|
||||||
' ========== ACL ==========
|
|
||||||
|
|
||||||
entity "acl.files" as acl_f {
|
|
||||||
* user_id : smallint
|
|
||||||
* file_id : uuid
|
|
||||||
--
|
|
||||||
* view : boolean
|
|
||||||
* edit : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
acl_f::user_id }o--|| usr::id
|
|
||||||
acl_f::file_id }o--|| fle::id
|
|
||||||
|
|
||||||
entity "acl.tags" as acl_t {
|
|
||||||
* user_id : smallint
|
|
||||||
* tag_id : uuid
|
|
||||||
--
|
|
||||||
* view : boolean
|
|
||||||
* edit : boolean
|
|
||||||
' * files_view : boolean
|
|
||||||
' * files_edit : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
acl_t::user_id }o--|| usr::id
|
|
||||||
acl_t::tag_id }o--|| tag::id
|
|
||||||
|
|
||||||
entity "acl.categories" as acl_c {
|
|
||||||
* user_id : smallint
|
|
||||||
* category_id : uuid
|
|
||||||
--
|
|
||||||
* view : boolean
|
|
||||||
* edit : boolean
|
|
||||||
' * tags_view : boolean
|
|
||||||
' * tags_edit : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
acl_c::user_id }o--|| usr::id
|
|
||||||
acl_c::category_id }o--|| cty::id
|
|
||||||
|
|
||||||
entity "acl.pools" as acl_p {
|
|
||||||
* user_id : smallint
|
|
||||||
* pool_id : uuid
|
|
||||||
--
|
|
||||||
* view : boolean
|
|
||||||
* edit : boolean
|
|
||||||
' * files_view : boolean
|
|
||||||
' * files_edit : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
acl_p::user_id }o--|| usr::id
|
|
||||||
acl_p::pool_id }o--|| pool::id
|
|
||||||
|
|
||||||
|
|
||||||
' ========== ACTIVITY ==========
|
|
||||||
|
|
||||||
entity "activity.sessions" as ssn {
|
|
||||||
* id : serial <<generated>>
|
|
||||||
--
|
|
||||||
* token : text
|
|
||||||
* user_id : smallint
|
|
||||||
* user_agent : varchar(512)
|
|
||||||
* started_at : timestamptz
|
|
||||||
expires_at : timestamptz
|
|
||||||
* last_activity : timestamptz
|
|
||||||
}
|
|
||||||
|
|
||||||
ssn::user_id }o--|| usr::id
|
|
||||||
|
|
||||||
entity "activity.file_views" as fv {
|
|
||||||
* file_id : uuid
|
|
||||||
* timestamp : timestamptz
|
|
||||||
* user_id : smallint
|
|
||||||
}
|
|
||||||
|
|
||||||
fv::file_id }o--|| fle::id
|
|
||||||
fv::user_id }o--|| usr::id
|
|
||||||
|
|
||||||
entity "activity.tag_uses" as tu {
|
|
||||||
* tag_id : uuid
|
|
||||||
* timestamp : timestamptz
|
|
||||||
* user_id : smallint
|
|
||||||
--
|
|
||||||
* included : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
tu::tag_id }o--|| tag::id
|
|
||||||
tu::user_id }o--|| usr::id
|
|
||||||
|
|
||||||
entity "activity.pool_views" as pv {
|
|
||||||
* pool_id : uuid
|
|
||||||
* timestamp : timestamptz
|
|
||||||
* user_id : smallint
|
|
||||||
}
|
|
||||||
|
|
||||||
pv::pool_id }o--|| pool::id
|
|
||||||
pv::user_id }o--|| usr::id
|
|
||||||
|
|
||||||
|
|
||||||
@enduml
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
## О проекте
|
||||||
|
|
||||||
|
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 отсутствует в БД (нет в БД — нет поддержки)
|
||||||
+2033
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user