# 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//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= 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 ```