tanabata

Tanabata File Manager β€” Go Project Structure

Backend counterpart of ARCHITECTURE.md. This document details the Go layout, the layer rules, and the key backend decisions.

Stack

The binary is fully static (CGO_ENABLED=0). External tools are invoked as subprocesses when present and are optional β€” the pure-Go paths keep the server working without them.

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 β†’ bootstrap admin β†’ wire β†’ serve
β”‚   └── dedup/
β”‚       └── main.go                     # Offline maintenance CLI: perceptual-hash backfill + duplicate-pairs rescan
β”‚
β”œβ”€β”€ internal/
β”‚   β”‚
β”‚   β”œβ”€β”€ domain/                         # Pure business entities & value objects (stdlib only)
β”‚   β”‚   β”œβ”€β”€ file.go                     # File, FileFilter, FileListParams, FilePage
β”‚   β”‚   β”œβ”€β”€ tag.go                      # Tag, TagRule
β”‚   β”‚   β”œβ”€β”€ category.go                 # Category
β”‚   β”‚   β”œβ”€β”€ pool.go                     # Pool, PoolFile
β”‚   β”‚   β”œβ”€β”€ user.go                     # User, Session
β”‚   β”‚   β”œβ”€β”€ acl.go                      # Permission, ObjectType
β”‚   β”‚   β”œβ”€β”€ audit.go                    # AuditEntry, ActionType
β”‚   β”‚   β”œβ”€β”€ duplicate.go                # DuplicatePair, PHashEntry
β”‚   β”‚   β”œβ”€β”€ context.go                  # WithUser / UserFromContext (identity + session in ctx)
β”‚   β”‚   └── errors.go                   # Domain error types (ErrNotFound, ErrForbidden, …)
β”‚   β”‚
β”‚   β”œβ”€β”€ port/                           # Interfaces (ports) β€” contracts between layers
β”‚   β”‚   β”œβ”€β”€ repository.go               # Transactor, FileRepo, TagRepo, TagRuleRepo, CategoryRepo,
β”‚   β”‚   β”‚                               # PoolRepo, UserRepo, SessionRepo, ACLRepo, AuditRepo,
β”‚   β”‚   β”‚                               # MimeRepo, DuplicatePairRepo, DismissalRepo
β”‚   β”‚   └── storage.go                  # FileStorage (originals + thumbnail/preview cache)
β”‚   β”‚
β”‚   β”œβ”€β”€ service/                        # Business logic (use cases)
β”‚   β”‚   β”œβ”€β”€ file_service.go             # Upload, update, delete, trash/restore, replace, import, filter/list
β”‚   β”‚   β”œβ”€β”€ tag_service.go              # CRUD + auto-tag (rule) application
β”‚   β”‚   β”œβ”€β”€ category_service.go         # CRUD (thin: repo + ACL + audit)
β”‚   β”‚   β”œβ”€β”€ pool_service.go             # CRUD + file ordering, add/remove files
β”‚   β”‚   β”œβ”€β”€ auth_service.go             # Login, logout, JWT issue/refresh, content tokens, sessions
β”‚   β”‚   β”œβ”€β”€ acl_service.go              # Permission checks, grant/revoke
β”‚   β”‚   β”œβ”€β”€ audit_service.go            # Log actions, query audit log
β”‚   β”‚   β”œβ”€β”€ user_service.go             # Profile update, admin CRUD, block/unblock, EnsureAdmin
β”‚   β”‚   β”œβ”€β”€ duplicate_service.go        # Cluster / resolve (merge) / dismiss + rescan orchestration
β”‚   β”‚   β”œβ”€β”€ duplicate_index.go          # BK-tree, Hamming pairing, connected-component clustering
β”‚   β”‚   └── metadata.go                 # EXIF / media metadata extraction (exiftool + pure-Go fallback)
β”‚   β”‚
β”‚   β”œβ”€β”€ handler/                        # HTTP layer (Gin handlers)
β”‚   β”‚   β”œβ”€β”€ router.go                   # Route registration, middleware, security headers, SPA fallback
β”‚   β”‚   β”œβ”€β”€ middleware.go               # Auth middleware (JWT / content token β†’ context)
β”‚   β”‚   β”œβ”€β”€ ratelimit.go                # Per-IP token-bucket limiter for /auth
β”‚   β”‚   β”œβ”€β”€ response.go                 # Error/success builders, domain-error β†’ HTTP mapping
β”‚   β”‚   β”œβ”€β”€ static.go                   # Built SPA serving + index.html fallback
β”‚   β”‚   β”œβ”€β”€ file_handler.go             # /files endpoints
β”‚   β”‚   β”œβ”€β”€ duplicate_handler.go        # /files/duplicates endpoints
β”‚   β”‚   β”œβ”€β”€ tag_handler.go              # /tags endpoints (+ file–tag relations)
β”‚   β”‚   β”œβ”€β”€ 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 endpoint
β”‚   β”‚
β”‚   β”œβ”€β”€ db/                             # Database adapters
β”‚   β”‚   β”œβ”€β”€ db.go                       # Shared helpers: Querier, tx-from-context, ScanRow, limit/offset clamps
β”‚   β”‚   └── postgres/                   # PostgreSQL implementation
β”‚   β”‚       β”œβ”€β”€ postgres.go             # pgxpool init, Transactor, conn-or-tx helper
β”‚   β”‚       β”œβ”€β”€ file_repo.go            # FileRepo (incl. perceptual-hash projections)
β”‚   β”‚       β”œβ”€β”€ tag_repo.go             # TagRepo + TagRuleRepo
β”‚   β”‚       β”œβ”€β”€ category_repo.go        # CategoryRepo
β”‚   β”‚       β”œβ”€β”€ pool_repo.go            # PoolRepo
β”‚   β”‚       β”œβ”€β”€ user_repo.go            # UserRepo
β”‚   β”‚       β”œβ”€β”€ session_repo.go         # SessionRepo
β”‚   β”‚       β”œβ”€β”€ acl_repo.go             # ACLRepo
β”‚   β”‚       β”œβ”€β”€ audit_repo.go           # AuditRepo
β”‚   β”‚       β”œβ”€β”€ mime_repo.go            # MimeRepo
β”‚   β”‚       β”œβ”€β”€ duplicate_repo.go       # DuplicatePairRepo + DismissalRepo
β”‚   β”‚       └── filter_parser.go        # Filter DSL β†’ SQL WHERE clause builder
β”‚   β”‚
β”‚   β”œβ”€β”€ storage/                        # File storage adapter
β”‚   β”‚   └── disk.go                     # FileStorage on disk: originals + thumbnail/preview cache
β”‚   β”‚                                   # (vipsthumbnail / ffmpeg / pure-Go imaging)
β”‚   β”‚
β”‚   β”œβ”€β”€ imagehash/                      # Perceptual hashing (64-bit dHash) for near-duplicate detection
β”‚   β”‚   └── imagehash.go
β”‚   β”‚
β”‚   β”œβ”€β”€ integration/                    # End-to-end HTTP tests against a disposable Postgres
β”‚   β”‚   └── server_test.go
β”‚   β”‚
β”‚   └── config/                         # Configuration
β”‚       └── config.go                   # Config struct + loader from env vars
β”‚
β”œβ”€β”€ migrations/                         # SQL migration files (goose format), embedded via go:embed
β”‚   β”œβ”€β”€ 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
β”‚   └── embed.go                        # //go:embed *.sql β†’ migrations.FS
β”‚
β”œβ”€β”€ go.mod
└── go.sum

