Backend counterpart of ARCHITECTURE.md. This document details the Go layout, the layer rules, and the key backend decisions.
go:embed (auto-applied on startup).env (joho/godotenv)internal/imagehash, internal/service/duplicate_*)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.
tanabata/
βββ backend/ β Go project
βββ frontend/ β SvelteKit project
βββ openapi.yaml β Shared API contract
βββ docker-compose.yml
βββ Dockerfile
βββ .env.example
βββ README.md
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
handler β service β port (interfaces) β db/postgres / storage
β
domain (entities, value objects, errors)
No layer may import a layer above it. No circular dependencies.
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()
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)
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 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)
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 |
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.
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.
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.
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).