Layer Dependency Rules

handler  β†’  service  β†’  port (interfaces)  ←  db/postgres / storage
                ↓
              domain (entities, value objects, errors)

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. Constructors take their collaborators explicitly; the shape below matches the real signatures.

// Pseudocode β€” see cmd/server/main.go for the exact calls.
pool := postgres.NewPool(ctx, cfg.DatabaseURL)
goose.Up(stdlib.OpenDBFromPool(pool), ".")   // migrations.FS embedded

// Storage
diskStorage := storage.NewDiskStorage(
    cfg.FilesPath, cfg.ThumbsCachePath,
    cfg.ThumbWidth, cfg.ThumbHeight, cfg.PreviewWidth, cfg.PreviewHeight,
    cfg.ThumbMaxPixels, cfg.ThumbConcurrency,
)

// Repos (all from internal/db/postgres/)
fileRepo := postgres.NewFileRepo(pool)
// … tag, tagRule, category, pool, user, session, acl, audit, mime,
//    duplicatePair, dismissal repos + transactor

// Services
authSvc := service.NewAuthService(userRepo, sessionRepo,
    cfg.JWTSecret, cfg.JWTAccessTTL, cfg.JWTRefreshTTL, cfg.ContentTokenTTL)
aclSvc  := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
auditSvc := service.NewAuditService(auditRepo)
tagSvc  := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
dupSvc  := service.NewDuplicateService(fileRepo, duplicatePairRepo, dismissalRepo,
    aclSvc, auditSvc, transactor, cfg.DuplicateHashThreshold)
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage,
    aclSvc, auditSvc, tagSvc, transactor, cfg.ImportPath)
// … category, pool, user services

// Bootstrap the initial admin from env (idempotent).
userSvc.EnsureAdmin(ctx, cfg.AdminUsername, cfg.AdminPassword)

// Handlers β†’ router (also wires trusted proxies + optional static SPA dir)
router, _ := handler.NewRouter(authMiddleware, authHandler, fileHandler,
    duplicateHandler, tagHandler, categoryHandler, poolHandler,
    userHandler, aclHandler, auditHandler, cfg.StaticDir, cfg.TrustedProxies)
srv.ListenAndServe()

Context Propagation

Every service method receives context.Context as the first argument. The auth middleware parses the JWT and puts the caller’s identity (user id, admin flag, session id) into the context. Services read it for ACL checks and audit logging.

// handler/middleware.go
claims := parseJWT(c.GetHeader("Authorization"))
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
c.Request = c.Request.WithContext(ctx)

// domain/context.go
func WithUser(ctx context.Context, userID int16, isAdmin bool, sessionID int) context.Context
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool, sessionID int)

Transaction Management

The Transactor port lets services compose multiple repo calls atomically. The postgres implementation stores the active pgx.Tx in the context; repo methods pick it up via a conn-or-tx helper, so the same method works inside or outside a transaction.

// port/repository.go
type Transactor interface {
    WithTx(ctx context.Context, fn func(ctx context.Context) error) error
}

// service/file_service.go (sketch)
func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, error) {
    return s.tx.WithTx(ctx, func(ctx context.Context) error {
        created, err := s.files.Create(ctx, f)   // uses tx from ctx
        // apply initial tags, etc., in the same tx
        return err
    })
}

ACL Check Pattern

ACL logic is centralized in ACLService. Other services call it before any mutation or retrieval. The model is private-by-default: admins see everything; otherwise a public flag, creator ownership, or an explicit acl.permissions grant is required.

// service/acl_service.go (shape)
func (s *ACLService) CanView(ctx context.Context, userID int16, isAdmin bool,
    creatorID int16, isPublic bool, objectType int16, objectID uuid.UUID) (bool, error)
func (s *ACLService) CanEdit(ctx context.Context, userID int16, isAdmin bool,
    creatorID int16, objectType int16, objectID uuid.UUID) (bool, error)

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 β€” a PostgreSQL-specific adapter concern. The service layer passes the raw DSL string down; the repository parses it and builds the query. For a different DBMS, a corresponding parser would live in db/<dbms>/filter_parser.go.

// 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
}

The DSL grammar itself is documented in openapi.yaml (the filter query parameter), so the contract stays in one place.

JWT Structure

type Claims struct {
    jwt.RegisteredClaims
    UserID    int16 `json:"uid"`
    IsAdmin   bool  `json:"adm"`
    SessionID int   `json:"sid"`
}

Access token: short-lived (15 min default). Refresh token: long-lived (30 days default), rotated on use, stored as a hash in activity.sessions. A separate content token (default 6 h) is a single-file capability minted for media URLs, so a long video keeps streaming past access-token expiry β€” see CONTENT_TOKEN_TTL in .env.example.

Perceptual Duplicate Detection

Images are dHash-ed inline on upload (internal/imagehash); video hashes are backfilled by the dedup CLI (ffmpeg stays off the upload path). A rescan rebuilds data.duplicate_pairs by inserting every pair within DUPLICATE_HASH_THRESHOLD Hamming distance (BK-tree lookups, not O(NΒ²)); the duplicates API then groups pairs into connected-component clusters. See service/duplicate_service.go and service/duplicate_index.go.

Configuration (.env)

Every variable the server reads is documented in .env.example (1:1 with config.Config). Required at startup: JWT_SECRET, ADMIN_PASSWORD, DATABASE_URL, FILES_PATH, THUMBS_CACHE_PATH, IMPORT_PATH. Everything else has a sensible default (see config.go).