From b0356bf103b0f6fe5838255fb9de026ce09395ad Mon Sep 17 00:00:00 2001 From: Huxley Date: Wed, 29 Apr 2026 17:36:04 +0800 Subject: [PATCH 01/31] Refactor auth handler into separate account handler --- internal/handler/account.go | 107 +++++++++++++++++++ internal/handler/account_test.go | 157 ++++++++++++++++++++++++++++ internal/handler/auth.go | 87 --------------- internal/handler/auth_test.go | 129 ++--------------------- internal/server/routes_protected.go | 10 +- 5 files changed, 277 insertions(+), 213 deletions(-) create mode 100644 internal/handler/account.go create mode 100644 internal/handler/account_test.go diff --git a/internal/handler/account.go b/internal/handler/account.go new file mode 100644 index 0000000..67b72f9 --- /dev/null +++ b/internal/handler/account.go @@ -0,0 +1,107 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/dhao2001/mygo/internal/api" + "github.com/dhao2001/mygo/internal/middleware" + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/service" +) + +// AccountHandler handles authenticated account endpoints. +type AccountHandler struct { + authService *service.AuthService +} + +// NewAccountHandler creates an AccountHandler. +func NewAccountHandler(authService *service.AuthService) *AccountHandler { + return &AccountHandler{authService: authService} +} + +type createPasskeyRequest struct { + Label string `json:"label" binding:"required"` +} + +// GetAccount handles GET /api/v1/account. +func (h *AccountHandler) GetAccount(c *gin.Context) { + userID := middleware.GetUserID(c) + if userID == "" { + api.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + + c.JSON(http.StatusOK, gin.H{"user_id": userID}) +} + +// ListPasskeys handles GET /api/v1/account/passkeys. +func (h *AccountHandler) ListPasskeys(c *gin.Context) { + userID := middleware.GetUserID(c) + if userID == "" { + api.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + + creds, err := h.authService.ListPasskeys(c.Request.Context(), userID) + if err != nil { + api.Error(c, http.StatusInternalServerError, err.Error()) + return + } + + if creds == nil { + creds = []model.Credential{} + } + + c.JSON(http.StatusOK, creds) +} + +// CreatePasskey handles POST /api/v1/account/passkeys. +func (h *AccountHandler) CreatePasskey(c *gin.Context) { + userID := middleware.GetUserID(c) + if userID == "" { + api.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + + var req createPasskeyRequest + if err := c.ShouldBindJSON(&req); err != nil { + api.Error(c, http.StatusBadRequest, "invalid request: "+err.Error()) + return + } + + pk, err := h.authService.CreatePasskey(c.Request.Context(), userID, req.Label) + if err != nil { + api.Error(c, http.StatusInternalServerError, err.Error()) + return + } + + c.JSON(http.StatusCreated, pk) +} + +// RevokePasskey handles DELETE /api/v1/account/passkeys/:id. +func (h *AccountHandler) RevokePasskey(c *gin.Context) { + userID := middleware.GetUserID(c) + if userID == "" { + api.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + + passkeyID := c.Param("id") + if passkeyID == "" { + api.Error(c, http.StatusBadRequest, "missing passkey id") + return + } + + if err := h.authService.RevokePasskey(c.Request.Context(), userID, passkeyID); err != nil { + if err == model.ErrForbidden { + api.Error(c, http.StatusForbidden, err.Error()) + return + } + api.Error(c, http.StatusInternalServerError, err.Error()) + return + } + + c.Status(http.StatusOK) +} diff --git a/internal/handler/account_test.go b/internal/handler/account_test.go new file mode 100644 index 0000000..e8fd1e8 --- /dev/null +++ b/internal/handler/account_test.go @@ -0,0 +1,157 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/dhao2001/mygo/internal/middleware" + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/service" +) + +func setupAccountHandler(t *testing.T) (*AccountHandler, []byte) { + t.Helper() + svc, secret := setupTestAuthService(t) + return NewAccountHandler(svc), secret +} + +func setupAccountRouter(t *testing.T) (*gin.Engine, []byte) { + t.Helper() + + svc, secret := setupTestAuthService(t) + authHandler := NewAuthHandler(svc) + accountHandler := NewAccountHandler(svc) + + gin.SetMode(gin.TestMode) + r := gin.New() + + auth := r.Group("/api/v1/auth") + { + auth.POST("/register", authHandler.Register) + auth.POST("/login", authHandler.Login) + } + + protected := r.Group("/api/v1") + protected.Use(middleware.AuthRequired(secret)) + { + account := protected.Group("/account") + { + account.GET("", accountHandler.GetAccount) + + passkeys := account.Group("/passkeys") + { + passkeys.GET("", accountHandler.ListPasskeys) + passkeys.POST("", accountHandler.CreatePasskey) + passkeys.DELETE("/:id", accountHandler.RevokePasskey) + } + } + } + + return r, secret +} + +func TestAccountEndpoint(t *testing.T) { + r, _ := setupAccountRouter(t) + + // Register + Login + body, _ := json.Marshal(gin.H{"username": "alice", "email": "alice@example.com", "password": "password123"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + loginBody, _ := json.Marshal(gin.H{"email": "alice@example.com", "password": "password123"}) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + var pair service.TokenPair + json.Unmarshal(rec.Body.Bytes(), &pair) + + // Get /account + req = httptest.NewRequest(http.MethodGet, "/api/v1/account", nil) + req.Header.Set("Authorization", "Bearer "+pair.AccessToken) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestAccountEndpointUnauthorized(t *testing.T) { + r, _ := setupAccountRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/account", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} + +func TestPasskeyCRUD(t *testing.T) { + r, _ := setupAccountRouter(t) + + // Register + Login + body, _ := json.Marshal(gin.H{"username": "alice", "email": "alice@example.com", "password": "password123"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + loginBody, _ := json.Marshal(gin.H{"email": "alice@example.com", "password": "password123"}) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + var pair service.TokenPair + json.Unmarshal(rec.Body.Bytes(), &pair) + authHeader := "Bearer " + pair.AccessToken + + // Create passkey + pkBody, _ := json.Marshal(gin.H{"label": "My Phone"}) + req = httptest.NewRequest(http.MethodPost, "/api/v1/account/passkeys", bytes.NewReader(pkBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", authHeader) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("create passkey: status = %d, body = %s", rec.Code, rec.Body.String()) + } + + // List passkeys + req = httptest.NewRequest(http.MethodGet, "/api/v1/account/passkeys", nil) + req.Header.Set("Authorization", authHeader) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("list passkeys: status = %d", rec.Code) + } + + // Revoke passkey + var creds []model.Credential + json.Unmarshal(rec.Body.Bytes(), &creds) + if len(creds) != 1 { + t.Fatalf("expected 1 passkey, got %d", len(creds)) + } + + req = httptest.NewRequest(http.MethodDelete, "/api/v1/account/passkeys/"+creds[0].ID, nil) + req.Header.Set("Authorization", authHeader) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("revoke passkey: status = %d", rec.Code) + } +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go index d33ad21..091b4b9 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -6,8 +6,6 @@ import ( "github.com/gin-gonic/gin" "github.com/dhao2001/mygo/internal/api" - "github.com/dhao2001/mygo/internal/middleware" - "github.com/dhao2001/mygo/internal/model" "github.com/dhao2001/mygo/internal/service" ) @@ -102,88 +100,3 @@ func (h *AuthHandler) Logout(c *gin.Context) { c.Status(http.StatusOK) } - -// GetAccount handles GET /api/v1/account. -func (h *AuthHandler) GetAccount(c *gin.Context) { - userID := middleware.GetUserID(c) - if userID == "" { - api.Error(c, http.StatusUnauthorized, "unauthorized") - return - } - - c.JSON(http.StatusOK, gin.H{"user_id": userID}) -} - -type createPasskeyRequest struct { - Label string `json:"label" binding:"required"` -} - -// ListPasskeys handles GET /api/v1/users/me/passkeys. -func (h *AuthHandler) ListPasskeys(c *gin.Context) { - userID := middleware.GetUserID(c) - if userID == "" { - api.Error(c, http.StatusUnauthorized, "unauthorized") - return - } - - creds, err := h.authService.ListPasskeys(c.Request.Context(), userID) - if err != nil { - api.Error(c, http.StatusInternalServerError, err.Error()) - return - } - - if creds == nil { - creds = []model.Credential{} - } - - c.JSON(http.StatusOK, creds) -} - -// CreatePasskey handles POST /api/v1/users/me/passkeys. -func (h *AuthHandler) CreatePasskey(c *gin.Context) { - userID := middleware.GetUserID(c) - if userID == "" { - api.Error(c, http.StatusUnauthorized, "unauthorized") - return - } - - var req createPasskeyRequest - if err := c.ShouldBindJSON(&req); err != nil { - api.Error(c, http.StatusBadRequest, "invalid request: "+err.Error()) - return - } - - pk, err := h.authService.CreatePasskey(c.Request.Context(), userID, req.Label) - if err != nil { - api.Error(c, http.StatusInternalServerError, err.Error()) - return - } - - c.JSON(http.StatusCreated, pk) -} - -// RevokePasskey handles DELETE /api/v1/users/me/passkeys/:id. -func (h *AuthHandler) RevokePasskey(c *gin.Context) { - userID := middleware.GetUserID(c) - if userID == "" { - api.Error(c, http.StatusUnauthorized, "unauthorized") - return - } - - passkeyID := c.Param("id") - if passkeyID == "" { - api.Error(c, http.StatusBadRequest, "missing passkey id") - return - } - - if err := h.authService.RevokePasskey(c.Request.Context(), userID, passkeyID); err != nil { - if err == model.ErrForbidden { - api.Error(c, http.StatusForbidden, err.Error()) - return - } - api.Error(c, http.StatusInternalServerError, err.Error()) - return - } - - c.Status(http.StatusOK) -} diff --git a/internal/handler/auth_test.go b/internal/handler/auth_test.go index 21f47d3..7078a85 100644 --- a/internal/handler/auth_test.go +++ b/internal/handler/auth_test.go @@ -12,13 +12,12 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" - "github.com/dhao2001/mygo/internal/middleware" "github.com/dhao2001/mygo/internal/model" "github.com/dhao2001/mygo/internal/repository" "github.com/dhao2001/mygo/internal/service" ) -func setupAuthHandler(t *testing.T) (*AuthHandler, []byte) { +func setupTestAuthService(t *testing.T) (*service.AuthService, []byte) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -39,7 +38,13 @@ func setupAuthHandler(t *testing.T) (*AuthHandler, []byte) { 7*24*time.Hour, ) - return NewAuthHandler(authService), secret + return authService, secret +} + +func setupAuthHandler(t *testing.T) (*AuthHandler, []byte) { + t.Helper() + svc, secret := setupTestAuthService(t) + return NewAuthHandler(svc), secret } func setupAuthRouter(t *testing.T) (*gin.Engine, []byte) { @@ -58,22 +63,6 @@ func setupAuthRouter(t *testing.T) (*gin.Engine, []byte) { auth.POST("/logout", handler.Logout) } - protected := r.Group("/api/v1") - protected.Use(middleware.AuthRequired(secret)) - { - account := protected.Group("/account") - { - account.GET("", handler.GetAccount) - - passkeys := account.Group("/passkeys") - { - passkeys.GET("", handler.ListPasskeys) - passkeys.POST("", handler.CreatePasskey) - passkeys.DELETE("/:id", handler.RevokePasskey) - } - } - } - return r, secret } @@ -198,7 +187,6 @@ func TestRefreshHandler(t *testing.T) { }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - httptest.NewRecorder().Body = nil rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -222,104 +210,3 @@ func TestRefreshHandler(t *testing.T) { t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) } } - -func TestAccountEndpoint(t *testing.T) { - r, _ := setupAuthRouter(t) - - // Register + Login - body, _ := json.Marshal(gin.H{"username": "alice", "email": "alice@example.com", "password": "password123"}) - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - loginBody, _ := json.Marshal(gin.H{"email": "alice@example.com", "password": "password123"}) - req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - var pair service.TokenPair - json.Unmarshal(rec.Body.Bytes(), &pair) - - // Get /account - req = httptest.NewRequest(http.MethodGet, "/api/v1/account", nil) - req.Header.Set("Authorization", "Bearer "+pair.AccessToken) - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) - } -} - -func TestAccountEndpointUnauthorized(t *testing.T) { - r, _ := setupAuthRouter(t) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/account", nil) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusUnauthorized { - t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) - } -} - -func TestPasskeyCRUD(t *testing.T) { - r, _ := setupAuthRouter(t) - - // Register + Login - body, _ := json.Marshal(gin.H{"username": "alice", "email": "alice@example.com", "password": "password123"}) - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - loginBody, _ := json.Marshal(gin.H{"email": "alice@example.com", "password": "password123"}) - req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - var pair service.TokenPair - json.Unmarshal(rec.Body.Bytes(), &pair) - authHeader := "Bearer " + pair.AccessToken - - // Create passkey - pkBody, _ := json.Marshal(gin.H{"label": "My Phone"}) - req = httptest.NewRequest(http.MethodPost, "/api/v1/account/passkeys", bytes.NewReader(pkBody)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", authHeader) - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Fatalf("create passkey: status = %d, body = %s", rec.Code, rec.Body.String()) - } - - // List passkeys - req = httptest.NewRequest(http.MethodGet, "/api/v1/account/passkeys", nil) - req.Header.Set("Authorization", authHeader) - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("list passkeys: status = %d", rec.Code) - } - - // Revoke passkey - var creds []model.Credential - json.Unmarshal(rec.Body.Bytes(), &creds) - if len(creds) != 1 { - t.Fatalf("expected 1 passkey, got %d", len(creds)) - } - - req = httptest.NewRequest(http.MethodDelete, "/api/v1/account/passkeys/"+creds[0].ID, nil) - req.Header.Set("Authorization", authHeader) - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("revoke passkey: status = %d", rec.Code) - } -} diff --git a/internal/server/routes_protected.go b/internal/server/routes_protected.go index 35f9e00..7b8986f 100644 --- a/internal/server/routes_protected.go +++ b/internal/server/routes_protected.go @@ -10,19 +10,19 @@ import ( func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) { jwtSecret := []byte(webApp.Config.JWT.Secret) - authHandler := handler.NewAuthHandler(webApp.AuthService) + accountHandler := handler.NewAccountHandler(webApp.AuthService) rg.Use(middleware.AuthRequired(jwtSecret)) account := rg.Group("/account") { - account.GET("", authHandler.GetAccount) + account.GET("", accountHandler.GetAccount) passkeys := account.Group("/passkeys") { - passkeys.GET("", authHandler.ListPasskeys) - passkeys.POST("", authHandler.CreatePasskey) - passkeys.DELETE("/:id", authHandler.RevokePasskey) + passkeys.GET("", accountHandler.ListPasskeys) + passkeys.POST("", accountHandler.CreatePasskey) + passkeys.DELETE("/:id", accountHandler.RevokePasskey) } } } From eaa31efd645673fb06ef1751f0c1a4eb0df8c864 Mon Sep 17 00:00:00 2001 From: Huxley Date: Fri, 1 May 2026 01:27:13 +0800 Subject: [PATCH 02/31] Update architecture and decisions docs with auth refinements --- docs/architecture.md | 23 ++++++++++++----------- docs/decisions.md | 17 +++++++++++++++++ docs/development.md | 27 +++++++++++++++++++++++++-- docs/roadmap.md | 6 +++--- 4 files changed, 57 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d4e4e7e..638a631 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,20 +23,20 @@ Rules: | Layer | Package | Purpose | Status | |-------|---------|---------|--------| | **CLI** | `cmd` | Cobra root command | πŸ›  WIP | -| | `cmd/serve.go` | `mygo serve` β€” wire deps, start HTTP | πŸ›  WIP | +| | `cmd/serve.go` | `mygo serve` β€” wire deps, start HTTP | βœ… | | | `cmd/config.go` | `mygo config` β€” config subcommand | πŸ›  WIP | | | `cmd/status.go` | `mygo status` β€” health check | πŸ›  WIP | -| **Config** | `internal/config` | Viper load (YAML + env + flags) | πŸ›  WIP | +| **Config** | `internal/config` | Viper load (YAML + env + flags), typed Duration config via built-in decode hook | βœ… | | **App** | `internal/app` | Runtime dependency container and build metadata | πŸ›  WIP | -| **HTTP** | `internal/server` | Gin router init, route registration, graceful shutdown | πŸ›  WIP | +| **HTTP** | `internal/server` | Gin router init, route registration (public/protected split), graceful shutdown | βœ… | | | `internal/handler` | HTTP handlers (auth, file, admin, webdav...) | πŸ›  WIP | -| | `internal/middleware` | Gin middleware (logger, cors, auth) | πŸ›  WIP | -| **Business** | `internal/service` | Business logic (auth, file, admin) | πŸ›  WIP | -| | `internal/model` | Domain types (User, File, errors) | πŸ›  WIP | -| **Data** | `internal/repository` | Repository interfaces + GORM implementations | πŸ›  WIP | +| | `internal/middleware` | Gin middleware (logger, jwt, cors, auth) | πŸ›  WIP | +| **Business** | `internal/service` | Business logic: `AuthService` (register, login, refresh, logout, passkey CRUD) | βœ… | +| | `internal/model` | Domain types (User, File, Credential, Session), error codes | βœ… | +| **Data** | `internal/repository` | Repository interfaces + GORM implementations (User, Session, File, Credential) | βœ… | | | `internal/storage` | Storage backend interface + local disk impl | πŸ›  WIP | -| **Util** | `internal/auth` | JWT sign/verify, context helpers | πŸ›  WIP | -| | `internal/api` | Error body helpers | πŸ›  WIP | +| **Util** | `internal/auth` | JWT sign/verify (HS256), token type discrimination (access/refresh), password hashing (bcrypt), app passkey tokens | βœ… | +| | `internal/api` | Unified JSON error response helpers | βœ… | ## API Routes (v0) @@ -76,7 +76,8 @@ Applied to protected groups: auth (JWT validation, inject user into gin.Context) ## Server Responsibilities -- `cmd/serve.go` loads config, creates `app.WebApp`, builds the router, and starts the HTTP server. +- `cmd/serve.go` loads config, calls `app.Bootstrap` to initialize DB + services, builds the router, and starts the HTTP server. - `app.WebApp` carries runtime dependencies and build metadata needed to assemble handlers. -- `internal/server` owns Gin router setup (`router.go`), route registration split into `routes_public.go` and `routes_protected.go`, and HTTP server lifecycle. +- `internal/server` owns Gin router setup (`router.go`), route registration split into `routes_public.go` (public auth) and `routes_protected.go` (JWT-protected account). +- Each route group creates its own handler instance: `routes_public.go` creates `AuthHandler`, `routes_protected.go` creates `AccountHandler` β€” no shared handler state between public and protected routes. - `RunWithGracefulShutdown` stops accepting new requests on termination and gives in-flight requests time to finish. diff --git a/docs/decisions.md b/docs/decisions.md index 679fafa..97574e2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -48,3 +48,20 @@ - Version is build metadata from `internal/app/version.go`, not a config-file field. - `app.WebApp` is the place to add future services, repositories, storage, and app metadata incrementally. - Request ID middleware is not part of the current foundation; add it only with a logging/tracing/error-correlation design. + +## 2026-04-29: Auth Refinements + +**Context**: Auth layer had three structural weaknesses β€” handler duplication, indistinguishable token types, and fragile config duration parsing. + +**Decisions**: + +| Decision | Guidance | +|----------|----------| +| One handler per route group | `AuthHandler` owns `/auth/*` (public); `AccountHandler` owns `/account/*` (protected). A route group maps 1:1 to a handler type. | +| JWT `type` claim | `Claims.Type` distinguishes access from refresh tokens. Middleware and service enforce the correct type at their respective boundaries. `ParseToken` does no type check β€” it verifies cryptographic validity only. | +| `time.Duration` in config structs | Config fields representing durations use `time.Duration` directly. Viper's built-in `StringToTimeDurationHookFunc` handles stringβ†’Duration conversion at unmarshal time. No accessor methods, no runtime parsing. Invalid values fail at startup via `Load()`. | + +**Consequences**: +- Handlers are independently extensible (caching, rate limiting scoped per handler). +- Refresh tokens cannot authenticate API requests; access tokens cannot be used to issue new token pairs. +- New duration config fields require zero boilerplate β€” declare as `time.Duration` in the struct. diff --git a/docs/development.md b/docs/development.md index 2e6cc43..a3004cf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,12 +26,35 @@ go vet ./... go fmt ./... ``` +## Dependencies + +```bash +go mod tidy # after adding/removing imports +``` + ## Config -Server config is in `config.yaml` (symlink to `config.example.yaml` in development environment). +Server config is loaded via viper from `config.yaml` (defaults in `internal/config/load.go`). -``` +```yaml server: host: 0.0.0.0 port: 10086 + +database: + driver: sqlite3 + sqlite: + path: data/mygo.db + +storage: + driver: local + local: + path: data/files + +jwt: + secret: changeme-in-production + access_ttl: 15m + refresh_ttl: 168h ``` + +Environment variables use `MYGO_` prefix with underscore separators: `MYGO_SERVER_PORT=8080`, `MYGO_JWT_SECRET=...` diff --git a/docs/roadmap.md b/docs/roadmap.md index 4da8342..2c35b87 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,7 +4,7 @@ | Feature | Status | Notes | |---------|--------|-------| -| CLI config management | βœ… | | +| CLI config management | βœ… | Viper YAML + env + flags, typed Duration config | | JWT authentication | βœ… | access + refresh tokens, refresh token in DB, app passkey support | | Web API foundation | βœ… | WebApp composition, Gin router, graceful shutdown, `GET /api/v1/version` | | File upload/download/manage APIs | πŸ›  WIP | REST API via Gin | @@ -15,7 +15,7 @@ Package-level implementation order (each task includes unit tests): -1. `internal/config` β€” Viper loader, config struct +1. `internal/config` β€” Viper loader, config struct βœ… 2. `internal/app` β€” runtime dependency container βœ… 3. `internal/model` β€” domain types, error codes βœ… 4. `internal/api` β€” error response helpers βœ… @@ -24,7 +24,7 @@ Package-level implementation order (each task includes unit tests): 7. `internal/repository` β€” interfaces + GORM/SQLite impl βœ… 8. `internal/service` β€” auth, file, admin services βœ… (auth done) 9. `internal/middleware` β€” logger, cors, auth βœ… (auth done) -10. `internal/handler` β€” auth, file, admin handlers βœ… (auth done) +10. `internal/handler` β€” auth, account, file, admin handlers πŸ›  (auth + account done) 11. `internal/server` β€” Gin router, route registration, graceful shutdown βœ… 12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` βœ… (serve done) 13. Integration tests From e2af482cc97ba4c40e37d83f3252872b1353de7c Mon Sep 17 00:00:00 2001 From: Huxley Date: Wed, 24 Jun 2026 14:08:57 +0800 Subject: [PATCH 03/31] Add file management API with local storage backend - Implement FileHandler with CRUD operations for files/directories - Add FileService with business logic and SHA-256 hashing - Create LocalStorage backend for filesystem persistence - Add database repository with pagination and name uniqueness constraints - Configure max upload size in storage settings - Include comprehensive tests for all layers --- config.example.yaml | 3 + go.mod | 2 +- internal/app/webapp.go | 16 + internal/app/webapp_test.go | 4 +- internal/config/config.go | 9 +- internal/handler/account_test.go | 6 - internal/handler/file.go | 222 ++++++++++++++ internal/handler/file_test.go | 439 ++++++++++++++++++++++++++ internal/model/file.go | 7 +- internal/repository/file.go | 30 ++ internal/server/routes_protected.go | 11 + internal/server/server_test.go | 2 +- internal/service/file.go | 376 +++++++++++++++++++++++ internal/service/file_test.go | 458 ++++++++++++++++++++++++++++ internal/storage/local.go | 91 ++++++ internal/storage/local_test.go | 130 ++++++++ internal/storage/storage.go | 24 ++ 17 files changed, 1815 insertions(+), 15 deletions(-) create mode 100644 internal/handler/file.go create mode 100644 internal/handler/file_test.go create mode 100644 internal/service/file.go create mode 100644 internal/service/file_test.go create mode 100644 internal/storage/local.go create mode 100644 internal/storage/local_test.go create mode 100644 internal/storage/storage.go diff --git a/config.example.yaml b/config.example.yaml index e554dae..fce6528 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -19,6 +19,9 @@ storage: local: path: data/files + # Max upload file size in bytes. 0 = unlimited + max_upload_size: 104857600 # 100 MB + jwt: secret: change-me-in-production access_ttl: 15m diff --git a/go.mod b/go.mod index 69cb763..c1c7e17 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/dhao2001/mygo go 1.26.2 require ( + github.com/gabriel-vasile/mimetype v1.4.12 github.com/gin-gonic/gin v1.12.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 @@ -20,7 +21,6 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect diff --git a/internal/app/webapp.go b/internal/app/webapp.go index a916550..5edd262 100644 --- a/internal/app/webapp.go +++ b/internal/app/webapp.go @@ -8,6 +8,7 @@ import ( "github.com/dhao2001/mygo/internal/config" "github.com/dhao2001/mygo/internal/repository" "github.com/dhao2001/mygo/internal/service" + "github.com/dhao2001/mygo/internal/storage" ) // WebApp contains application-wide runtime dependencies and metadata. @@ -21,6 +22,8 @@ type WebApp struct { FileRepo repository.FileRepository CredentialRepo repository.CredentialRepository AuthService *service.AuthService + FileService *service.FileService + Storage storage.StorageBackend } // Bootstrap creates a fully initialized WebApp from config. @@ -48,6 +51,13 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) { cfg.JWT.RefreshTTL, ) + store, err := storage.NewLocalStorage(cfg.Storage.Local.Path) + if err != nil { + return nil, fmt.Errorf("init storage: %w", err) + } + + fileService := service.NewFileService(fileRepo, store, cfg.Storage.MaxUploadSize) + return &WebApp{ Config: cfg, Version: AppVersion, @@ -57,6 +67,8 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) { FileRepo: fileRepo, CredentialRepo: credentialRepo, AuthService: authService, + FileService: fileService, + Storage: store, }, nil } @@ -67,6 +79,8 @@ func NewWebApp(cfg *config.Config, db *gorm.DB, fileRepo repository.FileRepository, credentialRepo repository.CredentialRepository, authService *service.AuthService, + fileService *service.FileService, + store storage.StorageBackend, ) *WebApp { return &WebApp{ Config: cfg, @@ -77,6 +91,8 @@ func NewWebApp(cfg *config.Config, db *gorm.DB, FileRepo: fileRepo, CredentialRepo: credentialRepo, AuthService: authService, + FileService: fileService, + Storage: store, } } diff --git a/internal/app/webapp_test.go b/internal/app/webapp_test.go index 03f8c87..3403fc3 100644 --- a/internal/app/webapp_test.go +++ b/internal/app/webapp_test.go @@ -9,7 +9,7 @@ import ( func TestNewWebApp(t *testing.T) { cfg := &config.Config{} - webApp := NewWebApp(cfg, nil, nil, nil, nil, nil, nil) + webApp := NewWebApp(cfg, nil, nil, nil, nil, nil, nil, nil, nil) if webApp.Config != cfg { t.Fatal("Config was not assigned") @@ -20,7 +20,7 @@ func TestNewWebApp(t *testing.T) { } func TestCloseNilDB(t *testing.T) { - webApp := NewWebApp(&config.Config{}, nil, nil, nil, nil, nil, nil) + webApp := NewWebApp(&config.Config{}, nil, nil, nil, nil, nil, nil, nil, nil) if err := webApp.Close(); err != nil { t.Errorf("Close with nil DB should not error: %v", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 9e706b9..24fdfe4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,8 +39,9 @@ type PostgresConfig struct { } type StorageConfig struct { - Driver string `mapstructure:"driver"` - Local LocalStorageConfig `mapstructure:"local"` + Driver string `mapstructure:"driver"` + Local LocalStorageConfig `mapstructure:"local"` + MaxUploadSize int64 `mapstructure:"max_upload_size"` } type LocalStorageConfig struct { @@ -90,6 +91,10 @@ func (c *Config) Validate() error { errs = append(errs, errors.New("storage.local.path: must not be empty")) } + if c.Storage.MaxUploadSize < 0 { + errs = append(errs, errors.New("storage.max_upload_size: must not be negative")) + } + if c.JWT.Secret == "" { errs = append(errs, errors.New("jwt.secret: must not be empty")) } diff --git a/internal/handler/account_test.go b/internal/handler/account_test.go index e8fd1e8..4c6c7b0 100644 --- a/internal/handler/account_test.go +++ b/internal/handler/account_test.go @@ -14,12 +14,6 @@ import ( "github.com/dhao2001/mygo/internal/service" ) -func setupAccountHandler(t *testing.T) (*AccountHandler, []byte) { - t.Helper() - svc, secret := setupTestAuthService(t) - return NewAccountHandler(svc), secret -} - func setupAccountRouter(t *testing.T) (*gin.Engine, []byte) { t.Helper() diff --git a/internal/handler/file.go b/internal/handler/file.go new file mode 100644 index 0000000..b858d12 --- /dev/null +++ b/internal/handler/file.go @@ -0,0 +1,222 @@ +package handler + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/dhao2001/mygo/internal/api" + "github.com/dhao2001/mygo/internal/middleware" + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/service" +) + +// FileHandler handles file management endpoints. +type FileHandler struct { + fileService *service.FileService +} + +// NewFileHandler creates a FileHandler. +func NewFileHandler(fileService *service.FileService) *FileHandler { + return &FileHandler{fileService: fileService} +} + +type createDirRequest struct { + Name string `json:"name" binding:"required"` + ParentID *string `json:"parent_id"` +} + +type updateFileRequest struct { + Name string `json:"name"` + ParentID *string `json:"parent_id"` +} + +// Upload handles POST /api/v1/files. +// If the content type is multipart/form-data, it uploads a file. +// If the content type is application/json, it creates a directory. +func (h *FileHandler) Upload(c *gin.Context) { + userID := middleware.GetUserID(c) + contentType := c.GetHeader("Content-Type") + + // Directory creation via JSON. + if len(contentType) >= 16 && contentType[:16] == "application/json" { + var req createDirRequest + if err := c.ShouldBindJSON(&req); err != nil { + api.Error(c, http.StatusBadRequest, "invalid request: "+err.Error()) + return + } + + dir, err := h.fileService.CreateDir(c.Request.Context(), userID, req.ParentID, req.Name) + if err != nil { + api.Error(c, mapError(err), err.Error()) + return + } + + c.JSON(http.StatusCreated, dir) + return + } + + // File upload via multipart. + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { + api.Error(c, http.StatusBadRequest, "invalid multipart form: "+err.Error()) + return + } + + parentIDStr := c.PostForm("parent_id") + var parentID *string + if parentIDStr != "" { + parentID = &parentIDStr + } + + header, err := c.FormFile("file") + if err != nil { + api.Error(c, http.StatusBadRequest, "missing file field: "+err.Error()) + return + } + + file, err := header.Open() + if err != nil { + api.Error(c, http.StatusInternalServerError, "open uploaded file: "+err.Error()) + return + } + defer file.Close() + + info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, header.Filename, file, header.Header.Get("Content-Type")) + if err != nil { + api.Error(c, mapError(err), err.Error()) + return + } + + c.JSON(http.StatusCreated, info) +} + +// List handles GET /api/v1/files. +func (h *FileHandler) List(c *gin.Context) { + userID := middleware.GetUserID(c) + + parentIDStr := c.Query("parent_id") + var parentID *string + if parentIDStr != "" { + parentID = &parentIDStr + } + + offset, err := strconv.Atoi(c.DefaultQuery("offset", "0")) + if err != nil || offset < 0 { + api.Error(c, http.StatusBadRequest, "invalid offset") + return + } + + limit, err := strconv.Atoi(c.DefaultQuery("limit", "50")) + if err != nil || limit < 1 { + api.Error(c, http.StatusBadRequest, "invalid limit") + return + } + if limit > 200 { + limit = 200 + } + + list, err := h.fileService.List(c.Request.Context(), userID, parentID, offset, limit) + if err != nil { + api.Error(c, mapError(err), err.Error()) + return + } + + c.JSON(http.StatusOK, list) +} + +// Get handles GET /api/v1/files/:id. +func (h *FileHandler) Get(c *gin.Context) { + userID := middleware.GetUserID(c) + fileID := c.Param("id") + + info, err := h.fileService.Get(c.Request.Context(), userID, fileID) + if err != nil { + api.Error(c, mapError(err), err.Error()) + return + } + + c.JSON(http.StatusOK, info) +} + +// Download handles GET /api/v1/files/:id/content. +func (h *FileHandler) Download(c *gin.Context) { + userID := middleware.GetUserID(c) + fileID := c.Param("id") + + reader, info, err := h.fileService.Download(c.Request.Context(), userID, fileID) + if err != nil { + api.Error(c, mapError(err), err.Error()) + return + } + defer reader.Close() + + mimeType := info.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + + c.Header("Content-Type", mimeType) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", url.PathEscape(info.Name))) + c.Header("Content-Length", strconv.FormatInt(info.Size, 10)) + + c.Status(http.StatusOK) + io.Copy(c.Writer, reader) +} + +// Update handles PUT /api/v1/files/:id. +func (h *FileHandler) Update(c *gin.Context) { + userID := middleware.GetUserID(c) + fileID := c.Param("id") + + var req updateFileRequest + if err := c.ShouldBindJSON(&req); err != nil { + api.Error(c, http.StatusBadRequest, "invalid request: "+err.Error()) + return + } + + // Validate at least one field is provided. + if req.Name == "" && req.ParentID == nil { + api.Error(c, http.StatusBadRequest, "at least one of name or parent_id must be provided") + return + } + + info, err := h.fileService.Update(c.Request.Context(), userID, fileID, req.Name, req.ParentID) + if err != nil { + api.Error(c, mapError(err), err.Error()) + return + } + + c.JSON(http.StatusOK, info) +} + +// Delete handles DELETE /api/v1/files/:id. +func (h *FileHandler) Delete(c *gin.Context) { + userID := middleware.GetUserID(c) + fileID := c.Param("id") + + if err := h.fileService.Delete(c.Request.Context(), userID, fileID); err != nil { + api.Error(c, mapError(err), err.Error()) + return + } + + c.Status(http.StatusNoContent) +} + +// mapError maps sentinel errors to HTTP status codes. +func mapError(err error) int { + switch { + case errors.Is(err, model.ErrNotFound): + return http.StatusNotFound + case errors.Is(err, model.ErrForbidden): + return http.StatusForbidden + case errors.Is(err, model.ErrDuplicate): + return http.StatusConflict + default: + return http.StatusInternalServerError + } +} diff --git a/internal/handler/file_test.go b/internal/handler/file_test.go new file mode 100644 index 0000000..68f2062 --- /dev/null +++ b/internal/handler/file_test.go @@ -0,0 +1,439 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/repository" + "github.com/dhao2001/mygo/internal/service" + "github.com/dhao2001/mygo/internal/storage" +) + +// inMemStore implements storage.StorageBackend with an in-memory map. +type inMemStore struct { + files map[string][]byte +} + +func newInMemStore() *inMemStore { + return &inMemStore{files: make(map[string][]byte)} +} + +func (s *inMemStore) Save(_ context.Context, path string, reader io.Reader) (int64, error) { + data, err := io.ReadAll(reader) + if err != nil { + return 0, err + } + s.files[path] = data + return int64(len(data)), nil +} + +func (s *inMemStore) Open(_ context.Context, path string) (io.ReadCloser, error) { + data, ok := s.files[path] + if !ok { + return nil, &fsNotFoundError{path} + } + return io.NopCloser(bytes.NewReader(data)), nil +} + +func (s *inMemStore) Delete(_ context.Context, path string) error { + delete(s.files, path) + return nil +} + +type fsNotFoundError struct{ path string } + +func (e *fsNotFoundError) Error() string { return "file not found on disk: " + e.path } + +var _ storage.StorageBackend = (*inMemStore)(nil) + +func setupFileHandler(t *testing.T) *FileHandler { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.File{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + fileRepo := repository.NewFileRepository(db) + store := newInMemStore() + fileService := service.NewFileService(fileRepo, store, 0) + + return NewFileHandler(fileService) +} + +func setupFileRouter(t *testing.T) *gin.Engine { + t.Helper() + + handler := setupFileHandler(t) + + gin.SetMode(gin.TestMode) + r := gin.New() + + // Middleware that injects user_id from header (simulates AuthRequired). + r.Use(func(c *gin.Context) { + userID := c.GetHeader("X-User-ID") + if userID != "" { + c.Set("user_id", userID) + } + c.Next() + }) + + files := r.Group("/api/v1/files") + { + files.GET("", handler.List) + files.POST("", handler.Upload) + files.GET("/:id", handler.Get) + files.GET("/:id/content", handler.Download) + files.PUT("/:id", handler.Update) + files.DELETE("/:id", handler.Delete) + } + + return r +} + +func TestFileHandler_Upload(t *testing.T) { + r := setupFileRouter(t) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "test.txt") + part.Write([]byte("hello upload")) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var info service.FileInfo + if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if info.Name != "test.txt" { + t.Errorf("Name = %q, want %q", info.Name, "test.txt") + } +} + +func TestFileHandler_UploadNoFile(t *testing.T) { + r := setupFileRouter(t) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestFileHandler_CreateDir(t *testing.T) { + r := setupFileRouter(t) + + body, _ := json.Marshal(gin.H{"name": "mydir"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var info service.FileInfo + if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !info.IsDir { + t.Error("IsDir should be true") + } +} + +func TestFileHandler_List(t *testing.T) { + r := setupFileRouter(t) + + // Upload a file first. + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "file1.txt") + part.Write([]byte("content1")) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + // List files. + req = httptest.NewRequest(http.MethodGet, "/api/v1/files", nil) + req.Header.Set("X-User-ID", "user1") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var list service.FileList + if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if list.Total != 1 { + t.Errorf("Total = %d, want 1", list.Total) + } +} + +func TestFileHandler_Get(t *testing.T) { + r := setupFileRouter(t) + + // Upload a file first. + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "info.txt") + part.Write([]byte("file info content")) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + var uploaded service.FileInfo + json.Unmarshal(rec.Body.Bytes(), &uploaded) + + // Get metadata. + req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil) + req.Header.Set("X-User-ID", "user1") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var info service.FileInfo + if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if info.ID != uploaded.ID { + t.Errorf("ID = %q, want %q", info.ID, uploaded.ID) + } +} + +func TestFileHandler_GetNotFound(t *testing.T) { + r := setupFileRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/files/nonexistent", nil) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestFileHandler_Download(t *testing.T) { + r := setupFileRouter(t) + + // Upload a file. + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "download.txt") + content := []byte("download me please") + part.Write(content) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + var uploaded service.FileInfo + json.Unmarshal(rec.Body.Bytes(), &uploaded) + + // Download. + req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID+"/content", nil) + req.Header.Set("X-User-ID", "user1") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + if !bytes.Equal(rec.Body.Bytes(), content) { + t.Errorf("content = %q, want %q", rec.Body.String(), content) + } + if rec.Header().Get("Content-Disposition") == "" { + t.Error("Content-Disposition header should be set") + } +} + +func TestFileHandler_Update(t *testing.T) { + r := setupFileRouter(t) + + // Upload a file. + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "oldname.txt") + part.Write([]byte("content")) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + var uploaded service.FileInfo + json.Unmarshal(rec.Body.Bytes(), &uploaded) + + // Rename. + updateBody, _ := json.Marshal(gin.H{"name": "newname.txt"}) + req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", "user1") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var updated service.FileInfo + if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if updated.Name != "newname.txt" { + t.Errorf("Name = %q, want %q", updated.Name, "newname.txt") + } +} + +func TestFileHandler_UpdateNoFields(t *testing.T) { + r := setupFileRouter(t) + + // Upload a file. + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "file.txt") + part.Write([]byte("content")) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + var uploaded service.FileInfo + json.Unmarshal(rec.Body.Bytes(), &uploaded) + + updateBody, _ := json.Marshal(gin.H{}) + req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", "user1") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestFileHandler_Delete(t *testing.T) { + r := setupFileRouter(t) + + // Upload a file. + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "delete-me.txt") + part.Write([]byte("content")) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + var uploaded service.FileInfo + json.Unmarshal(rec.Body.Bytes(), &uploaded) + + // Delete. + req = httptest.NewRequest(http.MethodDelete, "/api/v1/files/"+uploaded.ID, nil) + req.Header.Set("X-User-ID", "user1") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String()) + } + + // Verify gone. + req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil) + req.Header.Set("X-User-ID", "user1") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("status after delete = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestFileHandler_ForbiddenAccess(t *testing.T) { + r := setupFileRouter(t) + + // Upload as user1. + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "private.txt") + part.Write([]byte("secret")) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + var uploaded service.FileInfo + json.Unmarshal(rec.Body.Bytes(), &uploaded) + + // Try to access as user2. + req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil) + req.Header.Set("X-User-ID", "user2") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} diff --git a/internal/model/file.go b/internal/model/file.go index 2f71e7b..a357f45 100644 --- a/internal/model/file.go +++ b/internal/model/file.go @@ -7,12 +7,13 @@ import ( // File represents a file or directory entry in the virtual filesystem. type File struct { ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` - UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"` - ParentID *string `gorm:"index;type:varchar(36)" json:"parent_id"` - Name string `gorm:"type:varchar(255);not null" json:"name"` + UserID string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:1;type:varchar(36);not null" json:"user_id"` + ParentID *string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:2;type:varchar(36)" json:"parent_id"` + Name string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_parent_name,priority:3" json:"name"` Size int64 `gorm:"default:0" json:"size"` MimeType string `gorm:"type:varchar(127)" json:"mime_type"` StoragePath string `gorm:"type:varchar(512)" json:"storage_path"` + Hash string `gorm:"type:varchar(64)" json:"-"` IsDir bool `gorm:"default:false" json:"is_dir"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/repository/file.go b/internal/repository/file.go index efbe12c..584bcb9 100644 --- a/internal/repository/file.go +++ b/internal/repository/file.go @@ -15,6 +15,8 @@ type FileRepository interface { FindByID(ctx context.Context, id string) (*model.File, error) FindByUserID(ctx context.Context, userID string, offset, limit int) ([]model.File, int64, error) FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error) + FindByParentIDPaginated(ctx context.Context, userID string, parentID *string, offset, limit int) ([]model.File, int64, error) + FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error) Update(ctx context.Context, file *model.File) error Delete(ctx context.Context, id string) error } @@ -76,6 +78,34 @@ func (r *fileRepository) FindByParentID(ctx context.Context, userID string, pare return files, nil } +func (r *fileRepository) FindByParentIDPaginated(ctx context.Context, userID string, parentID *string, offset, limit int) ([]model.File, int64, error) { + var files []model.File + var total int64 + + if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND parent_id IS ?", userID, parentID).Count(&total).Error; err != nil { + return nil, 0, err + } + + result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ?", userID, parentID).Offset(offset).Limit(limit).Order("is_dir DESC, name ASC").Find(&files) + if result.Error != nil { + return nil, 0, result.Error + } + + return files, total, nil +} + +func (r *fileRepository) FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error) { + var file model.File + result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND name = ?", userID, parentID, name).First(&file) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, model.ErrNotFound + } + if result.Error != nil { + return nil, result.Error + } + return &file, nil +} + func (r *fileRepository) Update(ctx context.Context, file *model.File) error { result := r.db.WithContext(ctx).Save(file) if result.Error != nil { diff --git a/internal/server/routes_protected.go b/internal/server/routes_protected.go index 7b8986f..6e53b9e 100644 --- a/internal/server/routes_protected.go +++ b/internal/server/routes_protected.go @@ -11,6 +11,7 @@ import ( func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) { jwtSecret := []byte(webApp.Config.JWT.Secret) accountHandler := handler.NewAccountHandler(webApp.AuthService) + fileHandler := handler.NewFileHandler(webApp.FileService) rg.Use(middleware.AuthRequired(jwtSecret)) @@ -25,4 +26,14 @@ func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) { passkeys.DELETE("/:id", accountHandler.RevokePasskey) } } + + files := rg.Group("/files") + { + files.GET("", fileHandler.List) + files.POST("", fileHandler.Upload) + files.GET("/:id", fileHandler.Get) + files.GET("/:id/content", fileHandler.Download) + files.PUT("/:id", fileHandler.Update) + files.DELETE("/:id", fileHandler.Delete) + } } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 72cadb5..99ccc11 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -21,7 +21,7 @@ func TestVersionRoute(t *testing.T) { }, } authService := service.NewAuthService(nil, nil, nil, nil, 15*time.Minute, 7*24*time.Hour) - webApp := app.NewWebApp(cfg, nil, nil, nil, nil, nil, authService) + webApp := app.NewWebApp(cfg, nil, nil, nil, nil, nil, authService, nil, nil) router := NewRouter(webApp) req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil) diff --git a/internal/service/file.go b/internal/service/file.go new file mode 100644 index 0000000..1a7a86e --- /dev/null +++ b/internal/service/file.go @@ -0,0 +1,376 @@ +package service + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/gabriel-vasile/mimetype" + "github.com/google/uuid" + + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/repository" + "github.com/dhao2001/mygo/internal/storage" +) + +// FileInfo is the public representation of a file or directory. +type FileInfo struct { + ID string `json:"id"` + UserID string `json:"user_id"` + ParentID *string `json:"parent_id"` + Name string `json:"name"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + IsDir bool `json:"is_dir"` + Hash string `json:"hash,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// FileList is a paginated list of files. +type FileList struct { + Files []FileInfo `json:"files"` + Total int64 `json:"total"` +} + +// FileService handles file management business logic. +type FileService struct { + fileRepo repository.FileRepository + storage storage.StorageBackend + maxUploadSize int64 +} + +// NewFileService creates a FileService. +func NewFileService( + fileRepo repository.FileRepository, + storage storage.StorageBackend, + maxUploadSize int64, +) *FileService { + return &FileService{ + fileRepo: fileRepo, + storage: storage, + maxUploadSize: maxUploadSize, + } +} + +// Upload stores a file's content and creates its metadata record. +func (s *FileService) Upload(ctx context.Context, userID string, parentID *string, fileName string, reader io.Reader, clientMime string) (*FileInfo, error) { + if err := validateFileName(fileName); err != nil { + return nil, err + } + + if parentID != nil { + if err := s.verifyParent(ctx, userID, *parentID); err != nil { + return nil, err + } + } + + // Check for name conflicts in the target directory. + if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, fileName); err == nil { + return nil, fmt.Errorf("a file with this name already exists in this directory") + } else if !errors.Is(err, model.ErrNotFound) { + return nil, fmt.Errorf("check name conflict: %w", err) + } + + // Limit the reader if a max upload size is configured. + if s.maxUploadSize > 0 { + reader = io.LimitReader(reader, s.maxUploadSize+1) + } + + // Detect MIME type when the client does not provide a meaningful one. + mimeType := clientMime + if mimeType == "" || mimeType == "application/octet-stream" { + // Read the first 512 bytes for detection, then replay them. + head := make([]byte, 512) + n, readErr := io.ReadFull(reader, head) + if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF { + return nil, fmt.Errorf("read for mime detection: %w", readErr) + } + head = head[:n] + mimeType = mimetype.Detect(head).String() + reader = io.MultiReader(bytes.NewReader(head), reader) + } + + fileID := uuid.NewString() + storagePath := fmt.Sprintf("%s/%s", userID, fileID) + + // Compute SHA-256 hash while writing to storage. + hasher := sha256.New() + teeReader := io.TeeReader(reader, hasher) + + written, err := s.storage.Save(ctx, storagePath, teeReader) + if err != nil { + return nil, fmt.Errorf("save file: %w", err) + } + + if s.maxUploadSize > 0 && written > s.maxUploadSize { + // Clean up the oversized file. + _ = s.storage.Delete(ctx, storagePath) + return nil, fmt.Errorf("file size exceeds the maximum allowed size of %d bytes", s.maxUploadSize) + } + + file := &model.File{ + ID: fileID, + UserID: userID, + ParentID: parentID, + Name: fileName, + Size: written, + MimeType: mimeType, + StoragePath: storagePath, + Hash: hex.EncodeToString(hasher.Sum(nil)), + IsDir: false, + } + + if err := s.fileRepo.Create(ctx, file); err != nil { + // Compensate: remove the stored file. + _ = s.storage.Delete(ctx, storagePath) + if errors.Is(err, model.ErrDuplicate) { + return nil, fmt.Errorf("a file with this name already exists in this directory") + } + return nil, fmt.Errorf("create file record: %w", err) + } + + return modelToFileInfo(file), nil +} + +// Download returns a reader for the file's content and its metadata. +// The caller must close the returned ReadCloser. +func (s *FileService) Download(ctx context.Context, userID, fileID string) (io.ReadCloser, *FileInfo, error) { + file, err := s.getOwnedFile(ctx, userID, fileID) + if err != nil { + return nil, nil, err + } + + if file.IsDir { + return nil, nil, fmt.Errorf("cannot download a directory") + } + + reader, err := s.storage.Open(ctx, file.StoragePath) + if err != nil { + return nil, nil, fmt.Errorf("open file: %w", err) + } + + return reader, modelToFileInfo(file), nil +} + +// Get returns metadata for a single file or directory. +func (s *FileService) Get(ctx context.Context, userID, fileID string) (*FileInfo, error) { + file, err := s.getOwnedFile(ctx, userID, fileID) + if err != nil { + return nil, err + } + return modelToFileInfo(file), nil +} + +// List returns the contents of a directory with pagination. +func (s *FileService) List(ctx context.Context, userID string, parentID *string, offset, limit int) (*FileList, error) { + if parentID != nil { + if err := s.verifyParent(ctx, userID, *parentID); err != nil { + return nil, err + } + } + + files, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, parentID, offset, limit) + if err != nil { + return nil, fmt.Errorf("list files: %w", err) + } + + infos := make([]FileInfo, 0, len(files)) + for i := range files { + infos = append(infos, *modelToFileInfo(&files[i])) + } + + return &FileList{Files: infos, Total: total}, nil +} + +// Update renames or moves a file or directory. +func (s *FileService) Update(ctx context.Context, userID, fileID string, newName string, newParentID *string) (*FileInfo, error) { + file, err := s.getOwnedFile(ctx, userID, fileID) + if err != nil { + return nil, err + } + + // Validate new name if provided. + if newName != "" { + if err := validateFileName(newName); err != nil { + return nil, err + } + file.Name = newName + } + + // If parent is changing, verify the new parent. + if newParentID != nil { + if *newParentID == fileID { + return nil, fmt.Errorf("cannot move a directory into itself") + } + if err := s.verifyParent(ctx, userID, *newParentID); err != nil { + return nil, err + } + file.ParentID = newParentID + } + + // Check for name conflicts in the target directory. + if newName != "" || newParentID != nil { + conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name) + if err == nil && conflict.ID != fileID { + return nil, fmt.Errorf("a file with this name already exists in the target directory") + } else if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, fmt.Errorf("check name conflict: %w", err) + } + } + + if err := s.fileRepo.Update(ctx, file); err != nil { + if errors.Is(err, model.ErrDuplicate) { + return nil, fmt.Errorf("a file with this name already exists in the target directory") + } + return nil, fmt.Errorf("update file: %w", err) + } + + return modelToFileInfo(file), nil +} + +// Delete removes a file or (empty) directory. +func (s *FileService) Delete(ctx context.Context, userID, fileID string) error { + file, err := s.getOwnedFile(ctx, userID, fileID) + if err != nil { + return err + } + + // Directories must be empty before deletion. + if file.IsDir { + _, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, &fileID, 0, 1) + if err != nil { + return fmt.Errorf("check directory contents: %w", err) + } + if total > 0 { + return fmt.Errorf("directory is not empty") + } + } + + if err := s.fileRepo.Delete(ctx, fileID); err != nil { + return fmt.Errorf("delete file record: %w", err) + } + + // Remove content from storage (directories have no stored content). + if !file.IsDir { + if err := s.storage.Delete(ctx, file.StoragePath); err != nil { + // Log but don't fail β€” the DB record is already gone. + // A periodic cleanup job can handle orphaned files later. + _ = err + } + } + + return nil +} + +// CreateDir creates a new directory. +func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *string, dirName string) (*FileInfo, error) { + if err := validateFileName(dirName); err != nil { + return nil, err + } + + if parentID != nil { + if err := s.verifyParent(ctx, userID, *parentID); err != nil { + return nil, err + } + } + + // Check for name conflicts in the target directory. + if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, dirName); err == nil { + return nil, fmt.Errorf("a file with this name already exists in this directory") + } else if !errors.Is(err, model.ErrNotFound) { + return nil, fmt.Errorf("check name conflict: %w", err) + } + + dir := &model.File{ + ID: uuid.NewString(), + UserID: userID, + ParentID: parentID, + Name: dirName, + IsDir: true, + } + + if err := s.fileRepo.Create(ctx, dir); err != nil { + if errors.Is(err, model.ErrDuplicate) { + return nil, fmt.Errorf("a file with this name already exists in this directory") + } + return nil, fmt.Errorf("create directory record: %w", err) + } + + return modelToFileInfo(dir), nil +} + +// getOwnedFile fetches a file and verifies ownership. +func (s *FileService) getOwnedFile(ctx context.Context, userID, fileID string) (*model.File, error) { + file, err := s.fileRepo.FindByID(ctx, fileID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return nil, model.ErrNotFound + } + return nil, fmt.Errorf("find file: %w", err) + } + + if file.UserID != userID { + return nil, model.ErrForbidden + } + + return file, nil +} + +// verifyParent checks that a parent directory exists, belongs to the user, and is a directory. +func (s *FileService) verifyParent(ctx context.Context, userID, parentID string) error { + parent, err := s.fileRepo.FindByID(ctx, parentID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("parent directory not found") + } + return fmt.Errorf("find parent: %w", err) + } + + if parent.UserID != userID { + return model.ErrForbidden + } + + if !parent.IsDir { + return fmt.Errorf("parent is not a directory") + } + + return nil +} + +// validateFileName rejects names that are empty, contain path separators, +// null bytes, or are reserved names "." and "..". +func validateFileName(name string) error { + if name == "" { + return fmt.Errorf("file name must not be empty") + } + if strings.ContainsAny(name, "/\\\x00") { + return fmt.Errorf("file name contains invalid characters") + } + if name == "." || name == ".." { + return fmt.Errorf("file name is reserved: %s", name) + } + return nil +} + +// modelToFileInfo converts a model.File to a FileInfo DTO. +func modelToFileInfo(f *model.File) *FileInfo { + return &FileInfo{ + ID: f.ID, + UserID: f.UserID, + ParentID: f.ParentID, + Name: f.Name, + Size: f.Size, + MimeType: f.MimeType, + IsDir: f.IsDir, + Hash: f.Hash, + CreatedAt: f.CreatedAt, + UpdatedAt: f.UpdatedAt, + } +} diff --git a/internal/service/file_test.go b/internal/service/file_test.go new file mode 100644 index 0000000..8d0d2cd --- /dev/null +++ b/internal/service/file_test.go @@ -0,0 +1,458 @@ +package service + +import ( + "bytes" + "context" + "io" + "strings" + "sync" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/repository" + "github.com/dhao2001/mygo/internal/storage" +) + +// memStorage is an in-memory implementation of storage.StorageBackend for tests. +type memStorage struct { + mu sync.RWMutex + files map[string][]byte +} + +func newMemStorage() *memStorage { + return &memStorage{files: make(map[string][]byte)} +} + +func (s *memStorage) Save(_ context.Context, path string, reader io.Reader) (int64, error) { + data, err := io.ReadAll(reader) + if err != nil { + return 0, err + } + s.mu.Lock() + s.files[path] = data + s.mu.Unlock() + return int64(len(data)), nil +} + +func (s *memStorage) Open(_ context.Context, path string) (io.ReadCloser, error) { + s.mu.RLock() + data, ok := s.files[path] + s.mu.RUnlock() + if !ok { + return nil, io.ErrUnexpectedEOF + } + return io.NopCloser(bytes.NewReader(data)), nil +} + +func (s *memStorage) Delete(_ context.Context, path string) error { + s.mu.Lock() + delete(s.files, path) + s.mu.Unlock() + return nil +} + +var _ storage.StorageBackend = (*memStorage)(nil) + +func setupFileService(t *testing.T) *FileService { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.File{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + fileRepo := repository.NewFileRepository(db) + store := newMemStorage() + + return NewFileService(fileRepo, store, 0) // 0 = unlimited +} + +func TestFileService_Upload(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + info, err := svc.Upload(ctx, "user1", nil, "hello.txt", strings.NewReader("hello world"), "") + if err != nil { + t.Fatalf("Upload = %v", err) + } + if info.ID == "" { + t.Fatal("file ID is empty") + } + if info.Name != "hello.txt" { + t.Errorf("Name = %q, want %q", info.Name, "hello.txt") + } + if info.Size != 11 { + t.Errorf("Size = %d, want 11", info.Size) + } + if info.UserID != "user1" { + t.Errorf("UserID = %q, want %q", info.UserID, "user1") + } + if info.IsDir { + t.Error("IsDir should be false") + } + // SHA-256 of "hello world" = b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9 + expectedHash := "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + if info.Hash != expectedHash { + t.Errorf("Hash = %q, want %q", info.Hash, expectedHash) + } +} + +func TestFileService_UploadIntoDirectory(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + dir, err := svc.CreateDir(ctx, "user1", nil, "docs") + if err != nil { + t.Fatalf("CreateDir = %v", err) + } + + info, err := svc.Upload(ctx, "user1", &dir.ID, "readme.txt", strings.NewReader("README"), "") + if err != nil { + t.Fatalf("Upload = %v", err) + } + if info.ParentID == nil || *info.ParentID != dir.ID { + t.Error("file should be inside the docs directory") + } +} + +func TestFileService_UploadInvalidName(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + invalidNames := []string{"", "foo/bar", "foo\\bar", ".", "..", "foo\x00bar"} + for _, name := range invalidNames { + _, err := svc.Upload(ctx, "user1", nil, name, strings.NewReader("data"), "") + if err == nil { + t.Errorf("expected error for name %q, got nil", name) + } + } +} + +func TestFileService_UploadDuplicateName(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + _, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("first"), "") + if err != nil { + t.Fatalf("first Upload = %v", err) + } + + _, err = svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("second"), "") + if err == nil { + t.Fatal("expected duplicate name error, got nil") + } +} + +func TestFileService_UploadNonexistentParent(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + fakeParentID := "nonexistent-id" + _, err := svc.Upload(ctx, "user1", &fakeParentID, "file.txt", strings.NewReader("data"), "") + if err == nil { + t.Fatal("expected error for nonexistent parent, got nil") + } +} + +func TestFileService_UploadParentNotDirectory(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + file, err := svc.Upload(ctx, "user1", nil, "not-a-dir.txt", strings.NewReader("data"), "") + if err != nil { + t.Fatalf("Upload = %v", err) + } + + _, err = svc.Upload(ctx, "user1", &file.ID, "child.txt", strings.NewReader("data"), "") + if err == nil { + t.Fatal("expected error for non-directory parent, got nil") + } +} + +func TestFileService_Download(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + content := "download test content" + info, err := svc.Upload(ctx, "user1", nil, "download.txt", strings.NewReader(content), "") + if err != nil { + t.Fatalf("Upload = %v", err) + } + + reader, dlInfo, err := svc.Download(ctx, "user1", info.ID) + if err != nil { + t.Fatalf("Download = %v", err) + } + defer reader.Close() + + if dlInfo.Name != "download.txt" { + t.Errorf("Name = %q, want %q", dlInfo.Name, "download.txt") + } + + data, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("ReadAll = %v", err) + } + if string(data) != content { + t.Errorf("content = %q, want %q", string(data), content) + } +} + +func TestFileService_DownloadForbidden(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + info, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("data"), "") + if err != nil { + t.Fatalf("Upload = %v", err) + } + + _, _, err = svc.Download(ctx, "user2", info.ID) + if err != model.ErrForbidden { + t.Errorf("expected ErrForbidden, got %v", err) + } +} + +func TestFileService_DownloadDirectory(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + dir, err := svc.CreateDir(ctx, "user1", nil, "mydir") + if err != nil { + t.Fatalf("CreateDir = %v", err) + } + + _, _, err = svc.Download(ctx, "user1", dir.ID) + if err == nil { + t.Fatal("expected error downloading directory, got nil") + } +} + +func TestFileService_Get(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + created, err := svc.Upload(ctx, "user1", nil, "info.txt", strings.NewReader("data"), "") + if err != nil { + t.Fatalf("Upload = %v", err) + } + + info, err := svc.Get(ctx, "user1", created.ID) + if err != nil { + t.Fatalf("Get = %v", err) + } + if info.ID != created.ID { + t.Errorf("ID = %q, want %q", info.ID, created.ID) + } +} + +func TestFileService_GetNotFound(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + _, err := svc.Get(ctx, "user1", "nonexistent") + if err != model.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestFileService_GetForbidden(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + info, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("data"), "") + if err != nil { + t.Fatalf("Upload = %v", err) + } + + _, err = svc.Get(ctx, "user2", info.ID) + if err != model.ErrForbidden { + t.Errorf("expected ErrForbidden, got %v", err) + } +} + +func TestFileService_List(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + dir, err := svc.CreateDir(ctx, "user1", nil, "dir") + if err != nil { + t.Fatalf("CreateDir = %v", err) + } + _, err = svc.Upload(ctx, "user1", nil, "root.txt", strings.NewReader("root"), "") + if err != nil { + t.Fatalf("Upload root = %v", err) + } + _, err = svc.Upload(ctx, "user1", &dir.ID, "nested.txt", strings.NewReader("nested"), "") + if err != nil { + t.Fatalf("Upload nested = %v", err) + } + + // List root. + rootList, err := svc.List(ctx, "user1", nil, 0, 50) + if err != nil { + t.Fatalf("List root = %v", err) + } + if rootList.Total != 2 { + t.Errorf("root total = %d, want 2", rootList.Total) + } + + // List inside directory. + dirList, err := svc.List(ctx, "user1", &dir.ID, 0, 50) + if err != nil { + t.Fatalf("List dir = %v", err) + } + if dirList.Total != 1 { + t.Errorf("dir total = %d, want 1", dirList.Total) + } +} + +func TestFileService_Update(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + info, err := svc.Upload(ctx, "user1", nil, "oldname.txt", strings.NewReader("data"), "") + if err != nil { + t.Fatalf("Upload = %v", err) + } + + updated, err := svc.Update(ctx, "user1", info.ID, "newname.txt", nil) + if err != nil { + t.Fatalf("Update = %v", err) + } + if updated.Name != "newname.txt" { + t.Errorf("Name = %q, want %q", updated.Name, "newname.txt") + } +} + +func TestFileService_UpdateMove(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + dir1, err := svc.CreateDir(ctx, "user1", nil, "dir1") + if err != nil { + t.Fatalf("CreateDir 1 = %v", err) + } + dir2, err := svc.CreateDir(ctx, "user1", nil, "dir2") + if err != nil { + t.Fatalf("CreateDir 2 = %v", err) + } + + info, err := svc.Upload(ctx, "user1", &dir1.ID, "move.txt", strings.NewReader("data"), "") + if err != nil { + t.Fatalf("Upload = %v", err) + } + + updated, err := svc.Update(ctx, "user1", info.ID, "", &dir2.ID) + if err != nil { + t.Fatalf("Update = %v", err) + } + if updated.ParentID == nil || *updated.ParentID != dir2.ID { + t.Error("file should be moved to dir2") + } +} + +func TestFileService_Delete(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + info, err := svc.Upload(ctx, "user1", nil, "delete-me.txt", strings.NewReader("data"), "") + if err != nil { + t.Fatalf("Upload = %v", err) + } + + if err := svc.Delete(ctx, "user1", info.ID); err != nil { + t.Fatalf("Delete = %v", err) + } + + _, err = svc.Get(ctx, "user1", info.ID) + if err != model.ErrNotFound { + t.Errorf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestFileService_DeleteNonEmptyDirectory(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + dir, err := svc.CreateDir(ctx, "user1", nil, "dir") + if err != nil { + t.Fatalf("CreateDir = %v", err) + } + _, err = svc.Upload(ctx, "user1", &dir.ID, "child.txt", strings.NewReader("data"), "") + if err != nil { + t.Fatalf("Upload = %v", err) + } + + err = svc.Delete(ctx, "user1", dir.ID) + if err == nil { + t.Fatal("expected error deleting non-empty directory, got nil") + } +} + +func TestFileService_DeleteForbidden(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + info, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("data"), "") + if err != nil { + t.Fatalf("Upload = %v", err) + } + + err = svc.Delete(ctx, "user2", info.ID) + if err != model.ErrForbidden { + t.Errorf("expected ErrForbidden, got %v", err) + } +} + +func TestFileService_CreateDir(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + dir, err := svc.CreateDir(ctx, "user1", nil, "mydir") + if err != nil { + t.Fatalf("CreateDir = %v", err) + } + if !dir.IsDir { + t.Error("IsDir should be true") + } + if dir.Name != "mydir" { + t.Errorf("Name = %q, want %q", dir.Name, "mydir") + } +} + +func TestFileService_CreateDirDuplicateName(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + _, err := svc.CreateDir(ctx, "user1", nil, "dup") + if err != nil { + t.Fatalf("first CreateDir = %v", err) + } + + _, err = svc.CreateDir(ctx, "user1", nil, "dup") + if err == nil { + t.Fatal("expected duplicate name error, got nil") + } +} + +func TestFileService_VerifyParentForbidden(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + dir, err := svc.CreateDir(ctx, "user1", nil, "dir") + if err != nil { + t.Fatalf("CreateDir = %v", err) + } + + _, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"), "") + if err != model.ErrForbidden { + t.Errorf("expected ErrForbidden, got %v", err) + } +} diff --git a/internal/storage/local.go b/internal/storage/local.go new file mode 100644 index 0000000..a159f02 --- /dev/null +++ b/internal/storage/local.go @@ -0,0 +1,91 @@ +package storage + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" +) + +// LocalStorage implements StorageBackend using the local filesystem. +type LocalStorage struct { + basePath string +} + +// NewLocalStorage creates a LocalStorage rooted at basePath. The path is +// resolved to an absolute path and verified to exist. +func NewLocalStorage(basePath string) (*LocalStorage, error) { + abs, err := filepath.Abs(basePath) + if err != nil { + return nil, fmt.Errorf("resolve storage path: %w", err) + } + return &LocalStorage{basePath: abs}, nil +} + +// Save writes the contents of reader to path under the storage root. +func (s *LocalStorage) Save(_ context.Context, path string, reader io.Reader) (int64, error) { + fullPath := filepath.Join(s.basePath, path) + if !isSubPath(s.basePath, fullPath) { + return 0, fmt.Errorf("storage: path traversal attempt: %s", path) + } + + if err := os.MkdirAll(filepath.Dir(fullPath), 0750); err != nil { + return 0, fmt.Errorf("create parent directory: %w", err) + } + + file, err := os.Create(fullPath) + if err != nil { + return 0, fmt.Errorf("create file: %w", err) + } + defer file.Close() + + written, err := io.Copy(file, reader) + if err != nil { + // Best-effort cleanup on write failure. + os.Remove(fullPath) + return 0, fmt.Errorf("write file: %w", err) + } + + return written, nil +} + +// Open returns a reader for the file at path under the storage root. +func (s *LocalStorage) Open(_ context.Context, path string) (io.ReadCloser, error) { + fullPath := filepath.Join(s.basePath, path) + if !isSubPath(s.basePath, fullPath) { + return nil, fmt.Errorf("storage: path traversal attempt: %s", path) + } + + file, err := os.Open(fullPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("file not found on disk: %s", path) + } + return nil, fmt.Errorf("open file: %w", err) + } + return file, nil +} + +// Delete removes the file at path under the storage root. +func (s *LocalStorage) Delete(_ context.Context, path string) error { + fullPath := filepath.Join(s.basePath, path) + if !isSubPath(s.basePath, fullPath) { + return fmt.Errorf("storage: path traversal attempt: %s", path) + } + + err := os.Remove(fullPath) + if os.IsNotExist(err) { + return nil // idempotent: already gone + } + return err +} + +// isSubPath returns true if target is within base (no path traversal). +func isSubPath(base, target string) bool { + rel, err := filepath.Rel(base, target) + if err != nil { + return false + } + return rel != ".." && !filepath.IsAbs(rel) && rel[:2] != ".." +} diff --git a/internal/storage/local_test.go b/internal/storage/local_test.go new file mode 100644 index 0000000..6818ac4 --- /dev/null +++ b/internal/storage/local_test.go @@ -0,0 +1,130 @@ +package storage + +import ( + "context" + "io" + "os" + "strings" + "testing" +) + +func TestSaveAndOpen(t *testing.T) { + dir := t.TempDir() + store, err := NewLocalStorage(dir) + if err != nil { + t.Fatalf("NewLocalStorage: %v", err) + } + ctx := context.Background() + + content := "hello, mygo storage" + written, err := store.Save(ctx, "test/hello.txt", strings.NewReader(content)) + if err != nil { + t.Fatalf("Save: %v", err) + } + if written != int64(len(content)) { + t.Errorf("written = %d, want %d", written, len(content)) + } + + reader, err := store.Open(ctx, "test/hello.txt") + if err != nil { + t.Fatalf("Open: %v", err) + } + defer reader.Close() + + got, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if string(got) != content { + t.Errorf("content = %q, want %q", string(got), content) + } + + // Verify the file exists on disk under the base path. + expectedPath := dir + "/test/hello.txt" + if _, err := os.Stat(expectedPath); err != nil { + t.Errorf("file not found on disk at %s: %v", expectedPath, err) + } +} + +func TestDelete(t *testing.T) { + dir := t.TempDir() + store, err := NewLocalStorage(dir) + if err != nil { + t.Fatalf("NewLocalStorage: %v", err) + } + ctx := context.Background() + + _, err = store.Save(ctx, "to_delete.txt", strings.NewReader("delete me")) + if err != nil { + t.Fatalf("Save: %v", err) + } + + if err := store.Delete(ctx, "to_delete.txt"); err != nil { + t.Fatalf("Delete: %v", err) + } + + _, err = store.Open(ctx, "to_delete.txt") + if err == nil { + t.Error("Open should fail after delete") + } +} + +func TestDeleteIdempotent(t *testing.T) { + dir := t.TempDir() + store, err := NewLocalStorage(dir) + if err != nil { + t.Fatalf("NewLocalStorage: %v", err) + } + ctx := context.Background() + + // Deleting a non-existent file should not error. + if err := store.Delete(ctx, "nonexistent.txt"); err != nil { + t.Errorf("Delete nonexistent should be idempotent, got: %v", err) + } +} + +func TestPathTraversalRejected(t *testing.T) { + dir := t.TempDir() + store, err := NewLocalStorage(dir) + if err != nil { + t.Fatalf("NewLocalStorage: %v", err) + } + ctx := context.Background() + + _, err = store.Save(ctx, "../escape.txt", strings.NewReader("malicious")) + if err == nil { + t.Error("Save should reject path traversal") + } else if !strings.Contains(err.Error(), "path traversal") { + t.Errorf("expected path traversal error, got: %v", err) + } + + _, err = store.Open(ctx, "../escape.txt") + if err == nil { + t.Error("Open should reject path traversal") + } else if !strings.Contains(err.Error(), "path traversal") { + t.Errorf("expected path traversal error, got: %v", err) + } + + err = store.Delete(ctx, "../escape.txt") + if err == nil { + t.Error("Delete should reject path traversal") + } else if !strings.Contains(err.Error(), "path traversal") { + t.Errorf("expected path traversal error, got: %v", err) + } +} + +func TestOpenMissingFile(t *testing.T) { + dir := t.TempDir() + store, err := NewLocalStorage(dir) + if err != nil { + t.Fatalf("NewLocalStorage: %v", err) + } + ctx := context.Background() + + _, err = store.Open(ctx, "no/such/file.txt") + if err == nil { + t.Error("Open should error for missing file") + } else if !strings.Contains(err.Error(), "not found on disk") { + t.Errorf("expected 'not found on disk' error, got: %v", err) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..7350cc6 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,24 @@ +// Package storage provides an abstraction layer for file content persistence. +package storage + +import ( + "context" + "io" +) + +// StorageBackend abstracts where file content is written, read, and deleted. +// Paths passed to all methods are relative to the backend's root and must +// not contain path traversal sequences. +type StorageBackend interface { + // Save writes the contents of reader to path, creating parent directories + // as needed. It returns the number of bytes written. + Save(ctx context.Context, path string, reader io.Reader) (int64, error) + + // Open returns a reader for the file at path. The caller must close the + // returned ReadCloser. + Open(ctx context.Context, path string) (io.ReadCloser, error) + + // Delete removes the file at path. It is idempotent β€” deleting a + // non-existent file is not an error. + Delete(ctx context.Context, path string) error +} From a289130dcdee77d2ac3266413d2d6d0d494178c1 Mon Sep 17 00:00:00 2001 From: Huxley Date: Wed, 24 Jun 2026 14:28:32 +0800 Subject: [PATCH 04/31] Fix isSubPath to allow filenames starting with ".." --- internal/storage/local.go | 8 +++++++- internal/storage/local_test.go | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/storage/local.go b/internal/storage/local.go index a159f02..61f0cf5 100644 --- a/internal/storage/local.go +++ b/internal/storage/local.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "strings" ) // LocalStorage implements StorageBackend using the local filesystem. @@ -87,5 +88,10 @@ func isSubPath(base, target string) bool { if err != nil { return false } - return rel != ".." && !filepath.IsAbs(rel) && rel[:2] != ".." + if filepath.IsAbs(rel) { + return false + } + // Reject paths that escape the base directory. + // filepath.Rel returns ".." or starts with "../" for paths outside base. + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } diff --git a/internal/storage/local_test.go b/internal/storage/local_test.go index 6818ac4..a815847 100644 --- a/internal/storage/local_test.go +++ b/internal/storage/local_test.go @@ -128,3 +128,21 @@ func TestOpenMissingFile(t *testing.T) { t.Errorf("expected 'not found on disk' error, got: %v", err) } } + +func TestIsSubPath_SameDirectory(t *testing.T) { + if !isSubPath("/base", "/base") { + t.Error("isSubPath should return true when base == target") + } +} + +func TestIsSubPath_ShortChildName(t *testing.T) { + if !isSubPath("/base", "/base/a") { + t.Error("isSubPath should return true for single-char child path") + } +} + +func TestIsSubPath_DotDotPrefixFilename(t *testing.T) { + if !isSubPath("/base", "/base/..foo") { + t.Error("isSubPath should return true for filename starting with ..") + } +} From be4fcad60537177cb10cf3a529c4fa1c9af56183 Mon Sep 17 00:00:00 2001 From: Huxley Date: Wed, 24 Jun 2026 14:43:38 +0800 Subject: [PATCH 05/31] Refactor handlers to use MustGetUserID - Fix: replace repeated authorization checks in handlers with the new MustGetUserID helper, which panics if the user ID is missing. This simplifies handler code by eliminating redundant error handling. - Tests: update auth tests to verify the panic behavior in unprotected routes. --- internal/handler/account.go | 25 +++---------------- internal/handler/file.go | 12 ++++----- internal/middleware/auth.go | 12 +++++++++ internal/middleware/auth_test.go | 43 +++++++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 28 deletions(-) diff --git a/internal/handler/account.go b/internal/handler/account.go index 67b72f9..92ae18a 100644 --- a/internal/handler/account.go +++ b/internal/handler/account.go @@ -27,22 +27,13 @@ type createPasskeyRequest struct { // GetAccount handles GET /api/v1/account. func (h *AccountHandler) GetAccount(c *gin.Context) { - userID := middleware.GetUserID(c) - if userID == "" { - api.Error(c, http.StatusUnauthorized, "unauthorized") - return - } - + userID := middleware.MustGetUserID(c) c.JSON(http.StatusOK, gin.H{"user_id": userID}) } // ListPasskeys handles GET /api/v1/account/passkeys. func (h *AccountHandler) ListPasskeys(c *gin.Context) { - userID := middleware.GetUserID(c) - if userID == "" { - api.Error(c, http.StatusUnauthorized, "unauthorized") - return - } + userID := middleware.MustGetUserID(c) creds, err := h.authService.ListPasskeys(c.Request.Context(), userID) if err != nil { @@ -59,11 +50,7 @@ func (h *AccountHandler) ListPasskeys(c *gin.Context) { // CreatePasskey handles POST /api/v1/account/passkeys. func (h *AccountHandler) CreatePasskey(c *gin.Context) { - userID := middleware.GetUserID(c) - if userID == "" { - api.Error(c, http.StatusUnauthorized, "unauthorized") - return - } + userID := middleware.MustGetUserID(c) var req createPasskeyRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -82,11 +69,7 @@ func (h *AccountHandler) CreatePasskey(c *gin.Context) { // RevokePasskey handles DELETE /api/v1/account/passkeys/:id. func (h *AccountHandler) RevokePasskey(c *gin.Context) { - userID := middleware.GetUserID(c) - if userID == "" { - api.Error(c, http.StatusUnauthorized, "unauthorized") - return - } + userID := middleware.MustGetUserID(c) passkeyID := c.Param("id") if passkeyID == "" { diff --git a/internal/handler/file.go b/internal/handler/file.go index b858d12..ce420ef 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -40,7 +40,7 @@ type updateFileRequest struct { // If the content type is multipart/form-data, it uploads a file. // If the content type is application/json, it creates a directory. func (h *FileHandler) Upload(c *gin.Context) { - userID := middleware.GetUserID(c) + userID := middleware.MustGetUserID(c) contentType := c.GetHeader("Content-Type") // Directory creation via JSON. @@ -97,7 +97,7 @@ func (h *FileHandler) Upload(c *gin.Context) { // List handles GET /api/v1/files. func (h *FileHandler) List(c *gin.Context) { - userID := middleware.GetUserID(c) + userID := middleware.MustGetUserID(c) parentIDStr := c.Query("parent_id") var parentID *string @@ -131,7 +131,7 @@ func (h *FileHandler) List(c *gin.Context) { // Get handles GET /api/v1/files/:id. func (h *FileHandler) Get(c *gin.Context) { - userID := middleware.GetUserID(c) + userID := middleware.MustGetUserID(c) fileID := c.Param("id") info, err := h.fileService.Get(c.Request.Context(), userID, fileID) @@ -145,7 +145,7 @@ func (h *FileHandler) Get(c *gin.Context) { // Download handles GET /api/v1/files/:id/content. func (h *FileHandler) Download(c *gin.Context) { - userID := middleware.GetUserID(c) + userID := middleware.MustGetUserID(c) fileID := c.Param("id") reader, info, err := h.fileService.Download(c.Request.Context(), userID, fileID) @@ -170,7 +170,7 @@ func (h *FileHandler) Download(c *gin.Context) { // Update handles PUT /api/v1/files/:id. func (h *FileHandler) Update(c *gin.Context) { - userID := middleware.GetUserID(c) + userID := middleware.MustGetUserID(c) fileID := c.Param("id") var req updateFileRequest @@ -196,7 +196,7 @@ func (h *FileHandler) Update(c *gin.Context) { // Delete handles DELETE /api/v1/files/:id. func (h *FileHandler) Delete(c *gin.Context) { - userID := middleware.GetUserID(c) + userID := middleware.MustGetUserID(c) fileID := c.Param("id") if err := h.fileService.Delete(c.Request.Context(), userID, fileID); err != nil { diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index a66deb9..bd7ab05 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -49,6 +49,7 @@ func AuthRequired(jwtSecret []byte) gin.HandlerFunc { } // GetUserID extracts the user ID injected by AuthRequired. +// Returns an empty string if the key is not present (e.g., optional auth contexts). func GetUserID(c *gin.Context) string { v, _ := c.Get(userIDKey) if v == nil { @@ -56,3 +57,14 @@ func GetUserID(c *gin.Context) string { } return v.(string) } + +// MustGetUserID extracts the user ID injected by AuthRequired. +// It panics if the key is missing β€” this indicates a programming error +// (a handler registered without the AuthRequired middleware). +func MustGetUserID(c *gin.Context) string { + userID := GetUserID(c) + if userID == "" { + panic("user_id not found in context β€” is AuthRequired middleware applied?") + } + return userID +} diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go index 9d3bc14..08da150 100644 --- a/internal/middleware/auth_test.go +++ b/internal/middleware/auth_test.go @@ -17,7 +17,7 @@ func setupTestRouter(secret []byte) *gin.Engine { r := gin.New() r.Use(AuthRequired(secret)) r.GET("/protected", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"user_id": GetUserID(c)}) + c.JSON(http.StatusOK, gin.H{"user_id": MustGetUserID(c)}) }) return r } @@ -137,6 +137,47 @@ func TestGetUserID(t *testing.T) { } } +func TestMustGetUserID(t *testing.T) { + secret := []byte("test-secret") + token, err := auth.GenerateAccessToken("bob-99", secret, 15*time.Minute) + if err != nil { + t.Fatalf("GenerateAccessToken = %v", err) + } + + r := setupTestRouter(secret) + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + + body := rec.Body.String() + if !strings.Contains(body, "bob-99") { + t.Errorf("response body %q does not contain user id", body) + } +} + +func TestMustGetUserIDPanics(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/naked", func(c *gin.Context) { + MustGetUserID(c) // should panic β€” no AuthRequired middleware applied + }) + + req := httptest.NewRequest(http.MethodGet, "/naked", nil) + rec := httptest.NewRecorder() + + defer func() { + if r := recover(); r == nil { + t.Error("expected panic, got none") + } + }() + r.ServeHTTP(rec, req) +} + func TestAuthRequiredWrongSecret(t *testing.T) { secret := []byte("test-secret") token, err := auth.GenerateAccessToken("user-1", secret, 15*time.Minute) From a78d43b16684865667d32ccc6bdcae6d7f06a324 Mon Sep 17 00:00:00 2001 From: Huxley Date: Wed, 24 Jun 2026 20:27:22 +0800 Subject: [PATCH 06/31] Remove client MIME type parameter from Upload - Fix: Always detect MIME type server-side from file content instead of trusting or forwarding the client-supplied value. --- internal/handler/file.go | 6 ++++-- internal/service/file.go | 23 +++++++++----------- internal/service/file_test.go | 40 +++++++++++++++++------------------ 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/internal/handler/file.go b/internal/handler/file.go index ce420ef..56ac17a 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "net/url" "strconv" @@ -44,7 +45,8 @@ func (h *FileHandler) Upload(c *gin.Context) { contentType := c.GetHeader("Content-Type") // Directory creation via JSON. - if len(contentType) >= 16 && contentType[:16] == "application/json" { + mediaType, _, _ := mime.ParseMediaType(contentType) + if mediaType == "application/json" { var req createDirRequest if err := c.ShouldBindJSON(&req); err != nil { api.Error(c, http.StatusBadRequest, "invalid request: "+err.Error()) @@ -86,7 +88,7 @@ func (h *FileHandler) Upload(c *gin.Context) { } defer file.Close() - info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, header.Filename, file, header.Header.Get("Content-Type")) + info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, header.Filename, file) if err != nil { api.Error(c, mapError(err), err.Error()) return diff --git a/internal/service/file.go b/internal/service/file.go index 1a7a86e..99f2f87 100644 --- a/internal/service/file.go +++ b/internal/service/file.go @@ -60,7 +60,8 @@ func NewFileService( } // Upload stores a file's content and creates its metadata record. -func (s *FileService) Upload(ctx context.Context, userID string, parentID *string, fileName string, reader io.Reader, clientMime string) (*FileInfo, error) { +// The MIME type is always detected server-side from the file content. +func (s *FileService) Upload(ctx context.Context, userID string, parentID *string, fileName string, reader io.Reader) (*FileInfo, error) { if err := validateFileName(fileName); err != nil { return nil, err } @@ -83,19 +84,15 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin reader = io.LimitReader(reader, s.maxUploadSize+1) } - // Detect MIME type when the client does not provide a meaningful one. - mimeType := clientMime - if mimeType == "" || mimeType == "application/octet-stream" { - // Read the first 512 bytes for detection, then replay them. - head := make([]byte, 512) - n, readErr := io.ReadFull(reader, head) - if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF { - return nil, fmt.Errorf("read for mime detection: %w", readErr) - } - head = head[:n] - mimeType = mimetype.Detect(head).String() - reader = io.MultiReader(bytes.NewReader(head), reader) + // Detect MIME type from file content. + head := make([]byte, 512) + n, readErr := io.ReadFull(reader, head) + if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF { + return nil, fmt.Errorf("read for mime detection: %w", readErr) } + head = head[:n] + mimeType := mimetype.Detect(head).String() + reader = io.MultiReader(bytes.NewReader(head), reader) fileID := uuid.NewString() storagePath := fmt.Sprintf("%s/%s", userID, fileID) diff --git a/internal/service/file_test.go b/internal/service/file_test.go index 8d0d2cd..fcce35c 100644 --- a/internal/service/file_test.go +++ b/internal/service/file_test.go @@ -77,7 +77,7 @@ func TestFileService_Upload(t *testing.T) { svc := setupFileService(t) ctx := context.Background() - info, err := svc.Upload(ctx, "user1", nil, "hello.txt", strings.NewReader("hello world"), "") + info, err := svc.Upload(ctx, "user1", nil, "hello.txt", strings.NewReader("hello world")) if err != nil { t.Fatalf("Upload = %v", err) } @@ -112,7 +112,7 @@ func TestFileService_UploadIntoDirectory(t *testing.T) { t.Fatalf("CreateDir = %v", err) } - info, err := svc.Upload(ctx, "user1", &dir.ID, "readme.txt", strings.NewReader("README"), "") + info, err := svc.Upload(ctx, "user1", &dir.ID, "readme.txt", strings.NewReader("README")) if err != nil { t.Fatalf("Upload = %v", err) } @@ -127,7 +127,7 @@ func TestFileService_UploadInvalidName(t *testing.T) { invalidNames := []string{"", "foo/bar", "foo\\bar", ".", "..", "foo\x00bar"} for _, name := range invalidNames { - _, err := svc.Upload(ctx, "user1", nil, name, strings.NewReader("data"), "") + _, err := svc.Upload(ctx, "user1", nil, name, strings.NewReader("data")) if err == nil { t.Errorf("expected error for name %q, got nil", name) } @@ -138,12 +138,12 @@ func TestFileService_UploadDuplicateName(t *testing.T) { svc := setupFileService(t) ctx := context.Background() - _, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("first"), "") + _, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("first")) if err != nil { t.Fatalf("first Upload = %v", err) } - _, err = svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("second"), "") + _, err = svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("second")) if err == nil { t.Fatal("expected duplicate name error, got nil") } @@ -154,7 +154,7 @@ func TestFileService_UploadNonexistentParent(t *testing.T) { ctx := context.Background() fakeParentID := "nonexistent-id" - _, err := svc.Upload(ctx, "user1", &fakeParentID, "file.txt", strings.NewReader("data"), "") + _, err := svc.Upload(ctx, "user1", &fakeParentID, "file.txt", strings.NewReader("data")) if err == nil { t.Fatal("expected error for nonexistent parent, got nil") } @@ -164,12 +164,12 @@ func TestFileService_UploadParentNotDirectory(t *testing.T) { svc := setupFileService(t) ctx := context.Background() - file, err := svc.Upload(ctx, "user1", nil, "not-a-dir.txt", strings.NewReader("data"), "") + file, err := svc.Upload(ctx, "user1", nil, "not-a-dir.txt", strings.NewReader("data")) if err != nil { t.Fatalf("Upload = %v", err) } - _, err = svc.Upload(ctx, "user1", &file.ID, "child.txt", strings.NewReader("data"), "") + _, err = svc.Upload(ctx, "user1", &file.ID, "child.txt", strings.NewReader("data")) if err == nil { t.Fatal("expected error for non-directory parent, got nil") } @@ -180,7 +180,7 @@ func TestFileService_Download(t *testing.T) { ctx := context.Background() content := "download test content" - info, err := svc.Upload(ctx, "user1", nil, "download.txt", strings.NewReader(content), "") + info, err := svc.Upload(ctx, "user1", nil, "download.txt", strings.NewReader(content)) if err != nil { t.Fatalf("Upload = %v", err) } @@ -208,7 +208,7 @@ func TestFileService_DownloadForbidden(t *testing.T) { svc := setupFileService(t) ctx := context.Background() - info, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("data"), "") + info, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("data")) if err != nil { t.Fatalf("Upload = %v", err) } @@ -238,7 +238,7 @@ func TestFileService_Get(t *testing.T) { svc := setupFileService(t) ctx := context.Background() - created, err := svc.Upload(ctx, "user1", nil, "info.txt", strings.NewReader("data"), "") + created, err := svc.Upload(ctx, "user1", nil, "info.txt", strings.NewReader("data")) if err != nil { t.Fatalf("Upload = %v", err) } @@ -266,7 +266,7 @@ func TestFileService_GetForbidden(t *testing.T) { svc := setupFileService(t) ctx := context.Background() - info, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("data"), "") + info, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("data")) if err != nil { t.Fatalf("Upload = %v", err) } @@ -285,11 +285,11 @@ func TestFileService_List(t *testing.T) { if err != nil { t.Fatalf("CreateDir = %v", err) } - _, err = svc.Upload(ctx, "user1", nil, "root.txt", strings.NewReader("root"), "") + _, err = svc.Upload(ctx, "user1", nil, "root.txt", strings.NewReader("root")) if err != nil { t.Fatalf("Upload root = %v", err) } - _, err = svc.Upload(ctx, "user1", &dir.ID, "nested.txt", strings.NewReader("nested"), "") + _, err = svc.Upload(ctx, "user1", &dir.ID, "nested.txt", strings.NewReader("nested")) if err != nil { t.Fatalf("Upload nested = %v", err) } @@ -317,7 +317,7 @@ func TestFileService_Update(t *testing.T) { svc := setupFileService(t) ctx := context.Background() - info, err := svc.Upload(ctx, "user1", nil, "oldname.txt", strings.NewReader("data"), "") + info, err := svc.Upload(ctx, "user1", nil, "oldname.txt", strings.NewReader("data")) if err != nil { t.Fatalf("Upload = %v", err) } @@ -344,7 +344,7 @@ func TestFileService_UpdateMove(t *testing.T) { t.Fatalf("CreateDir 2 = %v", err) } - info, err := svc.Upload(ctx, "user1", &dir1.ID, "move.txt", strings.NewReader("data"), "") + info, err := svc.Upload(ctx, "user1", &dir1.ID, "move.txt", strings.NewReader("data")) if err != nil { t.Fatalf("Upload = %v", err) } @@ -362,7 +362,7 @@ func TestFileService_Delete(t *testing.T) { svc := setupFileService(t) ctx := context.Background() - info, err := svc.Upload(ctx, "user1", nil, "delete-me.txt", strings.NewReader("data"), "") + info, err := svc.Upload(ctx, "user1", nil, "delete-me.txt", strings.NewReader("data")) if err != nil { t.Fatalf("Upload = %v", err) } @@ -385,7 +385,7 @@ func TestFileService_DeleteNonEmptyDirectory(t *testing.T) { if err != nil { t.Fatalf("CreateDir = %v", err) } - _, err = svc.Upload(ctx, "user1", &dir.ID, "child.txt", strings.NewReader("data"), "") + _, err = svc.Upload(ctx, "user1", &dir.ID, "child.txt", strings.NewReader("data")) if err != nil { t.Fatalf("Upload = %v", err) } @@ -400,7 +400,7 @@ func TestFileService_DeleteForbidden(t *testing.T) { svc := setupFileService(t) ctx := context.Background() - info, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("data"), "") + info, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("data")) if err != nil { t.Fatalf("Upload = %v", err) } @@ -451,7 +451,7 @@ func TestFileService_VerifyParentForbidden(t *testing.T) { t.Fatalf("CreateDir = %v", err) } - _, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"), "") + _, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data")) if err != model.ErrForbidden { t.Errorf("expected ErrForbidden, got %v", err) } From 1dfccf513ab8ebd8c7e8acd088b16e5ecc426793 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 16:24:22 +0800 Subject: [PATCH 07/31] Add structured logging and centralized error handling - Initialize slog in the serve command with terminal/file support - Introduce `AppError` with HTTP status for unified service-layer errors - Replace ad-hoc `api.Error` calls with `api.RespondError` - Wrap internal errors with `model.NewInternalError` and add reference IDs - Add `RequestID` middleware and switch router from `gin.Default` to `gin.New` --- cmd/serve.go | 7 +++ config.example.yaml | 5 +++ internal/api/response.go | 48 ++++++++++++++++++++ internal/app/webapp.go | 3 +- internal/config/config.go | 38 ++++++++++++++++ internal/config/load.go | 4 ++ internal/handler/account.go | 14 +++--- internal/handler/auth.go | 21 +++++---- internal/handler/file.go | 44 +++++++----------- internal/handler/file_test.go | 2 +- internal/model/errors.go | 28 +++++++++++- internal/server/router.go | 9 +++- internal/service/auth.go | 57 ++++++++++++----------- internal/service/auth_test.go | 7 ++- internal/service/file.go | 85 +++++++++++++++++++---------------- internal/service/file_test.go | 43 ++++++++++-------- 16 files changed, 281 insertions(+), 134 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 682223d..ae94f44 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "log/slog" "os" "os/signal" "syscall" @@ -11,6 +12,7 @@ import ( "github.com/dhao2001/mygo/internal/app" "github.com/dhao2001/mygo/internal/config" + mygolog "github.com/dhao2001/mygo/internal/log" "github.com/dhao2001/mygo/internal/server" ) @@ -26,6 +28,11 @@ var serveCmd = &cobra.Command{ return fmt.Errorf("load config: %w", err) } + // Set up structured logging before anything else. + appLogger := mygolog.NewLogger(cfg.Log) + slog.SetDefault(appLogger) + slog.Info("mygo server starting") + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/config.example.yaml b/config.example.yaml index fce6528..47340f1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -22,6 +22,11 @@ storage: # Max upload file size in bytes. 0 = unlimited max_upload_size: 104857600 # 100 MB +log: + level: info # terminal: debug, info, warn, error + file_path: "" # empty = no file logging + file_level: debug # file: debug, info, warn, error + jwt: secret: change-me-in-production access_ttl: 15m diff --git a/internal/api/response.go b/internal/api/response.go index 478fcb7..f55022d 100644 --- a/internal/api/response.go +++ b/internal/api/response.go @@ -1,7 +1,16 @@ package api import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/http" + "github.com/gin-gonic/gin" + + "github.com/dhao2001/mygo/internal/model" ) // ErrorResponse is the standard JSON body for HTTP API errors. @@ -22,3 +31,42 @@ func Error(c *gin.Context, status int, message string) { }, }) } + +// RespondError unpacks an error from the service layer and writes a JSON +// error response. It expects *model.AppError; unexpected non-AppError +// values are treated as 500 with a safe message. For 500+ errors, a +// random reference hash is included in the response so operators can +// correlate it with server logs. +func RespondError(c *gin.Context, err error) { + var ae *model.AppError + if !errors.As(err, &ae) { + ref := randomHex(8) + slog.ErrorContext(c.Request.Context(), "non-AppError escaped to handler", + "ref", ref, "error", err, "type", fmt.Sprintf("%T", err)) + Error(c, http.StatusInternalServerError, "internal server error (ref: "+ref+")") + return + } + + // 500+ errors: log full detail with a reference hash, return sanitized message. + if ae.Status >= 500 { + ref := randomHex(8) + slog.ErrorContext(c.Request.Context(), "internal server error", + "ref", ref, slog.Any("error", ae.Err), "message", ae.Message) + Error(c, ae.Status, "internal server error (ref: "+ref+")") + return + } + + // 4xx errors with internal cause: log at WARN for diagnostics. + if ae.Err != nil { + slog.WarnContext(c.Request.Context(), ae.Message, + "status", ae.Status, slog.Any("error", ae.Err)) + } + + Error(c, ae.Status, ae.Message) +} + +func randomHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/app/webapp.go b/internal/app/webapp.go index 5edd262..ba554ff 100644 --- a/internal/app/webapp.go +++ b/internal/app/webapp.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "log/slog" "gorm.io/gorm" @@ -56,7 +57,7 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) { return nil, fmt.Errorf("init storage: %w", err) } - fileService := service.NewFileService(fileRepo, store, cfg.Storage.MaxUploadSize) + fileService := service.NewFileService(fileRepo, store, cfg.Storage.MaxUploadSize, slog.Default()) return &WebApp{ Config: cfg, diff --git a/internal/config/config.go b/internal/config/config.go index 24fdfe4..819ee67 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "log/slog" "net" "time" ) @@ -12,6 +13,7 @@ type Config struct { Database DatabaseConfig `mapstructure:"database"` Storage StorageConfig `mapstructure:"storage"` JWT JWTConfig `mapstructure:"jwt"` + Log LogConfig `mapstructure:"log"` } type ServerConfig struct { @@ -54,6 +56,30 @@ type JWTConfig struct { RefreshTTL time.Duration `mapstructure:"refresh_ttl"` } +type LogConfig struct { + Level string `mapstructure:"level"` + FilePath string `mapstructure:"file_path"` + FileLevel string `mapstructure:"file_level"` +} + +// ParseLogLevel converts a string to slog.Level. +func ParseLogLevel(s string) (slog.Level, error) { + if s == "" { + return slog.LevelInfo, nil + } + switch s { + case "debug": + return slog.LevelDebug, nil + case "info": + return slog.LevelInfo, nil + case "warn": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + default: + return slog.LevelInfo, fmt.Errorf("unknown log level: %q (use debug, info, warn, or error)", s) + } +} func (c *Config) Validate() error { var errs []error @@ -107,5 +133,17 @@ func (c *Config) Validate() error { errs = append(errs, errors.New("jwt.refresh_ttl: must be positive")) } + if c.Log.Level != "" { + if _, err := ParseLogLevel(c.Log.Level); err != nil { + errs = append(errs, fmt.Errorf("log.level: %w", err)) + } + } + + if c.Log.FileLevel != "" { + if _, err := ParseLogLevel(c.Log.FileLevel); err != nil { + errs = append(errs, fmt.Errorf("log.file_level: %w", err)) + } + } + return errors.Join(errs...) } diff --git a/internal/config/load.go b/internal/config/load.go index 0861549..801382f 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -27,6 +27,10 @@ func defaults(v *viper.Viper) { v.SetDefault("jwt.secret", "dev-secret-do-not-use-in-production") v.SetDefault("jwt.access_ttl", "15m") v.SetDefault("jwt.refresh_ttl", "168h") + + v.SetDefault("log.level", "info") + v.SetDefault("log.file_path", "") + v.SetDefault("log.file_level", "debug") } func New() *viper.Viper { diff --git a/internal/handler/account.go b/internal/handler/account.go index 92ae18a..fc9906a 100644 --- a/internal/handler/account.go +++ b/internal/handler/account.go @@ -1,6 +1,7 @@ package handler import ( + "log/slog" "net/http" "github.com/gin-gonic/gin" @@ -37,7 +38,7 @@ func (h *AccountHandler) ListPasskeys(c *gin.Context) { creds, err := h.authService.ListPasskeys(c.Request.Context(), userID) if err != nil { - api.Error(c, http.StatusInternalServerError, err.Error()) + api.RespondError(c, err) return } @@ -54,13 +55,14 @@ func (h *AccountHandler) CreatePasskey(c *gin.Context) { var req createPasskeyRequest if err := c.ShouldBindJSON(&req); err != nil { - api.Error(c, http.StatusBadRequest, "invalid request: "+err.Error()) + slog.DebugContext(c.Request.Context(), "create passkey bind failed", "error", err) + api.Error(c, http.StatusBadRequest, "invalid request body") return } pk, err := h.authService.CreatePasskey(c.Request.Context(), userID, req.Label) if err != nil { - api.Error(c, http.StatusInternalServerError, err.Error()) + api.RespondError(c, err) return } @@ -78,11 +80,7 @@ func (h *AccountHandler) RevokePasskey(c *gin.Context) { } if err := h.authService.RevokePasskey(c.Request.Context(), userID, passkeyID); err != nil { - if err == model.ErrForbidden { - api.Error(c, http.StatusForbidden, err.Error()) - return - } - api.Error(c, http.StatusInternalServerError, err.Error()) + api.RespondError(c, err) return } diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 091b4b9..fe225a3 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -1,6 +1,7 @@ package handler import ( + "log/slog" "net/http" "github.com/gin-gonic/gin" @@ -38,13 +39,14 @@ type tokenRequest struct { func (h *AuthHandler) Register(c *gin.Context) { var req registerRequest if err := c.ShouldBindJSON(&req); err != nil { - api.Error(c, http.StatusBadRequest, "invalid request: "+err.Error()) + slog.DebugContext(c.Request.Context(), "register bind failed", "error", err) + api.Error(c, http.StatusBadRequest, "invalid request body") return } user, err := h.authService.Register(c.Request.Context(), req.Username, req.Email, req.Password) if err != nil { - api.Error(c, http.StatusConflict, err.Error()) + api.RespondError(c, err) return } @@ -55,13 +57,14 @@ func (h *AuthHandler) Register(c *gin.Context) { func (h *AuthHandler) Login(c *gin.Context) { var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { - api.Error(c, http.StatusBadRequest, "invalid request: "+err.Error()) + slog.DebugContext(c.Request.Context(), "login bind failed", "error", err) + api.Error(c, http.StatusBadRequest, "invalid request body") return } pair, err := h.authService.Login(c.Request.Context(), req.Email, req.Password) if err != nil { - api.Error(c, http.StatusUnauthorized, err.Error()) + api.RespondError(c, err) return } @@ -72,13 +75,14 @@ func (h *AuthHandler) Login(c *gin.Context) { func (h *AuthHandler) Refresh(c *gin.Context) { var req tokenRequest if err := c.ShouldBindJSON(&req); err != nil { - api.Error(c, http.StatusBadRequest, "invalid request: "+err.Error()) + slog.DebugContext(c.Request.Context(), "refresh bind failed", "error", err) + api.Error(c, http.StatusBadRequest, "invalid request body") return } pair, err := h.authService.Refresh(c.Request.Context(), req.RefreshToken) if err != nil { - api.Error(c, http.StatusUnauthorized, err.Error()) + api.RespondError(c, err) return } @@ -89,12 +93,13 @@ func (h *AuthHandler) Refresh(c *gin.Context) { func (h *AuthHandler) Logout(c *gin.Context) { var req tokenRequest if err := c.ShouldBindJSON(&req); err != nil { - api.Error(c, http.StatusBadRequest, "invalid request: "+err.Error()) + slog.DebugContext(c.Request.Context(), "logout bind failed", "error", err) + api.Error(c, http.StatusBadRequest, "invalid request body") return } if err := h.authService.Logout(c.Request.Context(), req.RefreshToken); err != nil { - api.Error(c, http.StatusInternalServerError, err.Error()) + api.RespondError(c, err) return } diff --git a/internal/handler/file.go b/internal/handler/file.go index 56ac17a..99eb1b0 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -1,9 +1,9 @@ package handler import ( - "errors" "fmt" "io" + "log/slog" "mime" "net/http" "net/url" @@ -49,13 +49,14 @@ func (h *FileHandler) Upload(c *gin.Context) { if mediaType == "application/json" { var req createDirRequest if err := c.ShouldBindJSON(&req); err != nil { - api.Error(c, http.StatusBadRequest, "invalid request: "+err.Error()) + slog.DebugContext(c.Request.Context(), "create dir bind failed", "error", err) + api.Error(c, http.StatusBadRequest, "invalid request body") return } dir, err := h.fileService.CreateDir(c.Request.Context(), userID, req.ParentID, req.Name) if err != nil { - api.Error(c, mapError(err), err.Error()) + api.RespondError(c, err) return } @@ -65,7 +66,8 @@ func (h *FileHandler) Upload(c *gin.Context) { // File upload via multipart. if err := c.Request.ParseMultipartForm(32 << 20); err != nil { - api.Error(c, http.StatusBadRequest, "invalid multipart form: "+err.Error()) + slog.DebugContext(c.Request.Context(), "parse multipart form failed", "error", err) + api.Error(c, http.StatusBadRequest, "invalid multipart form") return } @@ -77,20 +79,21 @@ func (h *FileHandler) Upload(c *gin.Context) { header, err := c.FormFile("file") if err != nil { - api.Error(c, http.StatusBadRequest, "missing file field: "+err.Error()) + slog.DebugContext(c.Request.Context(), "form file missing", "error", err) + api.Error(c, http.StatusBadRequest, "missing file field") return } file, err := header.Open() if err != nil { - api.Error(c, http.StatusInternalServerError, "open uploaded file: "+err.Error()) + api.RespondError(c, model.NewInternalError("open uploaded file", err)) return } defer file.Close() info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, header.Filename, file) if err != nil { - api.Error(c, mapError(err), err.Error()) + api.RespondError(c, err) return } @@ -124,7 +127,7 @@ func (h *FileHandler) List(c *gin.Context) { list, err := h.fileService.List(c.Request.Context(), userID, parentID, offset, limit) if err != nil { - api.Error(c, mapError(err), err.Error()) + api.RespondError(c, err) return } @@ -138,7 +141,7 @@ func (h *FileHandler) Get(c *gin.Context) { info, err := h.fileService.Get(c.Request.Context(), userID, fileID) if err != nil { - api.Error(c, mapError(err), err.Error()) + api.RespondError(c, err) return } @@ -152,7 +155,7 @@ func (h *FileHandler) Download(c *gin.Context) { reader, info, err := h.fileService.Download(c.Request.Context(), userID, fileID) if err != nil { - api.Error(c, mapError(err), err.Error()) + api.RespondError(c, err) return } defer reader.Close() @@ -177,7 +180,8 @@ func (h *FileHandler) Update(c *gin.Context) { var req updateFileRequest if err := c.ShouldBindJSON(&req); err != nil { - api.Error(c, http.StatusBadRequest, "invalid request: "+err.Error()) + slog.DebugContext(c.Request.Context(), "update file bind failed", "error", err) + api.Error(c, http.StatusBadRequest, "invalid request body") return } @@ -189,7 +193,7 @@ func (h *FileHandler) Update(c *gin.Context) { info, err := h.fileService.Update(c.Request.Context(), userID, fileID, req.Name, req.ParentID) if err != nil { - api.Error(c, mapError(err), err.Error()) + api.RespondError(c, err) return } @@ -202,23 +206,9 @@ func (h *FileHandler) Delete(c *gin.Context) { fileID := c.Param("id") if err := h.fileService.Delete(c.Request.Context(), userID, fileID); err != nil { - api.Error(c, mapError(err), err.Error()) + api.RespondError(c, err) return } c.Status(http.StatusNoContent) } - -// mapError maps sentinel errors to HTTP status codes. -func mapError(err error) int { - switch { - case errors.Is(err, model.ErrNotFound): - return http.StatusNotFound - case errors.Is(err, model.ErrForbidden): - return http.StatusForbidden - case errors.Is(err, model.ErrDuplicate): - return http.StatusConflict - default: - return http.StatusInternalServerError - } -} diff --git a/internal/handler/file_test.go b/internal/handler/file_test.go index 68f2062..3f36118 100644 --- a/internal/handler/file_test.go +++ b/internal/handler/file_test.go @@ -70,7 +70,7 @@ func setupFileHandler(t *testing.T) *FileHandler { fileRepo := repository.NewFileRepository(db) store := newInMemStore() - fileService := service.NewFileService(fileRepo, store, 0) + fileService := service.NewFileService(fileRepo, store, 0, nil) return NewFileHandler(fileService) } diff --git a/internal/model/errors.go b/internal/model/errors.go index 9f77f81..a4d8719 100644 --- a/internal/model/errors.go +++ b/internal/model/errors.go @@ -1,6 +1,10 @@ package model -import "errors" +import ( + "errors" + "fmt" + "net/http" +) var ( ErrNotFound = errors.New("resource not found") @@ -8,3 +12,25 @@ var ( ErrUnauthorized = errors.New("unauthorized") ErrForbidden = errors.New("forbidden") ) + +// AppError is a service-layer error that carries an HTTP status, a user-safe +// message, and an optional internal cause for logging. Handlers unpack it +// transparently via api.RespondError. +type AppError struct { + Status int // HTTP status code + Message string // safe for API response + Err error // internal cause (nil = user-caused, skip logging) +} + +func (e *AppError) Error() string { return e.Message } +func (e *AppError) Unwrap() error { return e.Err } + +// NewInternalError creates a 500 AppError with a wrapped internal cause. +// msg describes the operation that failed; cause is the underlying error. +func NewInternalError(msg string, cause error) *AppError { + return &AppError{ + Status: http.StatusInternalServerError, + Message: "internal server error", + Err: fmt.Errorf("%s: %w", msg, cause), + } +} diff --git a/internal/server/router.go b/internal/server/router.go index 97f5bb3..cba43c4 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -4,11 +4,18 @@ import ( "github.com/gin-gonic/gin" "github.com/dhao2001/mygo/internal/app" + "github.com/dhao2001/mygo/internal/middleware" ) // NewRouter builds the Gin router and registers API routes. func NewRouter(webApp *app.WebApp) *gin.Engine { - router := gin.Default() + router := gin.New() + + // Request ID must be first β€” every subsequent middleware and handler + // gets access to req_id in the context. + router.Use(middleware.RequestID()) + router.Use(gin.Logger()) + router.Use(gin.Recovery()) v1 := router.Group("/api/v1") diff --git a/internal/service/auth.go b/internal/service/auth.go index 444eda6..4b24563 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -3,7 +3,7 @@ package service import ( "context" "errors" - "fmt" + "net/http" "strings" "time" @@ -59,12 +59,12 @@ func NewAuthService( // Register creates a new user account. func (s *AuthService) Register(ctx context.Context, username, email, password string) (*model.User, error) { if username == "" || email == "" || password == "" { - return nil, fmt.Errorf("username, email, and password are required") + return nil, &model.AppError{Status: http.StatusBadRequest, Message: "username, email, and password are required"} } passwordHash, err := auth.HashPassword(password) if err != nil { - return nil, fmt.Errorf("hash password: %w", err) + return nil, model.NewInternalError("hash password", err) } user := &model.User{ @@ -76,9 +76,9 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st if err := s.userRepo.Create(ctx, user); err != nil { if errors.Is(err, model.ErrDuplicate) { - return nil, fmt.Errorf("username or email already exists") + return nil, &model.AppError{Status: http.StatusConflict, Message: "username or email already exists"} } - return nil, fmt.Errorf("create user: %w", err) + return nil, model.NewInternalError("create user", err) } return user, nil @@ -89,13 +89,13 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token user, err := s.userRepo.FindByEmail(ctx, email) if err != nil { if errors.Is(err, model.ErrNotFound) { - return nil, fmt.Errorf("invalid email or password") + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"} } - return nil, fmt.Errorf("find user: %w", err) + return nil, model.NewInternalError("find user", err) } if err := auth.VerifyPassword(user.PasswordHash, password); err != nil { - return nil, fmt.Errorf("invalid email or password") + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"} } return s.issueTokens(ctx, user.ID) @@ -106,28 +106,28 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*TokenPair, error) { claims, err := auth.ParseToken(refreshTokenStr, s.jwtSecret) if err != nil { - return nil, fmt.Errorf("invalid token") + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} } if claims.Type != auth.TokenRefresh { - return nil, fmt.Errorf("invalid token") + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} } tokenHash := auth.HashToken(refreshTokenStr) session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash) if err != nil { if errors.Is(err, model.ErrNotFound) { - return nil, fmt.Errorf("invalid token") + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} } - return nil, fmt.Errorf("find session: %w", err) + return nil, model.NewInternalError("find session", err) } if session.UserID != claims.UserID { - return nil, fmt.Errorf("invalid token") + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} } if err := s.sessionRepo.Delete(ctx, session.ID); err != nil { - return nil, fmt.Errorf("delete old session: %w", err) + return nil, model.NewInternalError("delete old session", err) } return s.issueTokens(ctx, claims.UserID) @@ -141,7 +141,7 @@ func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error if errors.Is(err, model.ErrNotFound) { return nil } - return fmt.Errorf("find session: %w", err) + return model.NewInternalError("find session", err) } return s.sessionRepo.Delete(ctx, session.ID) @@ -151,7 +151,7 @@ func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) (*CreatedPasskey, error) { raw, hash, err := auth.GenerateToken() if err != nil { - return nil, fmt.Errorf("generate token: %w", err) + return nil, model.NewInternalError("generate token", err) } cred := &model.Credential{ @@ -163,7 +163,7 @@ func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) ( } if err := s.credentialRepo.Create(ctx, cred); err != nil { - return nil, fmt.Errorf("create credential: %w", err) + return nil, model.NewInternalError("create credential", err) } return &CreatedPasskey{ @@ -176,24 +176,24 @@ func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) ( // LoginWithPasskey authenticates a user using an app passkey token. func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*TokenPair, error) { if !strings.HasPrefix(tokenStr, "mygo_") { - return nil, fmt.Errorf("invalid passkey format") + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey format"} } tokenHash := auth.HashToken(tokenStr) cred, err := s.credentialRepo.FindByHash(ctx, tokenHash) if err != nil { if errors.Is(err, model.ErrNotFound) { - return nil, fmt.Errorf("invalid passkey") + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"} } - return nil, fmt.Errorf("find credential: %w", err) + return nil, model.NewInternalError("find credential", err) } if cred.Type != "app_passkey" { - return nil, fmt.Errorf("invalid credential type") + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"} } if err := s.credentialRepo.UpdateLastUsed(ctx, cred.ID); err != nil { - return nil, fmt.Errorf("update last used: %w", err) + return nil, model.NewInternalError("update last used", err) } return s.issueTokens(ctx, cred.UserID) @@ -208,11 +208,14 @@ func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model. func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string) error { cred, err := s.credentialRepo.FindByID(ctx, credID) if err != nil { - return fmt.Errorf("find credential: %w", err) + if errors.Is(err, model.ErrNotFound) { + return &model.AppError{Status: http.StatusNotFound, Message: "passkey not found", Err: model.ErrNotFound} + } + return model.NewInternalError("find credential", err) } if cred.UserID != userID { - return model.ErrForbidden + return &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden} } return s.credentialRepo.Delete(ctx, credID) @@ -221,12 +224,12 @@ func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string) func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPair, error) { accessToken, err := auth.GenerateAccessToken(userID, s.jwtSecret, s.accessTTL) if err != nil { - return nil, fmt.Errorf("generate access token: %w", err) + return nil, model.NewInternalError("generate access token", err) } refreshToken, err := auth.GenerateRefreshToken(userID, s.jwtSecret, s.refreshTTL) if err != nil { - return nil, fmt.Errorf("generate refresh token: %w", err) + return nil, model.NewInternalError("generate refresh token", err) } session := &model.Session{ @@ -237,7 +240,7 @@ func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPai } if err := s.sessionRepo.Create(ctx, session); err != nil { - return nil, fmt.Errorf("create session: %w", err) + return nil, model.NewInternalError("create session", err) } return &TokenPair{ diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go index 4cbcde9..3901c7f 100644 --- a/internal/service/auth_test.go +++ b/internal/service/auth_test.go @@ -2,6 +2,8 @@ package service import ( "context" + "errors" + "net/http" "testing" "time" @@ -336,8 +338,9 @@ func TestAuthService_RevokePasskeyNotOwner(t *testing.T) { claimsBob, _ := auth.ParseToken(pairBob.AccessToken, []byte("test-secret")) err = svc.RevokePasskey(ctx, claimsBob.UserID, pk.ID) - if err != model.ErrForbidden { - t.Fatalf("expected ErrForbidden, got %v", err) + var ae *model.AppError + if !errors.As(err, &ae) || ae.Status != http.StatusForbidden { + t.Fatalf("expected AppError 403 Forbidden, got %v", err) } } diff --git a/internal/service/file.go b/internal/service/file.go index 99f2f87..30b8198 100644 --- a/internal/service/file.go +++ b/internal/service/file.go @@ -8,6 +8,8 @@ import ( "errors" "fmt" "io" + "log/slog" + "net/http" "strings" "time" @@ -44,6 +46,7 @@ type FileService struct { fileRepo repository.FileRepository storage storage.StorageBackend maxUploadSize int64 + logger *slog.Logger } // NewFileService creates a FileService. @@ -51,11 +54,16 @@ func NewFileService( fileRepo repository.FileRepository, storage storage.StorageBackend, maxUploadSize int64, + logger *slog.Logger, ) *FileService { + if logger == nil { + logger = slog.Default() + } return &FileService{ fileRepo: fileRepo, storage: storage, maxUploadSize: maxUploadSize, + logger: logger, } } @@ -74,9 +82,9 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin // Check for name conflicts in the target directory. if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, fileName); err == nil { - return nil, fmt.Errorf("a file with this name already exists in this directory") + return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"} } else if !errors.Is(err, model.ErrNotFound) { - return nil, fmt.Errorf("check name conflict: %w", err) + return nil, model.NewInternalError("check name conflict", err) } // Limit the reader if a max upload size is configured. @@ -88,7 +96,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin head := make([]byte, 512) n, readErr := io.ReadFull(reader, head) if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF { - return nil, fmt.Errorf("read for mime detection: %w", readErr) + return nil, model.NewInternalError("read for mime detection", readErr) } head = head[:n] mimeType := mimetype.Detect(head).String() @@ -103,13 +111,13 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin written, err := s.storage.Save(ctx, storagePath, teeReader) if err != nil { - return nil, fmt.Errorf("save file: %w", err) + return nil, model.NewInternalError("save file", err) } if s.maxUploadSize > 0 && written > s.maxUploadSize { // Clean up the oversized file. _ = s.storage.Delete(ctx, storagePath) - return nil, fmt.Errorf("file size exceeds the maximum allowed size of %d bytes", s.maxUploadSize) + return nil, &model.AppError{Status: http.StatusBadRequest, Message: fmt.Sprintf("file size exceeds the maximum allowed size of %d bytes", s.maxUploadSize)} } file := &model.File{ @@ -128,9 +136,9 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin // Compensate: remove the stored file. _ = s.storage.Delete(ctx, storagePath) if errors.Is(err, model.ErrDuplicate) { - return nil, fmt.Errorf("a file with this name already exists in this directory") + return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"} } - return nil, fmt.Errorf("create file record: %w", err) + return nil, model.NewInternalError("create file record", err) } return modelToFileInfo(file), nil @@ -145,12 +153,12 @@ func (s *FileService) Download(ctx context.Context, userID, fileID string) (io.R } if file.IsDir { - return nil, nil, fmt.Errorf("cannot download a directory") + return nil, nil, &model.AppError{Status: http.StatusBadRequest, Message: "cannot download a directory"} } reader, err := s.storage.Open(ctx, file.StoragePath) if err != nil { - return nil, nil, fmt.Errorf("open file: %w", err) + return nil, nil, model.NewInternalError("open file", err) } return reader, modelToFileInfo(file), nil @@ -175,7 +183,7 @@ func (s *FileService) List(ctx context.Context, userID string, parentID *string, files, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, parentID, offset, limit) if err != nil { - return nil, fmt.Errorf("list files: %w", err) + return nil, model.NewInternalError("list files", err) } infos := make([]FileInfo, 0, len(files)) @@ -204,7 +212,7 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName // If parent is changing, verify the new parent. if newParentID != nil { if *newParentID == fileID { - return nil, fmt.Errorf("cannot move a directory into itself") + return nil, &model.AppError{Status: http.StatusBadRequest, Message: "cannot move a directory into itself"} } if err := s.verifyParent(ctx, userID, *newParentID); err != nil { return nil, err @@ -216,17 +224,17 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName if newName != "" || newParentID != nil { conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name) if err == nil && conflict.ID != fileID { - return nil, fmt.Errorf("a file with this name already exists in the target directory") + return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in the target directory"} } else if err != nil && !errors.Is(err, model.ErrNotFound) { - return nil, fmt.Errorf("check name conflict: %w", err) + return nil, model.NewInternalError("check name conflict", err) } } if err := s.fileRepo.Update(ctx, file); err != nil { if errors.Is(err, model.ErrDuplicate) { - return nil, fmt.Errorf("a file with this name already exists in the target directory") + return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in the target directory"} } - return nil, fmt.Errorf("update file: %w", err) + return nil, model.NewInternalError("update file", err) } return modelToFileInfo(file), nil @@ -243,23 +251,22 @@ func (s *FileService) Delete(ctx context.Context, userID, fileID string) error { if file.IsDir { _, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, &fileID, 0, 1) if err != nil { - return fmt.Errorf("check directory contents: %w", err) + return model.NewInternalError("check directory contents", err) } if total > 0 { - return fmt.Errorf("directory is not empty") + return &model.AppError{Status: http.StatusConflict, Message: "directory is not empty"} } } if err := s.fileRepo.Delete(ctx, fileID); err != nil { - return fmt.Errorf("delete file record: %w", err) + return model.NewInternalError("delete file record", err) } // Remove content from storage (directories have no stored content). if !file.IsDir { if err := s.storage.Delete(ctx, file.StoragePath); err != nil { - // Log but don't fail β€” the DB record is already gone. - // A periodic cleanup job can handle orphaned files later. - _ = err + s.logger.WarnContext(ctx, "failed to delete file from storage", + "path", file.StoragePath, "error", err) } } @@ -280,24 +287,24 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st // Check for name conflicts in the target directory. if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, dirName); err == nil { - return nil, fmt.Errorf("a file with this name already exists in this directory") + return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"} } else if !errors.Is(err, model.ErrNotFound) { - return nil, fmt.Errorf("check name conflict: %w", err) + return nil, model.NewInternalError("check name conflict", err) } dir := &model.File{ - ID: uuid.NewString(), - UserID: userID, + ID: uuid.NewString(), + UserID: userID, ParentID: parentID, - Name: dirName, - IsDir: true, + Name: dirName, + IsDir: true, } if err := s.fileRepo.Create(ctx, dir); err != nil { if errors.Is(err, model.ErrDuplicate) { - return nil, fmt.Errorf("a file with this name already exists in this directory") + return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"} } - return nil, fmt.Errorf("create directory record: %w", err) + return nil, model.NewInternalError("create directory record", err) } return modelToFileInfo(dir), nil @@ -308,13 +315,13 @@ func (s *FileService) getOwnedFile(ctx context.Context, userID, fileID string) ( file, err := s.fileRepo.FindByID(ctx, fileID) if err != nil { if errors.Is(err, model.ErrNotFound) { - return nil, model.ErrNotFound + return nil, &model.AppError{Status: http.StatusNotFound, Message: "file not found", Err: model.ErrNotFound} } - return nil, fmt.Errorf("find file: %w", err) + return nil, model.NewInternalError("find file", err) } if file.UserID != userID { - return nil, model.ErrForbidden + return nil, &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden} } return file, nil @@ -325,17 +332,17 @@ func (s *FileService) verifyParent(ctx context.Context, userID, parentID string) parent, err := s.fileRepo.FindByID(ctx, parentID) if err != nil { if errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("parent directory not found") + return &model.AppError{Status: http.StatusNotFound, Message: "parent directory not found"} } - return fmt.Errorf("find parent: %w", err) + return model.NewInternalError("find parent", err) } if parent.UserID != userID { - return model.ErrForbidden + return &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden} } if !parent.IsDir { - return fmt.Errorf("parent is not a directory") + return &model.AppError{Status: http.StatusBadRequest, Message: "parent is not a directory"} } return nil @@ -345,13 +352,13 @@ func (s *FileService) verifyParent(ctx context.Context, userID, parentID string) // null bytes, or are reserved names "." and "..". func validateFileName(name string) error { if name == "" { - return fmt.Errorf("file name must not be empty") + return &model.AppError{Status: http.StatusBadRequest, Message: "file name must not be empty"} } if strings.ContainsAny(name, "/\\\x00") { - return fmt.Errorf("file name contains invalid characters") + return &model.AppError{Status: http.StatusBadRequest, Message: "file name contains invalid characters"} } if name == "." || name == ".." { - return fmt.Errorf("file name is reserved: %s", name) + return &model.AppError{Status: http.StatusBadRequest, Message: fmt.Sprintf("file name is reserved: %s", name)} } return nil } diff --git a/internal/service/file_test.go b/internal/service/file_test.go index fcce35c..be1f147 100644 --- a/internal/service/file_test.go +++ b/internal/service/file_test.go @@ -3,7 +3,9 @@ package service import ( "bytes" "context" + "errors" "io" + "net/http" "strings" "sync" "testing" @@ -16,6 +18,21 @@ import ( "github.com/dhao2001/mygo/internal/storage" ) +// assertAppError checks that err is an *model.AppError with the expected status. +func assertAppError(t *testing.T, err error, wantStatus int) bool { + t.Helper() + var ae *model.AppError + if !errors.As(err, &ae) { + t.Errorf("expected *model.AppError, got %T: %v", err, err) + return false + } + if ae.Status != wantStatus { + t.Errorf("status = %d, want %d; message = %q", ae.Status, wantStatus, ae.Message) + return false + } + return true +} + // memStorage is an in-memory implementation of storage.StorageBackend for tests. type memStorage struct { mu sync.RWMutex @@ -70,7 +87,7 @@ func setupFileService(t *testing.T) *FileService { fileRepo := repository.NewFileRepository(db) store := newMemStorage() - return NewFileService(fileRepo, store, 0) // 0 = unlimited + return NewFileService(fileRepo, store, 0, nil) // 0 = unlimited, nil logger defaults to slog.Default() } func TestFileService_Upload(t *testing.T) { @@ -214,9 +231,7 @@ func TestFileService_DownloadForbidden(t *testing.T) { } _, _, err = svc.Download(ctx, "user2", info.ID) - if err != model.ErrForbidden { - t.Errorf("expected ErrForbidden, got %v", err) - } + assertAppError(t, err, http.StatusForbidden) } func TestFileService_DownloadDirectory(t *testing.T) { @@ -257,9 +272,7 @@ func TestFileService_GetNotFound(t *testing.T) { ctx := context.Background() _, err := svc.Get(ctx, "user1", "nonexistent") - if err != model.ErrNotFound { - t.Errorf("expected ErrNotFound, got %v", err) - } + assertAppError(t, err, http.StatusNotFound) } func TestFileService_GetForbidden(t *testing.T) { @@ -272,9 +285,7 @@ func TestFileService_GetForbidden(t *testing.T) { } _, err = svc.Get(ctx, "user2", info.ID) - if err != model.ErrForbidden { - t.Errorf("expected ErrForbidden, got %v", err) - } + assertAppError(t, err, http.StatusForbidden) } func TestFileService_List(t *testing.T) { @@ -372,9 +383,7 @@ func TestFileService_Delete(t *testing.T) { } _, err = svc.Get(ctx, "user1", info.ID) - if err != model.ErrNotFound { - t.Errorf("expected ErrNotFound after delete, got %v", err) - } + assertAppError(t, err, http.StatusNotFound) } func TestFileService_DeleteNonEmptyDirectory(t *testing.T) { @@ -406,9 +415,7 @@ func TestFileService_DeleteForbidden(t *testing.T) { } err = svc.Delete(ctx, "user2", info.ID) - if err != model.ErrForbidden { - t.Errorf("expected ErrForbidden, got %v", err) - } + assertAppError(t, err, http.StatusForbidden) } func TestFileService_CreateDir(t *testing.T) { @@ -452,7 +459,5 @@ func TestFileService_VerifyParentForbidden(t *testing.T) { } _, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data")) - if err != model.ErrForbidden { - t.Errorf("expected ErrForbidden, got %v", err) - } + assertAppError(t, err, http.StatusForbidden) } From 53bd473861e6c622798f8d7e14da79511ad5886b Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 16:24:37 +0800 Subject: [PATCH 08/31] Add structured logging with request ID middleware --- internal/log/context.go | 47 ++++++++++++++++++ internal/log/logger.go | 81 ++++++++++++++++++++++++++++++++ internal/middleware/requestid.go | 30 ++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 internal/log/context.go create mode 100644 internal/log/logger.go create mode 100644 internal/middleware/requestid.go diff --git a/internal/log/context.go b/internal/log/context.go new file mode 100644 index 0000000..99f6082 --- /dev/null +++ b/internal/log/context.go @@ -0,0 +1,47 @@ +package log + +import ( + "context" + "log/slog" +) + +type contextKey string + +// RequestIDKey is the context key for the request ID value. +const RequestIDKey contextKey = "req_id" + +// contextHandler wraps a slog.Handler, extracting req_id from the +// context.Context and appending it to every log record. +type contextHandler struct { + underlying slog.Handler +} + +// NewContextHandler wraps an slog.Handler so that records get a req_id +// attribute extracted from context.Context (if present). +func NewContextHandler(h slog.Handler) slog.Handler { + return &contextHandler{underlying: h} +} + +func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.underlying.Enabled(ctx, level) +} + +func (h *contextHandler) Handle(ctx context.Context, r slog.Record) error { + if reqID, ok := ctx.Value(RequestIDKey).(string); ok && reqID != "" { + r.AddAttrs(slog.String("req_id", reqID)) + } + return h.underlying.Handle(ctx, r) +} + +func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &contextHandler{underlying: h.underlying.WithAttrs(attrs)} +} + +func (h *contextHandler) WithGroup(name string) slog.Handler { + return &contextHandler{underlying: h.underlying.WithGroup(name)} +} + +// WithRequestID returns a child context carrying a request ID. +func WithRequestID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, RequestIDKey, id) +} diff --git a/internal/log/logger.go b/internal/log/logger.go new file mode 100644 index 0000000..dfed508 --- /dev/null +++ b/internal/log/logger.go @@ -0,0 +1,81 @@ +package log + +import ( + "context" + "log/slog" + "os" + + "github.com/dhao2001/mygo/internal/config" +) + +// NewLogger creates a slog.Logger with dual output: terminal (stderr) and +// optional file. Terminal and file outputs use independent log levels. +// On file-open failure, a warning is emitted to stderr and the logger +// continues without file output. +func NewLogger(cfg config.LogConfig) *slog.Logger { + terminalLevel, _ := config.ParseLogLevel(cfg.Level) + fileLevel, _ := config.ParseLogLevel(cfg.FileLevel) + + terminalHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: terminalLevel, + }) + + handlers := []slog.Handler{terminalHandler} + + if cfg.FilePath != "" { + f, err := os.OpenFile(cfg.FilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + slog.New(terminalHandler).Warn("failed to open log file, continuing without file output", + "path", cfg.FilePath, "error", err) + } else { + fileHandler := slog.NewTextHandler(f, &slog.HandlerOptions{ + Level: fileLevel, + }) + handlers = append(handlers, fileHandler) + } + } + + handler := NewContextHandler(&multiHandler{handlers: handlers}) + return slog.New(handler) +} + +// multiHandler fans out to multiple slog.Handler implementations. +type multiHandler struct { + handlers []slog.Handler +} + +func (m *multiHandler) Enabled(ctx context.Context, level slog.Level) bool { + for _, h := range m.handlers { + if h.Enabled(ctx, level) { + return true + } + } + return false +} + +func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error { + for _, h := range m.handlers { + if h.Enabled(ctx, r.Level) { + if err := h.Handle(ctx, r); err != nil { + return err + } + } + } + return nil +} + +func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + clones := make([]slog.Handler, len(m.handlers)) + for i, h := range m.handlers { + clones[i] = h.WithAttrs(attrs) + } + return &multiHandler{handlers: clones} +} + +func (m *multiHandler) WithGroup(name string) slog.Handler { + clones := make([]slog.Handler, len(m.handlers)) + for i, h := range m.handlers { + clones[i] = h.WithGroup(name) + } + return &multiHandler{handlers: clones} +} diff --git a/internal/middleware/requestid.go b/internal/middleware/requestid.go new file mode 100644 index 0000000..2507291 --- /dev/null +++ b/internal/middleware/requestid.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + mygolog "github.com/dhao2001/mygo/internal/log" +) + +// RequestID returns a Gin middleware that ensures every request has a +// request ID. If the client sends X-Request-ID, it is reused; otherwise a +// new UUID v4 is generated. The ID is injected into both the Go +// context.Context (for slog) and the Gin context (for handler access). +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + reqID := c.GetHeader("X-Request-ID") + if reqID == "" { + reqID = uuid.NewString() + } + + // Inject into Go context for slog.*Context calls. + c.Request = c.Request.WithContext(mygolog.WithRequestID(c.Request.Context(), reqID)) + + // Also set in Gin context for direct access. + c.Set("req_id", reqID) + c.Header("X-Request-ID", reqID) + + c.Next() + } +} From bff131ba429d2f238d834e7c9a87bcb0d1b81c42 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 17:08:10 +0800 Subject: [PATCH 09/31] feat(repo): soft-delete User with status filter on queries --- internal/repository/user.go | 24 ++++-- internal/repository/user_test.go | 141 +++++++++++++++++++++++++++++-- 2 files changed, 153 insertions(+), 12 deletions(-) diff --git a/internal/repository/user.go b/internal/repository/user.go index 18ccdfb..bf573d5 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -19,6 +19,7 @@ func isDuplicateKeyError(err error) bool { type UserRepository interface { Create(ctx context.Context, user *model.User) error FindByID(ctx context.Context, id string) (*model.User, error) + FindByIDIncludeDeleted(ctx context.Context, id string) (*model.User, error) FindByEmail(ctx context.Context, email string) (*model.User, error) FindByUsername(ctx context.Context, username string) (*model.User, error) Update(ctx context.Context, user *model.User) error @@ -47,6 +48,19 @@ func (r *userRepository) Create(ctx context.Context, user *model.User) error { } func (r *userRepository) FindByID(ctx context.Context, id string) (*model.User, error) { + var user model.User + result := r.db.WithContext(ctx).First(&user, "id = ? AND status = ?", id, model.StatusActive) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, model.ErrNotFound + } + if result.Error != nil { + return nil, result.Error + } + return &user, nil +} + +// FindByIDIncludeDeleted finds a user by ID regardless of status. +func (r *userRepository) FindByIDIncludeDeleted(ctx context.Context, id string) (*model.User, error) { var user model.User result := r.db.WithContext(ctx).First(&user, "id = ?", id) if errors.Is(result.Error, gorm.ErrRecordNotFound) { @@ -60,7 +74,7 @@ func (r *userRepository) FindByID(ctx context.Context, id string) (*model.User, func (r *userRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) { var user model.User - result := r.db.WithContext(ctx).First(&user, "email = ?", email) + result := r.db.WithContext(ctx).First(&user, "email = ? AND status = ?", email, model.StatusActive) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, model.ErrNotFound } @@ -72,7 +86,7 @@ func (r *userRepository) FindByEmail(ctx context.Context, email string) (*model. func (r *userRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) { var user model.User - result := r.db.WithContext(ctx).First(&user, "username = ?", username) + result := r.db.WithContext(ctx).First(&user, "username = ? AND status = ?", username, model.StatusActive) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, model.ErrNotFound } @@ -94,7 +108,7 @@ func (r *userRepository) Update(ctx context.Context, user *model.User) error { } func (r *userRepository) Delete(ctx context.Context, id string) error { - result := r.db.WithContext(ctx).Delete(&model.User{}, "id = ?", id) + result := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("status", model.StatusAdminDeleted) if result.Error != nil { return result.Error } @@ -105,11 +119,11 @@ func (r *userRepository) List(ctx context.Context, offset, limit int) ([]model.U var users []model.User var total int64 - if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil { + if err := r.db.WithContext(ctx).Model(&model.User{}).Where("status = ?", model.StatusActive).Count(&total).Error; err != nil { return nil, 0, err } - result := r.db.WithContext(ctx).Offset(offset).Limit(limit).Find(&users) + result := r.db.WithContext(ctx).Where("status = ?", model.StatusActive).Offset(offset).Limit(limit).Find(&users) if result.Error != nil { return nil, 0, result.Error } diff --git a/internal/repository/user_test.go b/internal/repository/user_test.go index 07a1f29..2bbe34d 100644 --- a/internal/repository/user_test.go +++ b/internal/repository/user_test.go @@ -33,6 +33,7 @@ func TestUserRepository_Create(t *testing.T) { Username: "alice", Email: "alice@example.com", PasswordHash: "hash", + Status: model.StatusActive, } if err := repo.Create(ctx, user); err != nil { @@ -44,8 +45,8 @@ func TestUserRepository_CreateDuplicateUsername(t *testing.T) { repo := setupUserRepo(t) ctx := context.Background() - u1 := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} - u2 := &model.User{ID: "user-2", Username: "alice", Email: "alice2@example.com", PasswordHash: "hash"} + u1 := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive} + u2 := &model.User{ID: "user-2", Username: "alice", Email: "alice2@example.com", PasswordHash: "hash", Status: model.StatusActive} if err := repo.Create(ctx, u1); err != nil { t.Fatalf("Create = %v", err) @@ -61,7 +62,7 @@ func TestUserRepository_FindByID(t *testing.T) { repo := setupUserRepo(t) ctx := context.Background() - user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} + user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive} if err := repo.Create(ctx, user); err != nil { t.Fatalf("Create = %v", err) } @@ -89,7 +90,7 @@ func TestUserRepository_FindByEmail(t *testing.T) { repo := setupUserRepo(t) ctx := context.Background() - user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} + user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive} if err := repo.Create(ctx, user); err != nil { t.Fatalf("Create = %v", err) } @@ -107,7 +108,7 @@ func TestUserRepository_FindByUsername(t *testing.T) { repo := setupUserRepo(t) ctx := context.Background() - user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} + user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive} if err := repo.Create(ctx, user); err != nil { t.Fatalf("Create = %v", err) } @@ -125,7 +126,7 @@ func TestUserRepository_Update(t *testing.T) { repo := setupUserRepo(t) ctx := context.Background() - user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} + user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive} if err := repo.Create(ctx, user); err != nil { t.Fatalf("Create = %v", err) } @@ -148,7 +149,7 @@ func TestUserRepository_Delete(t *testing.T) { repo := setupUserRepo(t) ctx := context.Background() - user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} + user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive} if err := repo.Create(ctx, user); err != nil { t.Fatalf("Create = %v", err) } @@ -173,6 +174,7 @@ func TestUserRepository_List(t *testing.T) { Username: "user" + string(rune('0'+i)), Email: "user" + string(rune('0'+i)) + "@example.com", PasswordHash: "hash", + Status: model.StatusActive, } if err := repo.Create(ctx, user); err != nil { t.Fatalf("Create = %v", err) @@ -190,3 +192,128 @@ func TestUserRepository_List(t *testing.T) { t.Errorf("total = %d, want %d", total, 5) } } + +func TestUserRepository_SoftDelete(t *testing.T) { + repo := setupUserRepo(t) + ctx := context.Background() + + user := &model.User{ + ID: "user-1", + Username: "alice", + Email: "alice@example.com", + PasswordHash: "hash", + Status: model.StatusActive, + } + if err := repo.Create(ctx, user); err != nil { + t.Fatalf("Create = %v", err) + } + + if err := repo.Delete(ctx, "user-1"); err != nil { + t.Fatalf("Delete = %v", err) + } + + // After soft-delete, FindByID should return ErrNotFound + // because the status filter excludes soft-deleted users. + _, err := repo.FindByID(ctx, "user-1") + if err != model.ErrNotFound { + t.Fatalf("expected ErrNotFound after soft-delete, got %v", err) + } +} + +func TestUserRepository_DisabledLogin(t *testing.T) { + repo := setupUserRepo(t) + ctx := context.Background() + + user := &model.User{ + ID: "user-1", + Username: "alice", + Email: "alice@example.com", + PasswordHash: "hash", + Status: model.StatusActive, + } + if err := repo.Create(ctx, user); err != nil { + t.Fatalf("Create = %v", err) + } + + if err := repo.Delete(ctx, "user-1"); err != nil { + t.Fatalf("Delete = %v", err) + } + + // Soft-deleted users should not be findable by email, + // preventing login for disabled accounts. + _, err := repo.FindByEmail(ctx, "alice@example.com") + if err != model.ErrNotFound { + t.Fatalf("expected ErrNotFound for soft-deleted user login, got %v", err) + } +} + +func TestUserRepository_StatusFilterList(t *testing.T) { + repo := setupUserRepo(t) + ctx := context.Background() + + u1 := &model.User{ + ID: "user-1", + Username: "alice", + Email: "alice@example.com", + PasswordHash: "hash", + Status: model.StatusActive, + } + if err := repo.Create(ctx, u1); err != nil { + t.Fatalf("Create u1 = %v", err) + } + + u2 := &model.User{ + ID: "user-2", + Username: "bob", + Email: "bob@example.com", + PasswordHash: "hash", + Status: model.StatusActive, + } + if err := repo.Create(ctx, u2); err != nil { + t.Fatalf("Create u2 = %v", err) + } + + // Soft-delete bob + if err := repo.Delete(ctx, "user-2"); err != nil { + t.Fatalf("Delete u2 = %v", err) + } + + // List with status filter should only return active users. + users, total, err := repo.List(ctx, 0, 10) + if err != nil { + t.Fatalf("List = %v", err) + } + if len(users) != 1 { + t.Fatalf("expected 1 active user, got %d", len(users)) + } + if total != 1 { + t.Fatalf("expected total 1, got %d", total) + } + if users[0].ID != "user-1" { + t.Errorf("expected active user user-1, got %s", users[0].ID) + } +} + +func TestUserRepository_DeleteIdempotent(t *testing.T) { + repo := setupUserRepo(t) + ctx := context.Background() + + user := &model.User{ + ID: "user-1", + Username: "alice", + Email: "alice@example.com", + PasswordHash: "hash", + Status: model.StatusActive, + } + if err := repo.Create(ctx, user); err != nil { + t.Fatalf("Create = %v", err) + } + + // Soft-delete the same user twice should not error. + if err := repo.Delete(ctx, "user-1"); err != nil { + t.Fatalf("first Delete = %v", err) + } + if err := repo.Delete(ctx, "user-1"); err != nil { + t.Fatalf("second Delete = %v", err) + } +} From 118b7e4d2a6879ae12c2cba4b0a31230938ddf54 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 17:13:02 +0800 Subject: [PATCH 10/31] feat(svc): reject disabled users in Login, LoginWithPasskey, and Refresh --- internal/service/auth.go | 21 +++++ internal/service/auth_test.go | 159 +++++++++++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 1 deletion(-) diff --git a/internal/service/auth.go b/internal/service/auth.go index 4b24563..584ce04 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -72,6 +72,7 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st Username: username, Email: email, PasswordHash: passwordHash, + Status: model.StatusActive, } if err := s.userRepo.Create(ctx, user); err != nil { @@ -94,6 +95,10 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token return nil, model.NewInternalError("find user", err) } + if user.Status != model.StatusActive { + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"} + } + if err := auth.VerifyPassword(user.PasswordHash, password); err != nil { return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"} } @@ -126,6 +131,14 @@ func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*Tok return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} } + user, err := s.userRepo.FindByID(ctx, claims.UserID) + if err != nil { + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} + } + if user.Status != model.StatusActive { + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} + } + if err := s.sessionRepo.Delete(ctx, session.ID); err != nil { return nil, model.NewInternalError("delete old session", err) } @@ -188,6 +201,14 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T return nil, model.NewInternalError("find credential", err) } + user, err := s.userRepo.FindByIDIncludeDeleted(ctx, cred.UserID) + if err != nil { + return nil, model.NewInternalError("find user", err) + } + if user.Status != model.StatusActive { + return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"} + } + if cred.Type != "app_passkey" { return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"} } diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go index 3901c7f..767edce 100644 --- a/internal/service/auth_test.go +++ b/internal/service/auth_test.go @@ -16,6 +16,13 @@ import ( ) func setupAuthService(t *testing.T) *AuthService { + svc, _ := setupAuthServiceWithRepos(t) + return svc +} + +// setupAuthServiceWithRepos creates an AuthService and returns the UserRepository +// for tests that need direct repo access (e.g., soft-deleting users). +func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepository) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -30,12 +37,13 @@ func setupAuthService(t *testing.T) *AuthService { sessionRepo := repository.NewSessionRepository(db) credentialRepo := repository.NewCredentialRepository(db) - return NewAuthService( + svc := NewAuthService( userRepo, sessionRepo, credentialRepo, []byte("test-secret"), 15*time.Minute, 7*24*time.Hour, ) + return svc, userRepo } func TestAuthService_Register(t *testing.T) { @@ -404,3 +412,152 @@ func TestAuthService_RefreshWithAccessToken(t *testing.T) { t.Fatal("expected error when using access token for refresh, got nil") } } + +func TestAuthService_LoginDisabledUser(t *testing.T) { + svc, userRepo := setupAuthServiceWithRepos(t) + ctx := context.Background() + + user, err := svc.Register(ctx, "alice", "alice@example.com", "password123") + if err != nil { + t.Fatalf("Register = %v", err) + } + + if err := userRepo.Delete(ctx, user.ID); err != nil { + t.Fatalf("Delete = %v", err) + } + + _, err = svc.Login(ctx, "alice@example.com", "password123") + if err == nil { + t.Fatal("expected error for disabled user login, got nil") + } + var ae *model.AppError + if !errors.As(err, &ae) { + t.Fatalf("expected AppError, got %T: %v", err, err) + } + if ae.Status != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized) + } + if ae.Message != "invalid email or password" { + t.Errorf("message = %q, want %q", ae.Message, "invalid email or password") + } +} + +func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) { + svc, userRepo := setupAuthServiceWithRepos(t) + ctx := context.Background() + + user, err := svc.Register(ctx, "alice", "alice@example.com", "password123") + if err != nil { + t.Fatalf("Register = %v", err) + } + + // Get error for wrong password (active user) + _, wrongPwErr := svc.Login(ctx, "alice@example.com", "wrongpassword") + + // Soft-delete user, then try to login + if err := userRepo.Delete(ctx, user.ID); err != nil { + t.Fatalf("Delete = %v", err) + } + _, disabledErr := svc.Login(ctx, "alice@example.com", "password123") + + // Both errors should have the same message (no user enumeration) + if disabledErr == nil { + t.Fatal("expected error for disabled user login, got nil") + } + var aeDisabled *model.AppError + if !errors.As(disabledErr, &aeDisabled) { + t.Fatalf("expected AppError, got %T: %v", disabledErr, disabledErr) + } + + var aeWrongPw *model.AppError + if !errors.As(wrongPwErr, &aeWrongPw) { + t.Fatalf("expected AppError for wrong password, got %T: %v", wrongPwErr, wrongPwErr) + } + + if aeDisabled.Message != aeWrongPw.Message { + t.Errorf("disabled message = %q, want %q (must match wrong-password message to prevent user enumeration)", aeDisabled.Message, aeWrongPw.Message) + } + if aeDisabled.Message != "invalid email or password" { + t.Errorf("disabled message = %q, want %q", aeDisabled.Message, "invalid email or password") + } +} + +func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) { + svc, userRepo := setupAuthServiceWithRepos(t) + ctx := context.Background() + + user, err := svc.Register(ctx, "alice", "alice@example.com", "password123") + if err != nil { + t.Fatalf("Register = %v", err) + } + + pair, err := svc.Login(ctx, "alice@example.com", "password123") + if err != nil { + t.Fatalf("Login = %v", err) + } + + claims, err := auth.ParseToken(pair.AccessToken, []byte("test-secret")) + if err != nil { + t.Fatalf("ParseToken = %v", err) + } + + pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone") + if err != nil { + t.Fatalf("CreatePasskey = %v", err) + } + + // Soft-delete the user + if err := userRepo.Delete(ctx, user.ID); err != nil { + t.Fatalf("Delete = %v", err) + } + + _, err = svc.LoginWithPasskey(ctx, pk.Raw) + if err == nil { + t.Fatal("expected error for disabled user passkey login, got nil") + } + var ae *model.AppError + if !errors.As(err, &ae) { + t.Fatalf("expected AppError, got %T: %v", err, err) + } + if ae.Status != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized) + } + if ae.Message != "invalid passkey" { + t.Errorf("message = %q, want %q", ae.Message, "invalid passkey") + } +} + +func TestAuthService_RefreshDisabledUser(t *testing.T) { + svc, userRepo := setupAuthServiceWithRepos(t) + ctx := context.Background() + + user, err := svc.Register(ctx, "alice", "alice@example.com", "password123") + if err != nil { + t.Fatalf("Register = %v", err) + } + + pair, err := svc.Login(ctx, "alice@example.com", "password123") + if err != nil { + t.Fatalf("Login = %v", err) + } + + // Soft-delete the user + if err := userRepo.Delete(ctx, user.ID); err != nil { + t.Fatalf("Delete = %v", err) + } + + _, err = svc.Refresh(ctx, pair.RefreshToken) + if err == nil { + t.Fatal("expected error for disabled user refresh, got nil") + } + var ae *model.AppError + if !errors.As(err, &ae) { + t.Fatalf("expected AppError, got %T: %v", err, err) + } + if ae.Status != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized) + } + if ae.Message != "invalid token" { + t.Errorf("message = %q, want %q", ae.Message, "invalid token") + } +} From 170e54495d2f13212e226c2a9c89782ed77aea14 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 17:18:23 +0800 Subject: [PATCH 11/31] feat(handler): add AdminHandler with admin routes --- internal/handler/admin.go | 111 ++++++++++++++++++++++++++++ internal/server/routes_protected.go | 9 +++ 2 files changed, 120 insertions(+) create mode 100644 internal/handler/admin.go diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..36575ef --- /dev/null +++ b/internal/handler/admin.go @@ -0,0 +1,111 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "github.com/dhao2001/mygo/internal/api" + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/repository" +) + +// AdminHandler handles admin-only endpoints for user management. +type AdminHandler struct { + userRepo repository.UserRepository +} + +// NewAdminHandler creates an AdminHandler. +func NewAdminHandler(userRepo repository.UserRepository) *AdminHandler { + return &AdminHandler{userRepo: userRepo} +} + +// adminUserResponse exposes all user fields, including status, for admin views. +type adminUserResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + IsAdmin bool `json:"is_admin"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// adminUserListResponse wraps a paginated list of admin user views. +type adminUserListResponse struct { + Users []adminUserResponse `json:"users"` + Total int64 `json:"total"` +} + +func toAdminResponse(u *model.User) adminUserResponse { + return adminUserResponse{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + IsAdmin: u.IsAdmin, + Status: u.Status, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +// DeleteUser handles DELETE /api/v1/admin/users/:id β€” soft-deletes a user. +func (h *AdminHandler) DeleteUser(c *gin.Context) { + id := c.Param("id") + + if err := h.userRepo.Delete(c.Request.Context(), id); err != nil { + api.RespondError(c, err) + return + } + + c.Status(http.StatusNoContent) +} + +// GetUser handles GET /api/v1/admin/users/:id β€” returns a user including deleted. +func (h *AdminHandler) GetUser(c *gin.Context) { + id := c.Param("id") + + user, err := h.userRepo.FindByIDIncludeDeleted(c.Request.Context(), id) + if err != nil { + api.RespondError(c, err) + return + } + + c.JSON(http.StatusOK, toAdminResponse(user)) +} + +// ListUsers handles GET /api/v1/admin/users β€” returns a paginated list of all users. +func (h *AdminHandler) ListUsers(c *gin.Context) { + offset, err := strconv.Atoi(c.DefaultQuery("offset", "0")) + if err != nil || offset < 0 { + api.Error(c, http.StatusBadRequest, "invalid offset") + return + } + + limit, err := strconv.Atoi(c.DefaultQuery("limit", "50")) + if err != nil || limit < 1 { + api.Error(c, http.StatusBadRequest, "invalid limit") + return + } + if limit > 200 { + limit = 200 + } + + users, total, err := h.userRepo.ListIncludeDeleted(c.Request.Context(), offset, limit) + if err != nil { + api.RespondError(c, err) + return + } + + items := make([]adminUserResponse, 0, len(users)) + for i := range users { + items = append(items, toAdminResponse(&users[i])) + } + + c.JSON(http.StatusOK, adminUserListResponse{ + Users: items, + Total: total, + }) +} diff --git a/internal/server/routes_protected.go b/internal/server/routes_protected.go index 6e53b9e..4c889c1 100644 --- a/internal/server/routes_protected.go +++ b/internal/server/routes_protected.go @@ -12,6 +12,7 @@ func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) { jwtSecret := []byte(webApp.Config.JWT.Secret) accountHandler := handler.NewAccountHandler(webApp.AuthService) fileHandler := handler.NewFileHandler(webApp.FileService) + adminHandler := handler.NewAdminHandler(webApp.UserRepo) rg.Use(middleware.AuthRequired(jwtSecret)) @@ -36,4 +37,12 @@ func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) { files.PUT("/:id", fileHandler.Update) files.DELETE("/:id", fileHandler.Delete) } + + admin := rg.Group("/admin") + admin.Use(middleware.AdminRequired(webApp.UserRepo)) + { + admin.GET("/users", adminHandler.ListUsers) + admin.GET("/users/:id", adminHandler.GetUser) + admin.DELETE("/users/:id", adminHandler.DeleteUser) + } } From a8078f787cc61eae134ac359ae3de390eaed7bf3 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 17:20:14 +0800 Subject: [PATCH 12/31] feat(model): add Status field and constants to User and File Add Status field (gorm, indexed, default active) to both User and File models. Add User status constants: StatusActive, StatusAdminDeleted. Add File status constant: StatusUserDeleted. Add tests verifying Status field is excluded from JSON serialization. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/model/file.go | 4 +++ internal/model/file_test.go | 49 +++++++++++++++++++++++++++++++++++++ internal/model/user.go | 7 ++++++ internal/model/user_test.go | 29 ++++++++++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 internal/model/file_test.go create mode 100644 internal/model/user_test.go diff --git a/internal/model/file.go b/internal/model/file.go index a357f45..413cf0d 100644 --- a/internal/model/file.go +++ b/internal/model/file.go @@ -4,6 +4,9 @@ import ( "time" ) +// StatusUserDeleted marks a file that has been deleted by the user (soft delete). +const StatusUserDeleted = "user_deleted" + // File represents a file or directory entry in the virtual filesystem. type File struct { ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` @@ -14,6 +17,7 @@ type File struct { MimeType string `gorm:"type:varchar(127)" json:"mime_type"` StoragePath string `gorm:"type:varchar(512)" json:"storage_path"` Hash string `gorm:"type:varchar(64)" json:"-"` + Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"` IsDir bool `gorm:"default:false" json:"is_dir"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/model/file_test.go b/internal/model/file_test.go new file mode 100644 index 0000000..9ff7711 --- /dev/null +++ b/internal/model/file_test.go @@ -0,0 +1,49 @@ +package model + +import ( + "encoding/json" + "testing" + "time" +) + +func TestFile_StatusExcludedFromJSON(t *testing.T) { + f := &File{ + ID: "1", + UserID: "u1", + ParentID: nil, + Name: "test.txt", + Size: 100, + MimeType: "text/plain", + Status: StatusActive, + IsDir: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + data, err := json.Marshal(f) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + + if _, ok := result["status"]; ok { + t.Errorf("Status field should be excluded from JSON (json:\"-\"), but it appeared in output") + } + + if _, ok := result["hash"]; ok { + t.Errorf("Hash field should be excluded from JSON (json:\"-\"), but it appeared in output") + } +} + +func TestStatusConstants(t *testing.T) { + if StatusActive != "active" { + t.Errorf("StatusActive = %q, want \"active\"", StatusActive) + } + if StatusUserDeleted != "user_deleted" { + t.Errorf("StatusUserDeleted = %q, want \"user_deleted\"", StatusUserDeleted) + } +} diff --git a/internal/model/user.go b/internal/model/user.go index 9c7ee99..77673bd 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -11,6 +11,13 @@ type User struct { Email string `gorm:"uniqueIndex;type:varchar(255);not null" json:"email"` PasswordHash string `gorm:"type:varchar(255);not null" json:"-"` IsAdmin bool `gorm:"default:false" json:"is_admin"` + Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +// User status constants. +const ( + StatusActive = "active" + StatusAdminDeleted = "admin_deleted" +) diff --git a/internal/model/user_test.go b/internal/model/user_test.go new file mode 100644 index 0000000..e63fca6 --- /dev/null +++ b/internal/model/user_test.go @@ -0,0 +1,29 @@ +package model + +import ( + "encoding/json" + "testing" +) + +func TestUserJSONOmitsStatusField(t *testing.T) { + u := User{ + ID: "user-1", + Username: "alice", + Email: "alice@example.com", + Status: StatusActive, + } + + raw, err := json.Marshal(u) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + + if _, ok := m["status"]; ok { + t.Error("Status field should not appear in JSON output") + } +} From 032f716e4917ebbf8aa2b404a04a821cd689ead0 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 17:20:22 +0800 Subject: [PATCH 13/31] feat(repo): add soft-delete with status filter to file queries File repository now filters by status=active in all query methods (FindByID, FindByUserID, FindByParentID, FindByNameAndParent). Delete now performs soft-delete by setting status to 'user_deleted' instead of physical row deletion. Add ListIncludeDeleted to UserRepository for admin views of all users. Add tests: StatusFilter, SoftDelete, DeleteIdempotent, StatusFilterCount. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/repository/file.go | 16 ++-- internal/repository/file_test.go | 140 ++++++++++++++++++++++++++++--- internal/repository/user.go | 18 ++++ 3 files changed, 156 insertions(+), 18 deletions(-) diff --git a/internal/repository/file.go b/internal/repository/file.go index 584bcb9..8f6a370 100644 --- a/internal/repository/file.go +++ b/internal/repository/file.go @@ -43,7 +43,7 @@ func (r *fileRepository) Create(ctx context.Context, file *model.File) error { func (r *fileRepository) FindByID(ctx context.Context, id string) (*model.File, error) { var file model.File - result := r.db.WithContext(ctx).First(&file, "id = ?", id) + result := r.db.WithContext(ctx).First(&file, "id = ? AND status = ?", id, model.StatusActive) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, model.ErrNotFound } @@ -57,11 +57,11 @@ func (r *fileRepository) FindByUserID(ctx context.Context, userID string, offset var files []model.File var total int64 - if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ?", userID).Count(&total).Error; err != nil { + if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND status = ?", userID, model.StatusActive).Count(&total).Error; err != nil { return nil, 0, err } - result := r.db.WithContext(ctx).Where("user_id = ?", userID).Offset(offset).Limit(limit).Find(&files) + result := r.db.WithContext(ctx).Where("user_id = ? AND status = ?", userID, model.StatusActive).Offset(offset).Limit(limit).Find(&files) if result.Error != nil { return nil, 0, result.Error } @@ -71,7 +71,7 @@ func (r *fileRepository) FindByUserID(ctx context.Context, userID string, offset func (r *fileRepository) FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error) { var files []model.File - result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ?", userID, parentID).Find(&files) + result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Find(&files) if result.Error != nil { return nil, result.Error } @@ -82,11 +82,11 @@ func (r *fileRepository) FindByParentIDPaginated(ctx context.Context, userID str var files []model.File var total int64 - if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND parent_id IS ?", userID, parentID).Count(&total).Error; err != nil { + if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Count(&total).Error; err != nil { return nil, 0, err } - result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ?", userID, parentID).Offset(offset).Limit(limit).Order("is_dir DESC, name ASC").Find(&files) + result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Offset(offset).Limit(limit).Order("is_dir DESC, name ASC").Find(&files) if result.Error != nil { return nil, 0, result.Error } @@ -96,7 +96,7 @@ func (r *fileRepository) FindByParentIDPaginated(ctx context.Context, userID str func (r *fileRepository) FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error) { var file model.File - result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND name = ?", userID, parentID, name).First(&file) + result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND name = ? AND status = ?", userID, parentID, name, model.StatusActive).First(&file) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, model.ErrNotFound } @@ -115,7 +115,7 @@ func (r *fileRepository) Update(ctx context.Context, file *model.File) error { } func (r *fileRepository) Delete(ctx context.Context, id string) error { - result := r.db.WithContext(ctx).Delete(&model.File{}, "id = ?", id) + result := r.db.WithContext(ctx).Model(&model.File{}).Where("id = ?", id).Update("status", model.StatusUserDeleted) if result.Error != nil { return result.Error } diff --git a/internal/repository/file_test.go b/internal/repository/file_test.go index 1f75ef7..cc140c5 100644 --- a/internal/repository/file_test.go +++ b/internal/repository/file_test.go @@ -33,6 +33,7 @@ func TestFileRepository_Create(t *testing.T) { UserID: "user-1", Name: "test.txt", Size: 1024, + Status: model.StatusActive, } if err := repo.Create(ctx, file); err != nil { @@ -48,6 +49,7 @@ func TestFileRepository_FindByID(t *testing.T) { ID: "file-1", UserID: "user-1", Name: "test.txt", + Status: model.StatusActive, } if err := repo.Create(ctx, file); err != nil { t.Fatalf("Create = %v", err) @@ -77,9 +79,9 @@ func TestFileRepository_FindByUserID(t *testing.T) { ctx := context.Background() files := []*model.File{ - {ID: "f-1", UserID: "user-1", Name: "a.txt"}, - {ID: "f-2", UserID: "user-1", Name: "b.txt"}, - {ID: "f-3", UserID: "user-2", Name: "c.txt"}, + {ID: "f-1", UserID: "user-1", Name: "a.txt", Status: model.StatusActive}, + {ID: "f-2", UserID: "user-1", Name: "b.txt", Status: model.StatusActive}, + {ID: "f-3", UserID: "user-2", Name: "c.txt", Status: model.StatusActive}, } for _, f := range files { if err := repo.Create(ctx, f); err != nil { @@ -105,9 +107,9 @@ func TestFileRepository_FindByParentID(t *testing.T) { parentID := "dir-1" files := []*model.File{ - {ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt"}, - {ID: "f-2", UserID: "user-1", ParentID: &parentID, Name: "b.txt"}, - {ID: "f-3", UserID: "user-1", Name: "c.txt"}, + {ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive}, + {ID: "f-2", UserID: "user-1", ParentID: &parentID, Name: "b.txt", Status: model.StatusActive}, + {ID: "f-3", UserID: "user-1", Name: "c.txt", Status: model.StatusActive}, } for _, f := range files { if err := repo.Create(ctx, f); err != nil { @@ -130,8 +132,8 @@ func TestFileRepository_FindByParentIDNull(t *testing.T) { parentID := "dir-1" files := []*model.File{ - {ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt"}, - {ID: "f-2", UserID: "user-1", Name: "root.txt"}, + {ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive}, + {ID: "f-2", UserID: "user-1", Name: "root.txt", Status: model.StatusActive}, } for _, f := range files { if err := repo.Create(ctx, f); err != nil { @@ -152,7 +154,7 @@ func TestFileRepository_Update(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() - file := &model.File{ID: "file-1", UserID: "user-1", Name: "original.txt"} + file := &model.File{ID: "file-1", UserID: "user-1", Name: "original.txt", Status: model.StatusActive} if err := repo.Create(ctx, file); err != nil { t.Fatalf("Create = %v", err) } @@ -179,7 +181,7 @@ func TestFileRepository_Delete(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() - file := &model.File{ID: "file-1", UserID: "user-1", Name: "test.txt"} + file := &model.File{ID: "file-1", UserID: "user-1", Name: "test.txt", Status: model.StatusActive} if err := repo.Create(ctx, file); err != nil { t.Fatalf("Create = %v", err) } @@ -193,3 +195,121 @@ func TestFileRepository_Delete(t *testing.T) { t.Fatalf("expected ErrNotFound after delete, got %v", err) } } + +func TestFileRepository_SoftDelete(t *testing.T) { + repo := setupFileRepo(t) + ctx := context.Background() + + file := &model.File{ + ID: "file-1", + UserID: "user-1", + Name: "test.txt", + Status: model.StatusActive, + } + if err := repo.Create(ctx, file); err != nil { + t.Fatalf("Create = %v", err) + } + + if err := repo.Delete(ctx, "file-1"); err != nil { + t.Fatalf("Delete = %v", err) + } + + _, err := repo.FindByID(ctx, "file-1") + if err != model.ErrNotFound { + t.Fatalf("expected ErrNotFound after soft-delete, got %v", err) + } +} + +func TestFileRepository_StatusFilter(t *testing.T) { + repo := setupFileRepo(t) + ctx := context.Background() + + activeFile := &model.File{ + ID: "f-1", + UserID: "user-1", + Name: "active.txt", + Status: model.StatusActive, + } + deletedFile := &model.File{ + ID: "f-2", + UserID: "user-1", + Name: "deleted.txt", + Status: model.StatusUserDeleted, + } + + if err := repo.Create(ctx, activeFile); err != nil { + t.Fatalf("Create active = %v", err) + } + if err := repo.Create(ctx, deletedFile); err != nil { + t.Fatalf("Create deleted = %v", err) + } + + result, total, err := repo.FindByUserID(ctx, "user-1", 0, 10) + if err != nil { + t.Fatalf("FindByUserID = %v", err) + } + if len(result) != 1 { + t.Errorf("len(result) = %d, want 1", len(result)) + } + if total != 1 { + t.Errorf("total = %d, want 1", total) + } + if result[0].ID != "f-1" { + t.Errorf("result[0].ID = %q, want %q", result[0].ID, "f-1") + } +} + +func TestFileRepository_DeleteIdempotent(t *testing.T) { + repo := setupFileRepo(t) + ctx := context.Background() + + file := &model.File{ + ID: "file-1", + UserID: "user-1", + Name: "test.txt", + Status: model.StatusActive, + } + if err := repo.Create(ctx, file); err != nil { + t.Fatalf("Create = %v", err) + } + + if err := repo.Delete(ctx, "file-1"); err != nil { + t.Fatalf("first Delete = %v", err) + } + if err := repo.Delete(ctx, "file-1"); err != nil { + t.Fatalf("second Delete = %v", err) + } +} + +func TestFileRepository_StatusFilterCount(t *testing.T) { + repo := setupFileRepo(t) + ctx := context.Background() + + activeFile := &model.File{ + ID: "f-1", + UserID: "user-1", + Name: "active.txt", + Status: model.StatusActive, + } + deletedFile := &model.File{ + ID: "f-2", + UserID: "user-1", + Name: "deleted.txt", + Status: model.StatusUserDeleted, + } + + if err := repo.Create(ctx, activeFile); err != nil { + t.Fatalf("Create active = %v", err) + } + if err := repo.Create(ctx, deletedFile); err != nil { + t.Fatalf("Create deleted = %v", err) + } + + _, total, err := repo.FindByUserID(ctx, "user-1", 0, 10) + if err != nil { + t.Fatalf("FindByUserID = %v", err) + } + if total != 1 { + t.Errorf("total = %d, want 1 (soft-deleted excluded from count)", total) + } +} diff --git a/internal/repository/user.go b/internal/repository/user.go index bf573d5..7be09f8 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -25,6 +25,7 @@ type UserRepository interface { Update(ctx context.Context, user *model.User) error Delete(ctx context.Context, id string) error List(ctx context.Context, offset, limit int) ([]model.User, int64, error) + ListIncludeDeleted(ctx context.Context, offset, limit int) ([]model.User, int64, error) } type userRepository struct { @@ -130,3 +131,20 @@ func (r *userRepository) List(ctx context.Context, offset, limit int) ([]model.U return users, total, nil } + +// ListIncludeDeleted returns all users (including soft-deleted), paginated. +func (r *userRepository) ListIncludeDeleted(ctx context.Context, offset, limit int) ([]model.User, int64, error) { + var users []model.User + var total int64 + + if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + result := r.db.WithContext(ctx).Offset(offset).Limit(limit).Find(&users) + if result.Error != nil { + return nil, 0, result.Error + } + + return users, total, nil +} From ac98b5ddbd68fcada35539cb2c4fa75aae7417f4 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 17:20:30 +0800 Subject: [PATCH 14/31] feat(middleware): add AdminRequired authorization middleware AdminRequired gates admin endpoints by checking user IsAdmin flag. Placed AFTER AuthRequired; fetches user from repository, returns 403 for non-admins. Soft-deleted users are rejected with 401 since FindByID excludes them. Tests: admin passes, non-admin forbidden, soft-deleted admin rejected, missing user ID. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/middleware/auth.go | 31 ++++++ internal/middleware/auth_test.go | 165 +++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index bd7ab05..20ba4cd 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -8,6 +8,7 @@ import ( "github.com/dhao2001/mygo/internal/api" "github.com/dhao2001/mygo/internal/auth" + "github.com/dhao2001/mygo/internal/repository" ) const userIDKey = "user_id" @@ -68,3 +69,33 @@ func MustGetUserID(c *gin.Context) string { } return userID } + +// AdminRequired returns a Gin middleware that gates access to admin-only +// endpoints. It must be applied AFTER AuthRequired. It fetches the user from +// the repository, and returns 403 if the user is not an admin. Soft-deleted +// users are not found by FindByID and will receive a 401 response. +func AdminRequired(userRepo repository.UserRepository) gin.HandlerFunc { + return func(c *gin.Context) { + userID := GetUserID(c) + if userID == "" { + api.Error(c, http.StatusUnauthorized, "missing user context") + c.Abort() + return + } + + user, err := userRepo.FindByID(c.Request.Context(), userID) + if err != nil { + api.Error(c, http.StatusUnauthorized, "user not found") + c.Abort() + return + } + + if !user.IsAdmin { + api.Error(c, http.StatusForbidden, "admin access required") + c.Abort() + return + } + + c.Next() + } +} diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go index 08da150..5c1835d 100644 --- a/internal/middleware/auth_test.go +++ b/internal/middleware/auth_test.go @@ -1,6 +1,8 @@ package middleware import ( + "context" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -8,8 +10,12 @@ import ( "time" "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" "github.com/dhao2001/mygo/internal/auth" + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/repository" ) func setupTestRouter(secret []byte) *gin.Engine { @@ -196,3 +202,162 @@ func TestAuthRequiredWrongSecret(t *testing.T) { t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } } + +// --- AdminRequired tests --- + +// errorBody extracts the error.message field from a JSON response body. +type errorBody struct { + Error struct { + Message string `json:"message"` + } `json:"error"` +} + +func setupAdminTestDB(t *testing.T) repository.UserRepository { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.User{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + return repository.NewUserRepository(db) +} + +// injectUserIDMiddleware simulates AuthRequired by injecting a user_id into the context. +func injectUserIDMiddleware(userID string) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set(userIDKey, userID) + c.Next() + } +} + +func TestAdminRequired_AdminPasses(t *testing.T) { + repo := setupAdminTestDB(t) + ctx := context.Background() + + user := &model.User{ + ID: "admin-1", + Username: "admin", + Email: "admin@example.com", + IsAdmin: true, + Status: model.StatusActive, + } + if err := repo.Create(ctx, user); err != nil { + t.Fatalf("Create = %v", err) + } + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(injectUserIDMiddleware("admin-1")) + r.Use(AdminRequired(repo)) + r.GET("/admin", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/admin", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestAdminRequired_NonAdminForbidden(t *testing.T) { + repo := setupAdminTestDB(t) + ctx := context.Background() + + user := &model.User{ + ID: "user-1", + Username: "regular", + Email: "regular@example.com", + IsAdmin: false, + Status: model.StatusActive, + } + if err := repo.Create(ctx, user); err != nil { + t.Fatalf("Create = %v", err) + } + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(injectUserIDMiddleware("user-1")) + r.Use(AdminRequired(repo)) + r.GET("/admin", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/admin", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden) + } + + var body errorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.Error.Message != "admin access required" { + t.Errorf("message = %q, want %q", body.Error.Message, "admin access required") + } +} + +func TestAdminRequired_SoftDeletedAdmin(t *testing.T) { + repo := setupAdminTestDB(t) + ctx := context.Background() + + user := &model.User{ + ID: "admin-2", + Username: "deleted_admin", + Email: "deleted_admin@example.com", + IsAdmin: true, + Status: model.StatusActive, + } + if err := repo.Create(ctx, user); err != nil { + t.Fatalf("Create = %v", err) + } + + // Soft-delete the admin user + if err := repo.Delete(ctx, "admin-2"); err != nil { + t.Fatalf("Delete = %v", err) + } + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(injectUserIDMiddleware("admin-2")) + r.Use(AdminRequired(repo)) + r.GET("/admin", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/admin", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} + +func TestAdminRequired_NoUserID(t *testing.T) { + repo := setupAdminTestDB(t) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(AdminRequired(repo)) + r.GET("/admin", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/admin", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} From e04e39bea8a03e785a92a08187f7b6ca60c545c5 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 17:20:34 +0800 Subject: [PATCH 15/31] feat(svc): implement soft-delete in FileService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete now performs soft-delete (status='user_deleted') instead of physical deletion. Removes storage content deletion β€” files are preserved on disk after soft-delete. Second delete on already-deleted file returns nil (idempotent). Add tests: SoftDelete, SoftDeleteKeepsStorage, SoftDeleteIdempotent, SoftDeleteForbidden. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/service/file.go | 16 +++---- internal/service/file_test.go | 86 ++++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/internal/service/file.go b/internal/service/file.go index 30b8198..8a720d4 100644 --- a/internal/service/file.go +++ b/internal/service/file.go @@ -129,6 +129,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin MimeType: mimeType, StoragePath: storagePath, Hash: hex.EncodeToString(hasher.Sum(nil)), + Status: model.StatusActive, IsDir: false, } @@ -240,10 +241,14 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName return modelToFileInfo(file), nil } -// Delete removes a file or (empty) directory. +// Delete soft-deletes a file or (empty) directory. Storage content is preserved. func (s *FileService) Delete(ctx context.Context, userID, fileID string) error { file, err := s.getOwnedFile(ctx, userID, fileID) if err != nil { + var ae *model.AppError + if errors.As(err, &ae) && errors.Is(ae.Err, model.ErrNotFound) { + return nil // idempotent: already deleted + } return err } @@ -262,14 +267,6 @@ func (s *FileService) Delete(ctx context.Context, userID, fileID string) error { return model.NewInternalError("delete file record", err) } - // Remove content from storage (directories have no stored content). - if !file.IsDir { - if err := s.storage.Delete(ctx, file.StoragePath); err != nil { - s.logger.WarnContext(ctx, "failed to delete file from storage", - "path", file.StoragePath, "error", err) - } - } - return nil } @@ -297,6 +294,7 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st UserID: userID, ParentID: parentID, Name: dirName, + Status: model.StatusActive, IsDir: true, } diff --git a/internal/service/file_test.go b/internal/service/file_test.go index be1f147..b14fc2a 100644 --- a/internal/service/file_test.go +++ b/internal/service/file_test.go @@ -73,7 +73,7 @@ func (s *memStorage) Delete(_ context.Context, path string) error { var _ storage.StorageBackend = (*memStorage)(nil) -func setupFileService(t *testing.T) *FileService { +func setupFileServiceWithStorage(t *testing.T) (*FileService, *memStorage) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -87,7 +87,12 @@ func setupFileService(t *testing.T) *FileService { fileRepo := repository.NewFileRepository(db) store := newMemStorage() - return NewFileService(fileRepo, store, 0, nil) // 0 = unlimited, nil logger defaults to slog.Default() + return NewFileService(fileRepo, store, 0, nil), store // 0 = unlimited, nil logger defaults to slog.Default() +} + +func setupFileService(t *testing.T) *FileService { + svc, _ := setupFileServiceWithStorage(t) + return svc } func TestFileService_Upload(t *testing.T) { @@ -461,3 +466,80 @@ func TestFileService_VerifyParentForbidden(t *testing.T) { _, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data")) assertAppError(t, err, http.StatusForbidden) } + +func TestFileService_SoftDelete(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + info, err := svc.Upload(ctx, "user1", nil, "soft-delete.txt", strings.NewReader("data")) + if err != nil { + t.Fatalf("Upload = %v", err) + } + + if err := svc.Delete(ctx, "user1", info.ID); err != nil { + t.Fatalf("Delete = %v", err) + } + + _, err = svc.Get(ctx, "user1", info.ID) + assertAppError(t, err, http.StatusNotFound) +} + +func TestFileService_SoftDeleteKeepsStorage(t *testing.T) { + svc, store := setupFileServiceWithStorage(t) + ctx := context.Background() + + content := "keep me after soft-delete" + info, err := svc.Upload(ctx, "user1", nil, "keep.txt", strings.NewReader(content)) + if err != nil { + t.Fatalf("Upload = %v", err) + } + + if err := svc.Delete(ctx, "user1", info.ID); err != nil { + t.Fatalf("Delete = %v", err) + } + + storagePath := "user1/" + info.ID + reader, err := store.Open(ctx, storagePath) + if err != nil { + t.Fatalf("storage should retain content after soft-delete, got: %v", err) + } + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read storage: %v", err) + } + if string(data) != content { + t.Errorf("storage content = %q, want %q", string(data), content) + } +} + +func TestFileService_SoftDeleteIdempotent(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + info, err := svc.Upload(ctx, "user1", nil, "idem.txt", strings.NewReader("data")) + if err != nil { + t.Fatalf("Upload = %v", err) + } + + if err := svc.Delete(ctx, "user1", info.ID); err != nil { + t.Fatalf("first Delete = %v", err) + } + if err := svc.Delete(ctx, "user1", info.ID); err != nil { + t.Fatalf("second Delete should be idempotent, got: %v", err) + } +} + +func TestFileService_SoftDeleteForbidden(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + info, err := svc.Upload(ctx, "user1", nil, "forbidden.txt", strings.NewReader("data")) + if err != nil { + t.Fatalf("Upload = %v", err) + } + + err = svc.Delete(ctx, "user2", info.ID) + assertAppError(t, err, http.StatusForbidden) +} From 3daad379e01640c304d7597c31cde6842af3c6e2 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 17:23:35 +0800 Subject: [PATCH 16/31] test(server): add admin route integration test --- internal/server/server_test.go | 124 +++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 99ccc11..6012a11 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,6 +1,8 @@ package server import ( + "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -8,7 +10,9 @@ import ( "time" "github.com/dhao2001/mygo/internal/app" + "github.com/dhao2001/mygo/internal/auth" "github.com/dhao2001/mygo/internal/config" + "github.com/dhao2001/mygo/internal/repository" "github.com/dhao2001/mygo/internal/service" ) @@ -56,3 +60,123 @@ func TestAddress(t *testing.T) { t.Errorf("Address() = %q, want %q", got, want) } } + +func TestAdminRoutes(t *testing.T) { + // Setup SQLite :memory: + dbCfg := config.DatabaseConfig{ + Driver: "sqlite3", + SQLite: config.SQLiteConfig{Path: ":memory:"}, + } + db, err := repository.Open(dbCfg) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := repository.AutoMigrate(db); err != nil { + t.Fatalf("migrate: %v", err) + } + + userRepo := repository.NewUserRepository(db) + sessionRepo := repository.NewSessionRepository(db) + credentialRepo := repository.NewCredentialRepository(db) + + jwtSecret := []byte("test-secret") + accessTTL := 15 * time.Minute + refreshTTL := 168 * time.Hour + + authService := service.NewAuthService(userRepo, sessionRepo, credentialRepo, jwtSecret, accessTTL, refreshTTL) + + cfg := &config.Config{ + JWT: config.JWTConfig{ + Secret: "test-secret", + AccessTTL: accessTTL, + RefreshTTL: refreshTTL, + }, + } + + webApp := app.NewWebApp(cfg, db, userRepo, sessionRepo, nil, credentialRepo, authService, nil, nil) + router := NewRouter(webApp) + + // Helper: register a user via POST /api/v1/auth/register and return the user ID. + register := func(username, email, password string) string { + body, _ := json.Marshal(map[string]string{ + "username": username, + "email": email, + "password": password, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("register %s: status=%d, body=%s", username, rec.Code, rec.Body.String()) + } + var user struct { + ID string `json:"id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &user); err != nil { + t.Fatalf("unmarshal register response: %v", err) + } + return user.ID + } + + // Register admin user and promote to admin. + adminID := register("admin", "admin@test.local", "adminpass1") + adminUser, err := userRepo.FindByID(context.Background(), adminID) + if err != nil { + t.Fatalf("find admin user: %v", err) + } + adminUser.IsAdmin = true + if err := userRepo.Update(context.Background(), adminUser); err != nil { + t.Fatalf("promote to admin: %v", err) + } + + // Generate admin JWT. + adminToken, err := auth.GenerateAccessToken(adminID, jwtSecret, accessTTL) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + // Register a regular (non-admin) user. + regularID := register("regular", "regular@test.local", "regularpass1") + regularToken, err := auth.GenerateAccessToken(regularID, jwtSecret, accessTTL) + if err != nil { + t.Fatalf("generate regular token: %v", err) + } + + // Register a victim user to delete. + victimID := register("victim", "victim@test.local", "victimpass1") + + // Helper: make a DELETE request with optional auth header. + deleteUser := func(userID, token string) *httptest.ResponseRecorder { + url := "/api/v1/admin/users/" + userID + req := httptest.NewRequest(http.MethodDelete, url, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + + // Test 1: Admin can delete a user β†’ 204. + rec := deleteUser(victimID, adminToken) + if rec.Code != http.StatusNoContent { + t.Errorf("admin delete: status=%d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) + } + + // Register a second victim for the remaining tests (first victim is soft-deleted). + victim2ID := register("victim2", "victim2@test.local", "victim2pass1") + + // Test 2: Non-admin JWT β†’ 403. + rec = deleteUser(victim2ID, regularToken) + if rec.Code != http.StatusForbidden { + t.Errorf("non-admin delete: status=%d, want %d, body=%s", rec.Code, http.StatusForbidden, rec.Body.String()) + } + + // Test 3: No auth β†’ 401. + rec = deleteUser(victim2ID, "") + if rec.Code != http.StatusUnauthorized { + t.Errorf("no-auth delete: status=%d, want %d, body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) + } +} From 7b6587a951e8a3e3511617239c7b5f15a0dbf28c Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 17:31:01 +0800 Subject: [PATCH 17/31] test(handler): TDD tests for AdminHandler user CRUD and visibility Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/handler/admin_test.go | 330 +++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 internal/handler/admin_test.go diff --git a/internal/handler/admin_test.go b/internal/handler/admin_test.go new file mode 100644 index 0000000..76c50fb --- /dev/null +++ b/internal/handler/admin_test.go @@ -0,0 +1,330 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/dhao2001/mygo/internal/middleware" + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/repository" + "github.com/dhao2001/mygo/internal/service" +) + +func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.User{}, &model.Session{}, &model.Credential{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + secret := []byte("test-secret") + userRepo := repository.NewUserRepository(db) + authService := service.NewAuthService( + userRepo, + repository.NewSessionRepository(db), + repository.NewCredentialRepository(db), + secret, + 15*time.Minute, + 7*24*time.Hour, + ) + + authHandler := NewAuthHandler(authService) + adminHandler := NewAdminHandler(userRepo) + + gin.SetMode(gin.TestMode) + r := gin.New() + + auth := r.Group("/api/v1/auth") + { + auth.POST("/register", authHandler.Register) + auth.POST("/login", authHandler.Login) + } + + admin := r.Group("/api/v1/admin") + admin.Use(middleware.AuthRequired(secret)) + admin.Use(middleware.AdminRequired(userRepo)) + { + admin.GET("/users", adminHandler.ListUsers) + admin.GET("/users/:id", adminHandler.GetUser) + admin.DELETE("/users/:id", adminHandler.DeleteUser) + } + + return r, userRepo +} + +// registerHelper creates a user via the HTTP endpoint and returns the user ID. +func registerHelper(t *testing.T, r *gin.Engine, username, email, password string) string { + t.Helper() + + body, _ := json.Marshal(gin.H{ + "username": username, + "email": email, + "password": password, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("register %s: status = %d, body = %s", username, rec.Code, rec.Body.String()) + } + + var user model.User + if err := json.Unmarshal(rec.Body.Bytes(), &user); err != nil { + t.Fatalf("unmarshal register response: %v", err) + } + return user.ID +} + +// loginHelper logs in a user and returns the access token. +func loginHelper(t *testing.T, r *gin.Engine, email, password string) string { + t.Helper() + + body, _ := json.Marshal(gin.H{"email": email, "password": password}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("login %s: status = %d, body = %s", email, rec.Code, rec.Body.String()) + } + + var pair service.TokenPair + if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil { + t.Fatalf("unmarshal login response: %v", err) + } + return pair.AccessToken +} + +// makeAdminHelper promotes a user to admin in the database. +func makeAdminHelper(t *testing.T, userRepo repository.UserRepository, userID string) { + t.Helper() + + user, err := userRepo.FindByID(context.Background(), userID) + if err != nil { + t.Fatalf("find user %s: %v", userID, err) + } + user.IsAdmin = true + if err := userRepo.Update(context.Background(), user); err != nil { + t.Fatalf("update user: %v", err) + } +} + +func TestAdminDeleteUser(t *testing.T) { + r, userRepo := setupAdminRouter(t) + + adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123") + makeAdminHelper(t, userRepo, adminID) + + userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123") + + adminToken := loginHelper(t, r, "admin@example.com", "adminpass123") + + // Delete regular user as admin + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/"+userID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Errorf("delete: status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String()) + } + + // Verify regular user cannot login (soft-deleted) + loginBody, _ := json.Marshal(gin.H{"email": "bob@example.com", "password": "bobpass123"}) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("login after soft-delete: status = %d, want %d; body = %s", rec.Code, http.StatusUnauthorized, rec.Body.String()) + } +} + +func TestAdminDeleteUserForbidden(t *testing.T) { + r, userRepo := setupAdminRouter(t) + + adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123") + makeAdminHelper(t, userRepo, adminID) + + userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123") + + // Login as regular user (non-admin) + userToken := loginHelper(t, r, "bob@example.com", "bobpass123") + + // Attempt to delete as non-admin + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/"+userID, nil) + req.Header.Set("Authorization", "Bearer "+userToken) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusForbidden, rec.Body.String()) + } +} + +func TestAdminDeleteUserUnauthorized(t *testing.T) { + r, _ := setupAdminRouter(t) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/some-id", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusUnauthorized, rec.Body.String()) + } +} + +func TestAdminGetUser(t *testing.T) { + r, userRepo := setupAdminRouter(t) + + adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123") + makeAdminHelper(t, userRepo, adminID) + + userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123") + + adminToken := loginHelper(t, r, "admin@example.com", "adminpass123") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/"+userID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("get user: status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + IsAdmin bool `json:"is_admin"` + Status string `json:"status"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if resp.Username != "bob" { + t.Errorf("username = %q, want %q", resp.Username, "bob") + } + if resp.Status != model.StatusActive { + t.Errorf("status = %q, want %q", resp.Status, model.StatusActive) + } +} + +func TestAdminGetDeletedUser(t *testing.T) { + r, userRepo := setupAdminRouter(t) + + adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123") + makeAdminHelper(t, userRepo, adminID) + + userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123") + + // Soft-delete regular user directly via repo + if err := userRepo.Delete(context.Background(), userID); err != nil { + t.Fatalf("soft-delete user: %v", err) + } + + adminToken := loginHelper(t, r, "admin@example.com", "adminpass123") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/"+userID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("get deleted user: status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Status string `json:"status"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if resp.Status != model.StatusAdminDeleted { + t.Errorf("status = %q, want %q", resp.Status, model.StatusAdminDeleted) + } +} + +func TestAdminListIncludesDeleted(t *testing.T) { + r, userRepo := setupAdminRouter(t) + + adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123") + makeAdminHelper(t, userRepo, adminID) + + // Create 2 regular users + _ = registerHelper(t, r, "alice", "alice@example.com", "alicepass123") + bobID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123") + + // Soft-delete bob + if err := userRepo.Delete(context.Background(), bobID); err != nil { + t.Fatalf("soft-delete user: %v", err) + } + + adminToken := loginHelper(t, r, "admin@example.com", "adminpass123") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("list users: status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Users []struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"users"` + Total int64 `json:"total"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // Total should be 3: admin + alice (active) + bob (soft-deleted) + if resp.Total != 3 { + t.Errorf("total = %d, want %d", resp.Total, 3) + } + + // Verify both active and soft-deleted users appear + foundAlice := false + foundBob := false + for _, u := range resp.Users { + switch u.Status { + case model.StatusActive: + // alice or admin + foundAlice = true + case model.StatusAdminDeleted: + if u.ID == bobID { + foundBob = true + } + } + } + if !foundAlice { + t.Error("active user not found in list") + } + if !foundBob { + t.Error("soft-deleted user not found in list") + } +} From 5eeb37389bfb440ab7d787d8b35d33cf6a916c96 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sun, 5 Jul 2026 13:25:44 +0800 Subject: [PATCH 18/31] fix(config): harden default JWT secret Replace known development JWT secret placeholders with an ephemeral runtime secret during config loading. Return LoadInfo so startup can warn when token persistence depends on a stable configured secret. Update config docs and tests for default, legacy placeholder, custom, env, and empty secret behavior. --- cmd/serve.go | 7 +++- config.example.yaml | 4 ++- docs/decisions.md | 2 ++ docs/development.md | 7 +++- internal/config/load.go | 61 +++++++++++++++++++++++++++++---- internal/config/load_test.go | 66 ++++++++++++++++++++++++++++++++---- 6 files changed, 131 insertions(+), 16 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index ae94f44..2426de5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -23,7 +23,7 @@ var serveCmd = &cobra.Command{ Short: "Start the MyGO HTTP server", RunE: func(cmd *cobra.Command, args []string) error { v := config.New() - cfg, err := config.Load(v, serveConfigFile) + cfg, loadInfo, err := config.Load(v, serveConfigFile) if err != nil { return fmt.Errorf("load config: %w", err) } @@ -32,6 +32,11 @@ var serveCmd = &cobra.Command{ appLogger := mygolog.NewLogger(cfg.Log) slog.SetDefault(appLogger) slog.Info("mygo server starting") + if loadInfo.EphemeralJWTSecret { + slog.Warn("jwt.secret is a development placeholder; using an ephemeral runtime secret. " + + "Set a stable jwt.secret or MYGO_JWT_SECRET for production, multi-instance deployments, " + + "and token persistence across restarts") + } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/config.example.yaml b/config.example.yaml index 47340f1..d9a652b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -28,6 +28,8 @@ log: file_level: debug # file: debug, info, warn, error jwt: - secret: change-me-in-production + # Development placeholder. MyGO replaces this with an ephemeral runtime + # secret at startup; set a stable value for production. + secret: dev-secret-do-not-use-in-production access_ttl: 15m refresh_ttl: 168h diff --git a/docs/decisions.md b/docs/decisions.md index 97574e2..2dd4732 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -59,9 +59,11 @@ |----------|----------| | One handler per route group | `AuthHandler` owns `/auth/*` (public); `AccountHandler` owns `/account/*` (protected). A route group maps 1:1 to a handler type. | | JWT `type` claim | `Claims.Type` distinguishes access from refresh tokens. Middleware and service enforce the correct type at their respective boundaries. `ParseToken` does no type check β€” it verifies cryptographic validity only. | +| Default JWT secret hardening | The development placeholder `jwt.secret` is replaced with an ephemeral runtime secret during config loading. Production and multi-instance deployments must set a stable secret. | | `time.Duration` in config structs | Config fields representing durations use `time.Duration` directly. Viper's built-in `StringToTimeDurationHookFunc` handles stringβ†’Duration conversion at unmarshal time. No accessor methods, no runtime parsing. Invalid values fail at startup via `Load()`. | **Consequences**: - Handlers are independently extensible (caching, rate limiting scoped per handler). - Refresh tokens cannot authenticate API requests; access tokens cannot be used to issue new token pairs. +- The placeholder JWT secret is safe for local startup, but tokens signed with it are invalidated on restart. - New duration config fields require zero boilerplate β€” declare as `time.Duration` in the struct. diff --git a/docs/development.md b/docs/development.md index a3004cf..644ae61 100644 --- a/docs/development.md +++ b/docs/development.md @@ -52,9 +52,14 @@ storage: path: data/files jwt: - secret: changeme-in-production + secret: dev-secret-do-not-use-in-production access_ttl: 15m refresh_ttl: 168h ``` Environment variables use `MYGO_` prefix with underscore separators: `MYGO_SERVER_PORT=8080`, `MYGO_JWT_SECRET=...` + +The default JWT secret is a development placeholder. At startup, MyGO replaces +it with an ephemeral runtime secret; set a stable `jwt.secret` or +`MYGO_JWT_SECRET` when tokens must survive restarts or when running multiple +instances. diff --git a/internal/config/load.go b/internal/config/load.go index 801382f..c343acf 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -1,6 +1,8 @@ package config import ( + "crypto/rand" + "encoding/base64" "errors" "fmt" "strings" @@ -8,6 +10,17 @@ import ( "github.com/spf13/viper" ) +// DefaultJWTSecret is a development-only placeholder. Load replaces it with +// an ephemeral secret before returning a usable Config. +const DefaultJWTSecret = "dev-secret-do-not-use-in-production" + +// LoadInfo describes runtime decisions made while loading configuration. +type LoadInfo struct { + // EphemeralJWTSecret is true when a placeholder jwt.secret was replaced + // with a random in-memory value for the current process. + EphemeralJWTSecret bool +} + func defaults(v *viper.Viper) { v.SetDefault("server.host", "0.0.0.0") v.SetDefault("server.port", 10086) @@ -24,7 +37,7 @@ func defaults(v *viper.Viper) { v.SetDefault("storage.driver", "local") v.SetDefault("storage.local.path", "data/files") - v.SetDefault("jwt.secret", "dev-secret-do-not-use-in-production") + v.SetDefault("jwt.secret", DefaultJWTSecret) v.SetDefault("jwt.access_ttl", "15m") v.SetDefault("jwt.refresh_ttl", "168h") @@ -42,7 +55,7 @@ func New() *viper.Viper { return v } -func Load(v *viper.Viper, cfgFile string) (*Config, error) { +func Load(v *viper.Viper, cfgFile string) (*Config, LoadInfo, error) { if cfgFile != "" { v.SetConfigFile(cfgFile) } else { @@ -54,18 +67,54 @@ func Load(v *viper.Viper, cfgFile string) (*Config, error) { if err := v.ReadInConfig(); err != nil { var notFound viper.ConfigFileNotFoundError if !errors.As(err, ¬Found) { - return nil, fmt.Errorf("read config: %w", err) + return nil, LoadInfo{}, fmt.Errorf("read config: %w", err) } } var cfg Config if err := v.Unmarshal(&cfg); err != nil { - return nil, fmt.Errorf("unmarshal config: %w", err) + return nil, LoadInfo{}, fmt.Errorf("unmarshal config: %w", err) + } + + info, err := hardenRuntimeSecrets(&cfg) + if err != nil { + return nil, LoadInfo{}, err } if err := cfg.Validate(); err != nil { - return nil, fmt.Errorf("validate config: %w", err) + return nil, LoadInfo{}, fmt.Errorf("validate config: %w", err) } - return &cfg, nil + return &cfg, info, nil +} + +func hardenRuntimeSecrets(cfg *Config) (LoadInfo, error) { + if !isWeakJWTSecret(cfg.JWT.Secret) { + return LoadInfo{}, nil + } + + secret, err := generateEphemeralJWTSecret() + if err != nil { + return LoadInfo{}, fmt.Errorf("generate ephemeral jwt secret: %w", err) + } + + cfg.JWT.Secret = secret + return LoadInfo{EphemeralJWTSecret: true}, nil +} + +func isWeakJWTSecret(secret string) bool { + switch secret { + case DefaultJWTSecret, "change-me-in-production", "changeme-in-production": + return true + default: + return false + } +} + +func generateEphemeralJWTSecret() (string, error) { + var secret [32]byte + if _, err := rand.Read(secret[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(secret[:]), nil } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 24e75a6..43d632b 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -9,7 +9,7 @@ import ( func TestDefaults(t *testing.T) { v := New() - cfg, err := Load(v, "") + cfg, info, err := Load(v, "") if err != nil { t.Fatal(err) } @@ -36,6 +36,16 @@ func TestDefaults(t *testing.T) { } }) } + + if !info.EphemeralJWTSecret { + t.Fatal("expected default jwt.secret to be replaced with an ephemeral secret") + } + if cfg.JWT.Secret == DefaultJWTSecret { + t.Fatal("default jwt.secret was not replaced") + } + if cfg.JWT.Secret == "" { + t.Fatal("ephemeral jwt.secret is empty") + } } func TestFromYAML(t *testing.T) { @@ -67,10 +77,13 @@ jwt: } v := New() - cfg, err := Load(v, path) + cfg, info, err := Load(v, path) if err != nil { t.Fatal(err) } + if info.EphemeralJWTSecret { + t.Fatal("custom jwt.secret should not be replaced") + } if cfg.Server.Host != "127.0.0.1" { t.Errorf("server.host = %q, want %q", cfg.Server.Host, "127.0.0.1") @@ -102,10 +115,13 @@ func TestEnvOverride(t *testing.T) { t.Setenv("MYGO_DATABASE_SQLITE_PATH", "/env/path/db.sqlite") v := New() - cfg, err := Load(v, "") + cfg, info, err := Load(v, "") if err != nil { t.Fatal(err) } + if info.EphemeralJWTSecret { + t.Fatal("env jwt.secret should not be replaced") + } if cfg.Server.Port != 8080 { t.Errorf("server.port = %d, want %d", cfg.Server.Port, 8080) @@ -137,7 +153,7 @@ server: t.Setenv("MYGO_SERVER_PORT", "9999") v := New() - cfg, err := Load(v, path) + cfg, _, err := Load(v, path) if err != nil { t.Fatal(err) } @@ -164,7 +180,7 @@ server: } v := New() - _, err := Load(v, path) + _, _, err := Load(v, path) if err == nil { t.Fatal("expected error for port 0, got nil") } @@ -183,7 +199,7 @@ jwt: } v := New() - _, err := Load(v, path) + _, _, err := Load(v, path) if err == nil { t.Fatal("expected error for empty jwt.secret, got nil") } @@ -191,12 +207,48 @@ jwt: func TestExplicitConfigFileNotFound(t *testing.T) { v := New() - _, err := Load(v, "/nonexistent/path/config.yaml") + _, _, err := Load(v, "/nonexistent/path/config.yaml") if err == nil { t.Fatal("expected error when explicitly specifying a nonexistent config file") } } +func TestWeakJWTSecretsAreReplaced(t *testing.T) { + tests := []string{ + DefaultJWTSecret, + "change-me-in-production", + "changeme-in-production", + } + + for _, secret := range tests { + t.Run(secret, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := "jwt:\n secret: " + secret + "\n" + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + v := New() + cfg, info, err := Load(v, path) + if err != nil { + t.Fatal(err) + } + + if !info.EphemeralJWTSecret { + t.Fatal("expected weak jwt.secret to be replaced") + } + if cfg.JWT.Secret == secret { + t.Fatal("weak jwt.secret was not replaced") + } + if cfg.JWT.Secret == "" { + t.Fatal("ephemeral jwt.secret is empty") + } + }) + } +} + func TestJWTConfigAccessDuration(t *testing.T) { j := JWTConfig{AccessTTL: 15 * time.Minute} if j.AccessTTL != 15*time.Minute { From 17e346836c83e78a3d2f6e67874e18877fe7ac28 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sun, 5 Jul 2026 13:54:16 +0800 Subject: [PATCH 19/31] docs(AGENTS.md): add Git Version Control section --- AGENTS.md | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 27d43df..1bd3557 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,46 @@ | `docs/roadmap.md` | Every task | Completing a feature | | `docs/development.md` | Build/test/debug setup | Changing workflow | +## Git Version Control + +- Do not create a commit unless the user explicitly asks for one. +- Before any commit, verify the work with the required project checks. For code changes, run `go vet ./... && go test ./...`; for docs-only changes, run the most relevant non-mutating checks available. +- Create a commit only when all required checks pass and the current implementation area has no unresolved issues. +- If a known failing check or unresolved issue belongs to another module and is outside the current task, report it clearly before asking for commit approval. +- Before running `git commit`, write the complete commit message first, show it to the user, and ask for explicit approval. Do not commit until the user approves that exact message. +- Never include `Co-authored-by`, generated-tool signatures, or external attribution trailers unless the user explicitly asks for them. + +### Commit Message Format + +Use Conventional Commits: + +```text +[optional scope]: + + +``` + +- Choose the most accurate `type`: `fix`, `feat`, `build`, `docs`, `refactor`, or `test`. +- Add `!` after `type` or `scope` when the change contains a breaking API change. +- Keep the title to one concise sentence describing the main change. +- Write the body as bullet points grouped by change category. Each bullet starts with a typed prefix such as `feat:`, `fix:`, `test:`, `docs:`, `refactor:`, or `build:`. +- Use one blank line between the title and body, and one blank line between body bullets. +- Do not paste full code sentences into the message body; summarize behavior and intent. + +Example: + +```text +feat(middleware): add AdminRequired authorization middleware + +- feat: AdminRequired gates admin endpoints by checking user IsAdmin flag. + +- feat: Placed after AuthRequired; fetches user from repository, returns 403 for non-admins. + +- fix: soft-deleted users are rejected with 401 since FindByID excludes them. + +- test: admin passes, non-admin forbidden, soft-deleted admin rejected, missing user ID. +``` + ## Commands ```bash @@ -52,7 +92,7 @@ go mod tidy # clean deps after add/remove - DO add all Go module dependencies **before** writing code that uses them - DON'T read `go.sum` entirely into context β€” use `grep` or other tools to search specific patterns if needed - DON'T skip `go vet ./...` before finishing work -- DON'T commit without explicit user request +- DON'T commit without following the Git Version Control rules above - DON'T add, remove, or change Go module dependencies after debugging has started β€” ask for explicit permission first ## Debugging Principles From bfeb4b26e4eb28b435bd6ca748ab9ef7aea32b27 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sun, 5 Jul 2026 15:34:22 +0800 Subject: [PATCH 20/31] docs(AGENTS.md): clarify and tighten Git workflow instructions --- AGENTS.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1bd3557..8561963 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,11 +37,11 @@ ## Git Version Control -- Do not create a commit unless the user explicitly asks for one. -- Before any commit, verify the work with the required project checks. For code changes, run `go vet ./... && go test ./...`; for docs-only changes, run the most relevant non-mutating checks available. +- DON'T create a commit unless the user explicitly asks for one. +- Before any commit, verify the work with the required project checks. For code changes, run `go vet ./... && go test ./...`; for docs-only changes, run the most relevant non-mutating checks if available. - Create a commit only when all required checks pass and the current implementation area has no unresolved issues. - If a known failing check or unresolved issue belongs to another module and is outside the current task, report it clearly before asking for commit approval. -- Before running `git commit`, write the complete commit message first, show it to the user, and ask for explicit approval. Do not commit until the user approves that exact message. +- Before running `git commit`, write the complete commit message first, show it to the user, and ask for explicit approval. DON'T commit until the user approves that exact message. - Never include `Co-authored-by`, generated-tool signatures, or external attribution trailers unless the user explicitly asks for them. ### Commit Message Format @@ -49,7 +49,7 @@ Use Conventional Commits: ```text -[optional scope]: +[optional scope][optional !]: ``` @@ -58,17 +58,16 @@ Use Conventional Commits: - Add `!` after `type` or `scope` when the change contains a breaking API change. - Keep the title to one concise sentence describing the main change. - Write the body as bullet points grouped by change category. Each bullet starts with a typed prefix such as `feat:`, `fix:`, `test:`, `docs:`, `refactor:`, or `build:`. +- Use as few as possible bullets. - Use one blank line between the title and body, and one blank line between body bullets. -- Do not paste full code sentences into the message body; summarize behavior and intent. +- DON'T paste full code sentences into the message body; summarize behavior and intent. Example: ```text feat(middleware): add AdminRequired authorization middleware -- feat: AdminRequired gates admin endpoints by checking user IsAdmin flag. - -- feat: Placed after AuthRequired; fetches user from repository, returns 403 for non-admins. +- feat: AdminRequired gates admin endpoints by checking user IsAdmin flag. Placed after AuthRequired; fetches user from repository, returns 403 for non-admins. - fix: soft-deleted users are rejected with 401 since FindByID excludes them. From b988b4b15e7324f42e544da43b991071f9e4611c Mon Sep 17 00:00:00 2001 From: Huxley Date: Sun, 5 Jul 2026 17:18:19 +0800 Subject: [PATCH 21/31] fix(file)!: stream uploads through staged storage - fix: replace multipart form parsing with streaming multipart reads and apply request body limits when max_upload_size is configured. - refactor: route uploads through staging paths before promotion to long-term data paths, keeping incomplete uploads out of durable storage records. - test: cover oversized uploads, unlimited uploads, staged cleanup, and local storage promotion boundaries. - docs: document the staged upload model and multipart parent_id query parameter. --- docs/architecture.md | 6 +-- docs/decisions.md | 19 ++++++++ docs/roadmap.md | 2 +- internal/handler/file.go | 77 ++++++++++++++++++++++++----- internal/handler/file_test.go | 89 ++++++++++++++++++++++++++++++++-- internal/service/file.go | 86 ++++++++++++++++++++++++++++---- internal/service/file_test.go | 55 +++++++++++++++++++-- internal/storage/local.go | 69 +++++++++++++++++++++++++- internal/storage/local_test.go | 86 ++++++++++++++++++++++++++++---- internal/storage/storage.go | 18 ++++--- 10 files changed, 457 insertions(+), 50 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 638a631..b3cf1e9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ Repository (GORM data access) Storage (file I/O) Rules: - Handler has no business logic β€” parse request, call service, write response. - Service has no HTTP awareness β€” operates on domain models and interfaces. -- Repository abstracts the database; Storage abstracts where bytes live. +- Repository abstracts the database; Storage abstracts where bytes live, including staged upload promotion. - `internal/server` is the composition root β€” wires all dependencies together. ## Package Map @@ -34,7 +34,7 @@ Rules: | **Business** | `internal/service` | Business logic: `AuthService` (register, login, refresh, logout, passkey CRUD) | βœ… | | | `internal/model` | Domain types (User, File, Credential, Session), error codes | βœ… | | **Data** | `internal/repository` | Repository interfaces + GORM implementations (User, Session, File, Credential) | βœ… | -| | `internal/storage` | Storage backend interface + local disk impl | πŸ›  WIP | +| | `internal/storage` | Storage backend interface + local disk impl, with staging and promotion | πŸ›  WIP | | **Util** | `internal/auth` | JWT sign/verify (HS256), token type discrimination (access/refresh), password hashing (bcrypt), app passkey tokens | βœ… | | | `internal/api` | Unified JSON error response helpers | βœ… | @@ -54,7 +54,7 @@ POST /api/v1/account/passkeys DELETE /api/v1/account/passkeys/:id GET /api/v1/files -POST /api/v1/files +POST /api/v1/files # JSON creates directories; multipart uploads files. File upload parent_id is a query parameter. GET /api/v1/files/:id GET /api/v1/files/:id/content PUT /api/v1/files/:id diff --git a/docs/decisions.md b/docs/decisions.md index 2dd4732..5785aa5 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -67,3 +67,22 @@ - Refresh tokens cannot authenticate API requests; access tokens cannot be used to issue new token pairs. - The placeholder JWT secret is safe for local startup, but tokens signed with it are invalidated on restart. - New duration config fields require zero boilerplate β€” declare as `time.Duration` in the struct. + +## 2026-07-05: Staged File Uploads + +**Context**: Multipart uploads previously used `ParseMultipartForm`, which parses the request before service-level size checks and may spill oversized requests to temporary disk. The file service also wrote directly to the long-term storage path and then attempted compensating deletes on upload failure. + +**Decisions**: + +| Decision | Guidance | +|----------|----------| +| Stream multipart requests | Handlers use `Request.MultipartReader()` instead of `ParseMultipartForm`/`FormFile`, so uploads are streamed into the service. | +| Optional upload limits | `storage.max_upload_size = 0` means unlimited. Positive values enable both HTTP body limiting and service-level file content limiting. | +| Staging before promotion | Storage backends write upload bytes to a staging path first, then promote the object to the long-term data path only after validation succeeds. | +| Promote before DB create | The service promotes the object before creating the active file record, preventing visible DB rows from pointing at missing objects. If DB creation fails after promotion, the service best-effort deletes the promoted object. | +| Upload parent location | Multipart upload `parent_id` is passed as a query parameter, keeping the multipart body focused on the file stream. | + +**Consequences**: +- Interrupted, malformed, and oversized uploads leave only staging objects, which are safe to clean by path prefix and age. +- Local storage can promote with `os.Rename`; future S3 storage can implement promotion with copy/delete while keeping business visibility controlled by the DB row. +- A DB failure after promotion can still leave a long-term orphan object, but it is not visible through the file API and can be cleaned independently. diff --git a/docs/roadmap.md b/docs/roadmap.md index 2c35b87..c4f456c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -20,7 +20,7 @@ Package-level implementation order (each task includes unit tests): 3. `internal/model` β€” domain types, error codes βœ… 4. `internal/api` β€” error response helpers βœ… 5. `internal/auth` β€” JWT utils βœ… -6. `internal/storage` β€” backend interface + local fs +6. `internal/storage` β€” backend interface + local fs with staged upload promotion 7. `internal/repository` β€” interfaces + GORM/SQLite impl βœ… 8. `internal/service` β€” auth, file, admin services βœ… (auth done) 9. `internal/middleware` β€” logger, cors, auth βœ… (auth done) diff --git a/internal/handler/file.go b/internal/handler/file.go index 99eb1b0..f1c14d6 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -1,10 +1,12 @@ package handler import ( + "errors" "fmt" "io" "log/slog" "mime" + "mime/multipart" "net/http" "net/url" "strconv" @@ -13,10 +15,15 @@ import ( "github.com/dhao2001/mygo/internal/api" "github.com/dhao2001/mygo/internal/middleware" - "github.com/dhao2001/mygo/internal/model" "github.com/dhao2001/mygo/internal/service" ) +const ( + jsonUploadBodyLimit = 64 << 10 + multipartUploadOverhead = 1 << 20 + multipartFileField = "file" +) + // FileHandler handles file management endpoints. type FileHandler struct { fileService *service.FileService @@ -47,9 +54,15 @@ func (h *FileHandler) Upload(c *gin.Context) { // Directory creation via JSON. mediaType, _, _ := mime.ParseMediaType(contentType) if mediaType == "application/json" { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, jsonUploadBodyLimit) + var req createDirRequest if err := c.ShouldBindJSON(&req); err != nil { slog.DebugContext(c.Request.Context(), "create dir bind failed", "error", err) + if isRequestBodyTooLarge(err) { + api.Error(c, http.StatusRequestEntityTooLarge, "request body is too large") + return + } api.Error(c, http.StatusBadRequest, "invalid request body") return } @@ -65,33 +78,45 @@ func (h *FileHandler) Upload(c *gin.Context) { } // File upload via multipart. - if err := c.Request.ParseMultipartForm(32 << 20); err != nil { - slog.DebugContext(c.Request.Context(), "parse multipart form failed", "error", err) - api.Error(c, http.StatusBadRequest, "invalid multipart form") + if mediaType != "multipart/form-data" { + api.Error(c, http.StatusBadRequest, "unsupported content type") return } - parentIDStr := c.PostForm("parent_id") + if maxUploadSize := h.fileService.MaxUploadSize(); maxUploadSize > 0 { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize+multipartUploadOverhead) + } + var parentID *string + parentIDStr := c.Query("parent_id") if parentIDStr != "" { parentID = &parentIDStr } - header, err := c.FormFile("file") + reader, err := c.Request.MultipartReader() if err != nil { - slog.DebugContext(c.Request.Context(), "form file missing", "error", err) - api.Error(c, http.StatusBadRequest, "missing file field") + slog.DebugContext(c.Request.Context(), "create multipart reader failed", "error", err) + if isRequestBodyTooLarge(err) { + api.Error(c, http.StatusRequestEntityTooLarge, "request body is too large") + return + } + api.Error(c, http.StatusBadRequest, "invalid multipart form") return } - file, err := header.Open() + part, fileName, err := nextUploadFilePart(reader) if err != nil { - api.RespondError(c, model.NewInternalError("open uploaded file", err)) + slog.DebugContext(c.Request.Context(), "read multipart file part failed", "error", err) + if isRequestBodyTooLarge(err) { + api.Error(c, http.StatusRequestEntityTooLarge, "request body is too large") + return + } + api.Error(c, http.StatusBadRequest, err.Error()) return } - defer file.Close() + defer part.Close() - info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, header.Filename, file) + info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, fileName, part) if err != nil { api.RespondError(c, err) return @@ -100,6 +125,34 @@ func (h *FileHandler) Upload(c *gin.Context) { c.JSON(http.StatusCreated, info) } +func nextUploadFilePart(reader *multipart.Reader) (*multipart.Part, string, error) { + part, err := reader.NextPart() + if err != nil { + if err == io.EOF { + return nil, "", errors.New("missing file field") + } + return nil, "", err + } + + if part.FormName() != multipartFileField { + part.Close() + return nil, "", fmt.Errorf("unexpected multipart field %q", part.FormName()) + } + + fileName := part.FileName() + if fileName == "" { + part.Close() + return nil, "", errors.New("missing file name") + } + + return part, fileName, nil +} + +func isRequestBodyTooLarge(err error) bool { + var maxBytesErr *http.MaxBytesError + return errors.As(err, &maxBytesErr) +} + // List handles GET /api/v1/files. func (h *FileHandler) List(c *gin.Context) { userID := middleware.MustGetUserID(c) diff --git a/internal/handler/file_test.go b/internal/handler/file_test.go index 3f36118..99dacf2 100644 --- a/internal/handler/file_test.go +++ b/internal/handler/file_test.go @@ -8,6 +8,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -29,7 +30,7 @@ func newInMemStore() *inMemStore { return &inMemStore{files: make(map[string][]byte)} } -func (s *inMemStore) Save(_ context.Context, path string, reader io.Reader) (int64, error) { +func (s *inMemStore) SaveStaged(_ context.Context, path string, reader io.Reader) (int64, error) { data, err := io.ReadAll(reader) if err != nil { return 0, err @@ -38,6 +39,16 @@ func (s *inMemStore) Save(_ context.Context, path string, reader io.Reader) (int return int64(len(data)), nil } +func (s *inMemStore) PromoteStaged(_ context.Context, stagedPath, finalPath string) error { + data, ok := s.files[stagedPath] + if !ok { + return io.ErrUnexpectedEOF + } + s.files[finalPath] = data + delete(s.files, stagedPath) + return nil +} + func (s *inMemStore) Open(_ context.Context, path string) (io.ReadCloser, error) { data, ok := s.files[path] if !ok { @@ -46,6 +57,10 @@ func (s *inMemStore) Open(_ context.Context, path string) (io.ReadCloser, error) return io.NopCloser(bytes.NewReader(data)), nil } +func (s *inMemStore) DeleteStaged(ctx context.Context, path string) error { + return s.Delete(ctx, path) +} + func (s *inMemStore) Delete(_ context.Context, path string) error { delete(s.files, path) return nil @@ -58,6 +73,10 @@ func (e *fsNotFoundError) Error() string { return "file not found on disk: " + e var _ storage.StorageBackend = (*inMemStore)(nil) func setupFileHandler(t *testing.T) *FileHandler { + return setupFileHandlerWithMaxUploadSize(t, 0) +} + +func setupFileHandlerWithMaxUploadSize(t *testing.T, maxUploadSize int64) *FileHandler { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -70,15 +89,19 @@ func setupFileHandler(t *testing.T) *FileHandler { fileRepo := repository.NewFileRepository(db) store := newInMemStore() - fileService := service.NewFileService(fileRepo, store, 0, nil) + fileService := service.NewFileService(fileRepo, store, maxUploadSize, nil) return NewFileHandler(fileService) } func setupFileRouter(t *testing.T) *gin.Engine { + return setupFileRouterWithMaxUploadSize(t, 0) +} + +func setupFileRouterWithMaxUploadSize(t *testing.T, maxUploadSize int64) *gin.Engine { t.Helper() - handler := setupFileHandler(t) + handler := setupFileHandlerWithMaxUploadSize(t, maxUploadSize) gin.SetMode(gin.TestMode) r := gin.New() @@ -133,6 +156,66 @@ func TestFileHandler_Upload(t *testing.T) { } } +func TestFileHandler_UploadRejectsOversizedFile(t *testing.T) { + r := setupFileRouterWithMaxUploadSize(t, 5) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "too-large.txt") + part.Write([]byte("123456")) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String()) + } +} + +func TestFileHandler_UploadAllowsExactMaxUploadSize(t *testing.T) { + r := setupFileRouterWithMaxUploadSize(t, 5) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "exact.txt") + part.Write([]byte("12345")) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String()) + } +} + +func TestFileHandler_UploadUnlimitedWhenMaxUploadSizeIsZero(t *testing.T) { + r := setupFileRouter(t) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "unlimited.txt") + part.Write([]byte(strings.Repeat("a", 128))) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String()) + } +} + func TestFileHandler_UploadNoFile(t *testing.T) { r := setupFileRouter(t) diff --git a/internal/service/file.go b/internal/service/file.go index 8a720d4..74bca17 100644 --- a/internal/service/file.go +++ b/internal/service/file.go @@ -41,6 +41,8 @@ type FileList struct { Total int64 `json:"total"` } +var errUploadTooLarge = errors.New("upload exceeds maximum size") + // FileService handles file management business logic. type FileService struct { fileRepo repository.FileRepository @@ -67,6 +69,12 @@ func NewFileService( } } +// MaxUploadSize returns the configured maximum upload size in bytes. +// A value of 0 means uploads are not size-limited by MyGO. +func (s *FileService) MaxUploadSize() int64 { + return s.maxUploadSize +} + // Upload stores a file's content and creates its metadata record. // The MIME type is always detected server-side from the file content. func (s *FileService) Upload(ctx context.Context, userID string, parentID *string, fileName string, reader io.Reader) (*FileInfo, error) { @@ -87,14 +95,16 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin return nil, model.NewInternalError("check name conflict", err) } - // Limit the reader if a max upload size is configured. if s.maxUploadSize > 0 { - reader = io.LimitReader(reader, s.maxUploadSize+1) + reader = &uploadLimitReader{reader: reader, remaining: s.maxUploadSize} } // Detect MIME type from file content. head := make([]byte, 512) n, readErr := io.ReadFull(reader, head) + if isUploadTooLargeError(readErr) { + return nil, uploadTooLargeError(s.maxUploadSize) + } if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF { return nil, model.NewInternalError("read for mime detection", readErr) } @@ -103,21 +113,30 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin reader = io.MultiReader(bytes.NewReader(head), reader) fileID := uuid.NewString() - storagePath := fmt.Sprintf("%s/%s", userID, fileID) + stagedPath := fmt.Sprintf("staging/%s/%s", userID, uuid.NewString()) + storagePath := fmt.Sprintf("data/%s/%s", userID, fileID) - // Compute SHA-256 hash while writing to storage. + // Compute SHA-256 hash while writing to staging storage. hasher := sha256.New() teeReader := io.TeeReader(reader, hasher) - written, err := s.storage.Save(ctx, storagePath, teeReader) + written, err := s.storage.SaveStaged(ctx, stagedPath, teeReader) if err != nil { - return nil, model.NewInternalError("save file", err) + _ = s.storage.DeleteStaged(ctx, stagedPath) + if isUploadTooLargeError(err) { + return nil, uploadTooLargeError(s.maxUploadSize) + } + return nil, model.NewInternalError("save staged file", err) } if s.maxUploadSize > 0 && written > s.maxUploadSize { - // Clean up the oversized file. - _ = s.storage.Delete(ctx, storagePath) - return nil, &model.AppError{Status: http.StatusBadRequest, Message: fmt.Sprintf("file size exceeds the maximum allowed size of %d bytes", s.maxUploadSize)} + _ = s.storage.DeleteStaged(ctx, stagedPath) + return nil, uploadTooLargeError(s.maxUploadSize) + } + + if err := s.storage.PromoteStaged(ctx, stagedPath, storagePath); err != nil { + _ = s.storage.DeleteStaged(ctx, stagedPath) + return nil, model.NewInternalError("promote staged file", err) } file := &model.File{ @@ -134,7 +153,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin } if err := s.fileRepo.Create(ctx, file); err != nil { - // Compensate: remove the stored file. + // Compensate: remove the promoted file. _ = s.storage.Delete(ctx, storagePath) if errors.Is(err, model.ErrDuplicate) { return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"} @@ -145,6 +164,53 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin return modelToFileInfo(file), nil } +type uploadLimitReader struct { + reader io.Reader + remaining int64 +} + +func (r *uploadLimitReader) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + if r.remaining == 0 { + var one [1]byte + n, err := r.reader.Read(one[:]) + if n > 0 { + p[0] = one[0] + return 1, errUploadTooLarge + } + return 0, err + } + + if int64(len(p)) > r.remaining { + p = p[:r.remaining] + } + + n, err := r.reader.Read(p) + r.remaining -= int64(n) + return n, err +} + +func isUploadTooLargeError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, errUploadTooLarge) { + return true + } + var maxBytesErr *http.MaxBytesError + return errors.As(err, &maxBytesErr) +} + +func uploadTooLargeError(maxUploadSize int64) *model.AppError { + if maxUploadSize > 0 { + return &model.AppError{Status: http.StatusRequestEntityTooLarge, Message: fmt.Sprintf("file size exceeds the maximum allowed size of %d bytes", maxUploadSize)} + } + return &model.AppError{Status: http.StatusRequestEntityTooLarge, Message: "request body is too large"} +} + // Download returns a reader for the file's content and its metadata. // The caller must close the returned ReadCloser. func (s *FileService) Download(ctx context.Context, userID, fileID string) (io.ReadCloser, *FileInfo, error) { diff --git a/internal/service/file_test.go b/internal/service/file_test.go index b14fc2a..debc957 100644 --- a/internal/service/file_test.go +++ b/internal/service/file_test.go @@ -43,7 +43,7 @@ func newMemStorage() *memStorage { return &memStorage{files: make(map[string][]byte)} } -func (s *memStorage) Save(_ context.Context, path string, reader io.Reader) (int64, error) { +func (s *memStorage) SaveStaged(_ context.Context, path string, reader io.Reader) (int64, error) { data, err := io.ReadAll(reader) if err != nil { return 0, err @@ -54,6 +54,19 @@ func (s *memStorage) Save(_ context.Context, path string, reader io.Reader) (int return int64(len(data)), nil } +func (s *memStorage) PromoteStaged(_ context.Context, stagedPath, finalPath string) error { + s.mu.Lock() + defer s.mu.Unlock() + + data, ok := s.files[stagedPath] + if !ok { + return io.ErrUnexpectedEOF + } + s.files[finalPath] = data + delete(s.files, stagedPath) + return nil +} + func (s *memStorage) Open(_ context.Context, path string) (io.ReadCloser, error) { s.mu.RLock() data, ok := s.files[path] @@ -64,6 +77,10 @@ func (s *memStorage) Open(_ context.Context, path string) (io.ReadCloser, error) return io.NopCloser(bytes.NewReader(data)), nil } +func (s *memStorage) DeleteStaged(ctx context.Context, path string) error { + return s.Delete(ctx, path) +} + func (s *memStorage) Delete(_ context.Context, path string) error { s.mu.Lock() delete(s.files, path) @@ -74,6 +91,10 @@ func (s *memStorage) Delete(_ context.Context, path string) error { var _ storage.StorageBackend = (*memStorage)(nil) func setupFileServiceWithStorage(t *testing.T) (*FileService, *memStorage) { + return setupFileServiceWithStorageAndMaxUploadSize(t, 0) +} + +func setupFileServiceWithStorageAndMaxUploadSize(t *testing.T, maxUploadSize int64) (*FileService, *memStorage) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -87,7 +108,7 @@ func setupFileServiceWithStorage(t *testing.T) (*FileService, *memStorage) { fileRepo := repository.NewFileRepository(db) store := newMemStorage() - return NewFileService(fileRepo, store, 0, nil), store // 0 = unlimited, nil logger defaults to slog.Default() + return NewFileService(fileRepo, store, maxUploadSize, nil), store // nil logger defaults to slog.Default() } func setupFileService(t *testing.T) *FileService { @@ -125,6 +146,34 @@ func TestFileService_Upload(t *testing.T) { } } +func TestFileService_UploadRejectsOversizedFileAndCleansStaging(t *testing.T) { + svc, store := setupFileServiceWithStorageAndMaxUploadSize(t, 5) + ctx := context.Background() + + _, err := svc.Upload(ctx, "user1", nil, "too-large.txt", strings.NewReader("123456")) + assertAppError(t, err, http.StatusRequestEntityTooLarge) + + store.mu.RLock() + defer store.mu.RUnlock() + if len(store.files) != 0 { + t.Fatalf("storage files = %#v, want empty after oversized upload", store.files) + } +} + +func TestFileService_UploadUnlimitedWhenMaxUploadSizeIsZero(t *testing.T) { + svc := setupFileService(t) + ctx := context.Background() + + content := strings.Repeat("a", 128) + info, err := svc.Upload(ctx, "user1", nil, "unlimited.txt", strings.NewReader(content)) + if err != nil { + t.Fatalf("Upload = %v", err) + } + if info.Size != int64(len(content)) { + t.Errorf("Size = %d, want %d", info.Size, len(content)) + } +} + func TestFileService_UploadIntoDirectory(t *testing.T) { svc := setupFileService(t) ctx := context.Background() @@ -498,7 +547,7 @@ func TestFileService_SoftDeleteKeepsStorage(t *testing.T) { t.Fatalf("Delete = %v", err) } - storagePath := "user1/" + info.ID + storagePath := "data/user1/" + info.ID reader, err := store.Open(ctx, storagePath) if err != nil { t.Fatalf("storage should retain content after soft-delete, got: %v", err) diff --git a/internal/storage/local.go b/internal/storage/local.go index 61f0cf5..d8e781c 100644 --- a/internal/storage/local.go +++ b/internal/storage/local.go @@ -9,6 +9,11 @@ import ( "strings" ) +const ( + dataPathPrefix = "data" + stagingPathPrefix = "staging" +) + // LocalStorage implements StorageBackend using the local filesystem. type LocalStorage struct { basePath string @@ -24,12 +29,15 @@ func NewLocalStorage(basePath string) (*LocalStorage, error) { return &LocalStorage{basePath: abs}, nil } -// Save writes the contents of reader to path under the storage root. -func (s *LocalStorage) Save(_ context.Context, path string, reader io.Reader) (int64, error) { +// SaveStaged writes the contents of reader to a staging path under the storage root. +func (s *LocalStorage) SaveStaged(_ context.Context, path string, reader io.Reader) (int64, error) { fullPath := filepath.Join(s.basePath, path) if !isSubPath(s.basePath, fullPath) { return 0, fmt.Errorf("storage: path traversal attempt: %s", path) } + if !hasPathPrefix(path, stagingPathPrefix) { + return 0, fmt.Errorf("storage: staged path must be under %s/: %s", stagingPathPrefix, path) + } if err := os.MkdirAll(filepath.Dir(fullPath), 0750); err != nil { return 0, fmt.Errorf("create parent directory: %w", err) @@ -51,6 +59,41 @@ func (s *LocalStorage) Save(_ context.Context, path string, reader io.Reader) (i return written, nil } +// PromoteStaged moves a staged file to its final path under the storage root. +func (s *LocalStorage) PromoteStaged(_ context.Context, stagedPath, finalPath string) error { + fullStagedPath := filepath.Join(s.basePath, stagedPath) + if !isSubPath(s.basePath, fullStagedPath) { + return fmt.Errorf("storage: path traversal attempt: %s", stagedPath) + } + if !hasPathPrefix(stagedPath, stagingPathPrefix) { + return fmt.Errorf("storage: staged path must be under %s/: %s", stagingPathPrefix, stagedPath) + } + + fullFinalPath := filepath.Join(s.basePath, finalPath) + if !isSubPath(s.basePath, fullFinalPath) { + return fmt.Errorf("storage: path traversal attempt: %s", finalPath) + } + if !hasPathPrefix(finalPath, dataPathPrefix) { + return fmt.Errorf("storage: final path must be under %s/: %s", dataPathPrefix, finalPath) + } + + if _, err := os.Stat(fullFinalPath); err == nil { + return fmt.Errorf("final file already exists: %s", finalPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("check final file: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(fullFinalPath), 0750); err != nil { + return fmt.Errorf("create parent directory: %w", err) + } + + if err := os.Rename(fullStagedPath, fullFinalPath); err != nil { + return fmt.Errorf("promote staged file: %w", err) + } + + return nil +} + // Open returns a reader for the file at path under the storage root. func (s *LocalStorage) Open(_ context.Context, path string) (io.ReadCloser, error) { fullPath := filepath.Join(s.basePath, path) @@ -68,6 +111,23 @@ func (s *LocalStorage) Open(_ context.Context, path string) (io.ReadCloser, erro return file, nil } +// DeleteStaged removes a staged file under the storage root. +func (s *LocalStorage) DeleteStaged(_ context.Context, path string) error { + fullPath := filepath.Join(s.basePath, path) + if !isSubPath(s.basePath, fullPath) { + return fmt.Errorf("storage: path traversal attempt: %s", path) + } + if !hasPathPrefix(path, stagingPathPrefix) { + return fmt.Errorf("storage: staged path must be under %s/: %s", stagingPathPrefix, path) + } + + err := os.Remove(fullPath) + if os.IsNotExist(err) { + return nil + } + return err +} + // Delete removes the file at path under the storage root. func (s *LocalStorage) Delete(_ context.Context, path string) error { fullPath := filepath.Join(s.basePath, path) @@ -95,3 +155,8 @@ func isSubPath(base, target string) bool { // filepath.Rel returns ".." or starts with "../" for paths outside base. return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } + +func hasPathPrefix(path, prefix string) bool { + clean := filepath.Clean(path) + return clean == prefix || strings.HasPrefix(clean, prefix+string(filepath.Separator)) +} diff --git a/internal/storage/local_test.go b/internal/storage/local_test.go index a815847..5ee3d77 100644 --- a/internal/storage/local_test.go +++ b/internal/storage/local_test.go @@ -17,15 +17,19 @@ func TestSaveAndOpen(t *testing.T) { ctx := context.Background() content := "hello, mygo storage" - written, err := store.Save(ctx, "test/hello.txt", strings.NewReader(content)) + written, err := store.SaveStaged(ctx, "staging/test/hello.txt", strings.NewReader(content)) if err != nil { - t.Fatalf("Save: %v", err) + t.Fatalf("SaveStaged: %v", err) } if written != int64(len(content)) { t.Errorf("written = %d, want %d", written, len(content)) } - reader, err := store.Open(ctx, "test/hello.txt") + if err := store.PromoteStaged(ctx, "staging/test/hello.txt", "data/test/hello.txt"); err != nil { + t.Fatalf("PromoteStaged: %v", err) + } + + reader, err := store.Open(ctx, "data/test/hello.txt") if err != nil { t.Fatalf("Open: %v", err) } @@ -40,7 +44,7 @@ func TestSaveAndOpen(t *testing.T) { } // Verify the file exists on disk under the base path. - expectedPath := dir + "/test/hello.txt" + expectedPath := dir + "/data/test/hello.txt" if _, err := os.Stat(expectedPath); err != nil { t.Errorf("file not found on disk at %s: %v", expectedPath, err) } @@ -54,16 +58,19 @@ func TestDelete(t *testing.T) { } ctx := context.Background() - _, err = store.Save(ctx, "to_delete.txt", strings.NewReader("delete me")) + _, err = store.SaveStaged(ctx, "staging/to_delete.txt", strings.NewReader("delete me")) if err != nil { - t.Fatalf("Save: %v", err) + t.Fatalf("SaveStaged: %v", err) + } + if err := store.PromoteStaged(ctx, "staging/to_delete.txt", "data/to_delete.txt"); err != nil { + t.Fatalf("PromoteStaged: %v", err) } - if err := store.Delete(ctx, "to_delete.txt"); err != nil { + if err := store.Delete(ctx, "data/to_delete.txt"); err != nil { t.Fatalf("Delete: %v", err) } - _, err = store.Open(ctx, "to_delete.txt") + _, err = store.Open(ctx, "data/to_delete.txt") if err == nil { t.Error("Open should fail after delete") } @@ -91,9 +98,23 @@ func TestPathTraversalRejected(t *testing.T) { } ctx := context.Background() - _, err = store.Save(ctx, "../escape.txt", strings.NewReader("malicious")) + _, err = store.SaveStaged(ctx, "../escape.txt", strings.NewReader("malicious")) if err == nil { - t.Error("Save should reject path traversal") + t.Error("SaveStaged should reject path traversal") + } else if !strings.Contains(err.Error(), "path traversal") { + t.Errorf("expected path traversal error, got: %v", err) + } + + err = store.PromoteStaged(ctx, "../escape.txt", "data/escape.txt") + if err == nil { + t.Error("PromoteStaged should reject staged path traversal") + } else if !strings.Contains(err.Error(), "path traversal") { + t.Errorf("expected path traversal error, got: %v", err) + } + + err = store.PromoteStaged(ctx, "staging/escape.txt", "../escape.txt") + if err == nil { + t.Error("PromoteStaged should reject final path traversal") } else if !strings.Contains(err.Error(), "path traversal") { t.Errorf("expected path traversal error, got: %v", err) } @@ -111,6 +132,51 @@ func TestPathTraversalRejected(t *testing.T) { } else if !strings.Contains(err.Error(), "path traversal") { t.Errorf("expected path traversal error, got: %v", err) } + + err = store.DeleteStaged(ctx, "../escape.txt") + if err == nil { + t.Error("DeleteStaged should reject path traversal") + } else if !strings.Contains(err.Error(), "path traversal") { + t.Errorf("expected path traversal error, got: %v", err) + } +} + +func TestStagedPathsRequireStagingPrefix(t *testing.T) { + dir := t.TempDir() + store, err := NewLocalStorage(dir) + if err != nil { + t.Fatalf("NewLocalStorage: %v", err) + } + ctx := context.Background() + + _, err = store.SaveStaged(ctx, "data/not-staged.txt", strings.NewReader("bad")) + if err == nil { + t.Fatal("SaveStaged should reject non-staging path") + } + + err = store.DeleteStaged(ctx, "data/not-staged.txt") + if err == nil { + t.Fatal("DeleteStaged should reject non-staging path") + } +} + +func TestPromoteRequiresDataFinalPrefix(t *testing.T) { + dir := t.TempDir() + store, err := NewLocalStorage(dir) + if err != nil { + t.Fatalf("NewLocalStorage: %v", err) + } + ctx := context.Background() + + _, err = store.SaveStaged(ctx, "staging/promote.txt", strings.NewReader("content")) + if err != nil { + t.Fatalf("SaveStaged: %v", err) + } + + err = store.PromoteStaged(ctx, "staging/promote.txt", "other/promote.txt") + if err == nil { + t.Fatal("PromoteStaged should reject non-data final path") + } } func TestOpenMissingFile(t *testing.T) { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7350cc6..198ac2a 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -6,18 +6,24 @@ import ( "io" ) -// StorageBackend abstracts where file content is written, read, and deleted. -// Paths passed to all methods are relative to the backend's root and must -// not contain path traversal sequences. +// StorageBackend abstracts where file content is staged, promoted, read, and +// deleted. Paths passed to all methods are relative to the backend's root and +// must not contain path traversal sequences. type StorageBackend interface { - // Save writes the contents of reader to path, creating parent directories - // as needed. It returns the number of bytes written. - Save(ctx context.Context, path string, reader io.Reader) (int64, error) + // SaveStaged writes the contents of reader to a staging path, creating + // parent directories as needed. It returns the number of bytes written. + SaveStaged(ctx context.Context, path string, reader io.Reader) (int64, error) + + // PromoteStaged moves a staged object to its final path. + PromoteStaged(ctx context.Context, stagedPath, finalPath string) error // Open returns a reader for the file at path. The caller must close the // returned ReadCloser. Open(ctx context.Context, path string) (io.ReadCloser, error) + // DeleteStaged removes a staged object. It is idempotent. + DeleteStaged(ctx context.Context, path string) error + // Delete removes the file at path. It is idempotent β€” deleting a // non-existent file is not an error. Delete(ctx context.Context, path string) error From fc2b9312fad9fff8515cdced07d4cd6b9f986387 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sun, 5 Jul 2026 17:18:59 +0800 Subject: [PATCH 22/31] docs: clarify conventional commit body blank-line rule --- AGENTS.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8561963..10e217c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ Use Conventional Commits: - Keep the title to one concise sentence describing the main change. - Write the body as bullet points grouped by change category. Each bullet starts with a typed prefix such as `feat:`, `fix:`, `test:`, `docs:`, `refactor:`, or `build:`. - Use as few as possible bullets. -- Use one blank line between the title and body, and one blank line between body bullets. +- Use one blank line between the title and body. - DON'T paste full code sentences into the message body; summarize behavior and intent. Example: @@ -68,9 +68,7 @@ Example: feat(middleware): add AdminRequired authorization middleware - feat: AdminRequired gates admin endpoints by checking user IsAdmin flag. Placed after AuthRequired; fetches user from repository, returns 403 for non-admins. - - fix: soft-deleted users are rejected with 401 since FindByID excludes them. - - test: admin passes, non-admin forbidden, soft-deleted admin rejected, missing user ID. ``` From 28e17a5b080c35e8f4dff4e079b0f10153d578d4 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sun, 5 Jul 2026 17:35:34 +0800 Subject: [PATCH 23/31] refactor: add AppError helpers and harden upload/download error handling - refactor: replace inline AppError literals with model.New*Error constructors in service and handler layers - feat: PromoteStaged falls back to copy/delete when os.Rename returns EXDEV (cross-device link) - fix: upload error messages no longer leak internal multipart field names or parser implementation details - fix: upload size violations are converted to domain error ErrUploadTooLarge at the handler boundary - fix: download logs error and size mismatch when io.Copy fails or writes fewer bytes than expected - test: add tests for field name leak prevention, parser error sanitization, read errors after response start, and EXDEV fallback --- docs/decisions.md | 3 +- internal/handler/file.go | 63 ++++++++++++++++-- internal/handler/file_test.go | 118 +++++++++++++++++++++++++++++++-- internal/model/errors.go | 34 ++++++++-- internal/service/file.go | 49 ++++++-------- internal/storage/local.go | 40 ++++++++++- internal/storage/local_test.go | 45 +++++++++++++ 7 files changed, 305 insertions(+), 47 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 5785aa5..b68eeb5 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -81,8 +81,9 @@ | Staging before promotion | Storage backends write upload bytes to a staging path first, then promote the object to the long-term data path only after validation succeeds. | | Promote before DB create | The service promotes the object before creating the active file record, preventing visible DB rows from pointing at missing objects. If DB creation fails after promotion, the service best-effort deletes the promoted object. | | Upload parent location | Multipart upload `parent_id` is passed as a query parameter, keeping the multipart body focused on the file stream. | +| Service-layer upload errors | HTTP transport errors are converted at the handler boundary; the file service only handles domain errors such as oversized uploads. | **Consequences**: - Interrupted, malformed, and oversized uploads leave only staging objects, which are safe to clean by path prefix and age. -- Local storage can promote with `os.Rename`; future S3 storage can implement promotion with copy/delete while keeping business visibility controlled by the DB row. +- Local storage promotes with `os.Rename` and falls back to copy/delete on cross-device `EXDEV`; future S3 storage can implement promotion with copy/delete while keeping business visibility controlled by the DB row. - A DB failure after promotion can still leave a long-term orphan object, but it is not visible through the file API and can be cleaned independently. diff --git a/internal/handler/file.go b/internal/handler/file.go index f1c14d6..e53ac5f 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -15,6 +15,7 @@ import ( "github.com/dhao2001/mygo/internal/api" "github.com/dhao2001/mygo/internal/middleware" + "github.com/dhao2001/mygo/internal/model" "github.com/dhao2001/mygo/internal/service" ) @@ -24,6 +25,13 @@ const ( multipartFileField = "file" ) +var ( + errMissingFileField = errors.New("missing file field") + errMissingFileName = errors.New("missing file name") + errUnexpectedMultipartField = errors.New("unexpected multipart field") + errInvalidMultipartForm = errors.New("invalid multipart form") +) + // FileHandler handles file management endpoints. type FileHandler struct { fileService *service.FileService @@ -111,12 +119,12 @@ func (h *FileHandler) Upload(c *gin.Context) { api.Error(c, http.StatusRequestEntityTooLarge, "request body is too large") return } - api.Error(c, http.StatusBadRequest, err.Error()) + api.Error(c, http.StatusBadRequest, uploadPartErrorMessage(err)) return } defer part.Close() - info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, fileName, part) + info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, fileName, uploadBodyReader{reader: part}) if err != nil { api.RespondError(c, err) return @@ -129,30 +137,55 @@ func nextUploadFilePart(reader *multipart.Reader) (*multipart.Part, string, erro part, err := reader.NextPart() if err != nil { if err == io.EOF { - return nil, "", errors.New("missing file field") + return nil, "", errMissingFileField } - return nil, "", err + return nil, "", fmt.Errorf("%w: %v", errInvalidMultipartForm, err) } if part.FormName() != multipartFileField { part.Close() - return nil, "", fmt.Errorf("unexpected multipart field %q", part.FormName()) + return nil, "", fmt.Errorf("%w: %q", errUnexpectedMultipartField, part.FormName()) } fileName := part.FileName() if fileName == "" { part.Close() - return nil, "", errors.New("missing file name") + return nil, "", errMissingFileName } return part, fileName, nil } +func uploadPartErrorMessage(err error) string { + switch { + case errors.Is(err, errMissingFileField): + return "missing file field" + case errors.Is(err, errMissingFileName): + return "missing file name" + case errors.Is(err, errUnexpectedMultipartField): + return "unexpected multipart field" + default: + return "invalid multipart form" + } +} + func isRequestBodyTooLarge(err error) bool { var maxBytesErr *http.MaxBytesError return errors.As(err, &maxBytesErr) } +type uploadBodyReader struct { + reader io.Reader +} + +func (r uploadBodyReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + if isRequestBodyTooLarge(err) { + return n, model.ErrUploadTooLarge + } + return n, err +} + // List handles GET /api/v1/files. func (h *FileHandler) List(c *gin.Context) { userID := middleware.MustGetUserID(c) @@ -223,7 +256,23 @@ func (h *FileHandler) Download(c *gin.Context) { c.Header("Content-Length", strconv.FormatInt(info.Size, 10)) c.Status(http.StatusOK) - io.Copy(c.Writer, reader) + written, err := io.Copy(c.Writer, reader) + if err != nil { + slog.WarnContext(c.Request.Context(), "download copy failed", + "user_id", userID, + "file_id", fileID, + "written", written, + "expected_size", info.Size, + "error", err) + return + } + if written != info.Size { + slog.WarnContext(c.Request.Context(), "download size mismatch", + "user_id", userID, + "file_id", fileID, + "written", written, + "expected_size", info.Size) + } } // Update handles PUT /api/v1/files/:id. diff --git a/internal/handler/file_test.go b/internal/handler/file_test.go index 99dacf2..7df27cc 100644 --- a/internal/handler/file_test.go +++ b/internal/handler/file_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "mime/multipart" "net/http" @@ -23,7 +24,8 @@ import ( // inMemStore implements storage.StorageBackend with an in-memory map. type inMemStore struct { - files map[string][]byte + files map[string][]byte + failReads bool } func newInMemStore() *inMemStore { @@ -54,6 +56,9 @@ func (s *inMemStore) Open(_ context.Context, path string) (io.ReadCloser, error) if !ok { return nil, &fsNotFoundError{path} } + if s.failReads { + return io.NopCloser(&errorAfterReader{data: data, err: errors.New("download read failed")}), nil + } return io.NopCloser(bytes.NewReader(data)), nil } @@ -70,13 +75,27 @@ type fsNotFoundError struct{ path string } func (e *fsNotFoundError) Error() string { return "file not found on disk: " + e.path } +type errorAfterReader struct { + data []byte + err error +} + +func (r *errorAfterReader) Read(p []byte) (int, error) { + if len(r.data) == 0 { + return 0, r.err + } + n := copy(p, r.data) + r.data = r.data[n:] + return n, nil +} + var _ storage.StorageBackend = (*inMemStore)(nil) -func setupFileHandler(t *testing.T) *FileHandler { +func setupFileHandler(t *testing.T) (*FileHandler, *inMemStore) { return setupFileHandlerWithMaxUploadSize(t, 0) } -func setupFileHandlerWithMaxUploadSize(t *testing.T, maxUploadSize int64) *FileHandler { +func setupFileHandlerWithMaxUploadSize(t *testing.T, maxUploadSize int64) (*FileHandler, *inMemStore) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -91,7 +110,7 @@ func setupFileHandlerWithMaxUploadSize(t *testing.T, maxUploadSize int64) *FileH store := newInMemStore() fileService := service.NewFileService(fileRepo, store, maxUploadSize, nil) - return NewFileHandler(fileService) + return NewFileHandler(fileService), store } func setupFileRouter(t *testing.T) *gin.Engine { @@ -99,9 +118,14 @@ func setupFileRouter(t *testing.T) *gin.Engine { } func setupFileRouterWithMaxUploadSize(t *testing.T, maxUploadSize int64) *gin.Engine { + r, _ := setupFileRouterWithMaxUploadSizeAndStore(t, maxUploadSize) + return r +} + +func setupFileRouterWithMaxUploadSizeAndStore(t *testing.T, maxUploadSize int64) (*gin.Engine, *inMemStore) { t.Helper() - handler := setupFileHandlerWithMaxUploadSize(t, maxUploadSize) + handler, store := setupFileHandlerWithMaxUploadSize(t, maxUploadSize) gin.SetMode(gin.TestMode) r := gin.New() @@ -125,7 +149,7 @@ func setupFileRouterWithMaxUploadSize(t *testing.T, maxUploadSize int64) *gin.En files.DELETE("/:id", handler.Delete) } - return r + return r, store } func TestFileHandler_Upload(t *testing.T) { @@ -234,6 +258,52 @@ func TestFileHandler_UploadNoFile(t *testing.T) { } } +func TestFileHandler_UploadUnexpectedFieldDoesNotEchoFieldName(t *testing.T) { + r := setupFileRouter(t) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormField("secret_token") + part.Write([]byte("secret")) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "unexpected multipart field") { + t.Errorf("body = %s, want safe unexpected field message", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "secret_token") { + t.Errorf("body leaks multipart field name: %s", rec.Body.String()) + } +} + +func TestFileHandler_UploadMalformedMultipartDoesNotLeakParserError(t *testing.T) { + r := setupFileRouter(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", strings.NewReader("not a valid multipart body")) + req.Header.Set("Content-Type", "multipart/form-data; boundary=mygo-boundary") + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "invalid multipart form") { + t.Errorf("body = %s, want safe invalid multipart message", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "NextPart") || strings.Contains(rec.Body.String(), "EOF") { + t.Errorf("body leaks parser internals: %s", rec.Body.String()) + } +} + func TestFileHandler_CreateDir(t *testing.T) { r := setupFileRouter(t) @@ -380,6 +450,42 @@ func TestFileHandler_Download(t *testing.T) { } } +func TestFileHandler_DownloadReadErrorAfterResponseStarted(t *testing.T) { + r, store := setupFileRouterWithMaxUploadSizeAndStore(t, 0) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "download-error.txt") + content := []byte("partial response") + part.Write(content) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-User-ID", "user1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + var uploaded service.FileInfo + if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil { + t.Fatalf("unmarshal upload: %v", err) + } + + store.failReads = true + + req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID+"/content", nil) + req.Header.Set("X-User-ID", "user1") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + if !bytes.Equal(rec.Body.Bytes(), content) { + t.Errorf("content = %q, want %q", rec.Body.String(), content) + } +} + func TestFileHandler_Update(t *testing.T) { r := setupFileRouter(t) diff --git a/internal/model/errors.go b/internal/model/errors.go index a4d8719..3389528 100644 --- a/internal/model/errors.go +++ b/internal/model/errors.go @@ -7,10 +7,11 @@ import ( ) var ( - ErrNotFound = errors.New("resource not found") - ErrDuplicate = errors.New("resource already exists") - ErrUnauthorized = errors.New("unauthorized") - ErrForbidden = errors.New("forbidden") + ErrNotFound = errors.New("resource not found") + ErrDuplicate = errors.New("resource already exists") + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") + ErrUploadTooLarge = errors.New("upload exceeds maximum size") ) // AppError is a service-layer error that carries an HTTP status, a user-safe @@ -25,6 +26,31 @@ type AppError struct { func (e *AppError) Error() string { return e.Message } func (e *AppError) Unwrap() error { return e.Err } +// NewBadRequestError creates a 400 AppError. +func NewBadRequestError(message string) *AppError { + return &AppError{Status: http.StatusBadRequest, Message: message} +} + +// NewConflictError creates a 409 AppError. +func NewConflictError(message string) *AppError { + return &AppError{Status: http.StatusConflict, Message: message} +} + +// NewNotFoundError creates a 404 AppError. +func NewNotFoundError(message string, cause error) *AppError { + return &AppError{Status: http.StatusNotFound, Message: message, Err: cause} +} + +// NewForbiddenError creates a 403 AppError. +func NewForbiddenError(message string, cause error) *AppError { + return &AppError{Status: http.StatusForbidden, Message: message, Err: cause} +} + +// NewPayloadTooLargeError creates a 413 AppError. +func NewPayloadTooLargeError(message string, cause error) *AppError { + return &AppError{Status: http.StatusRequestEntityTooLarge, Message: message, Err: cause} +} + // NewInternalError creates a 500 AppError with a wrapped internal cause. // msg describes the operation that failed; cause is the underlying error. func NewInternalError(msg string, cause error) *AppError { diff --git a/internal/service/file.go b/internal/service/file.go index 74bca17..a2911a2 100644 --- a/internal/service/file.go +++ b/internal/service/file.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "log/slog" - "net/http" "strings" "time" @@ -41,8 +40,6 @@ type FileList struct { Total int64 `json:"total"` } -var errUploadTooLarge = errors.New("upload exceeds maximum size") - // FileService handles file management business logic. type FileService struct { fileRepo repository.FileRepository @@ -90,7 +87,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin // Check for name conflicts in the target directory. if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, fileName); err == nil { - return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"} + return nil, model.NewConflictError("a file with this name already exists in this directory") } else if !errors.Is(err, model.ErrNotFound) { return nil, model.NewInternalError("check name conflict", err) } @@ -156,7 +153,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin // Compensate: remove the promoted file. _ = s.storage.Delete(ctx, storagePath) if errors.Is(err, model.ErrDuplicate) { - return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"} + return nil, model.NewConflictError("a file with this name already exists in this directory") } return nil, model.NewInternalError("create file record", err) } @@ -179,7 +176,7 @@ func (r *uploadLimitReader) Read(p []byte) (int, error) { n, err := r.reader.Read(one[:]) if n > 0 { p[0] = one[0] - return 1, errUploadTooLarge + return 1, model.ErrUploadTooLarge } return 0, err } @@ -197,18 +194,14 @@ func isUploadTooLargeError(err error) bool { if err == nil { return false } - if errors.Is(err, errUploadTooLarge) { - return true - } - var maxBytesErr *http.MaxBytesError - return errors.As(err, &maxBytesErr) + return errors.Is(err, model.ErrUploadTooLarge) } func uploadTooLargeError(maxUploadSize int64) *model.AppError { if maxUploadSize > 0 { - return &model.AppError{Status: http.StatusRequestEntityTooLarge, Message: fmt.Sprintf("file size exceeds the maximum allowed size of %d bytes", maxUploadSize)} + return model.NewPayloadTooLargeError(fmt.Sprintf("file size exceeds the maximum allowed size of %d bytes", maxUploadSize), model.ErrUploadTooLarge) } - return &model.AppError{Status: http.StatusRequestEntityTooLarge, Message: "request body is too large"} + return model.NewPayloadTooLargeError("request body is too large", model.ErrUploadTooLarge) } // Download returns a reader for the file's content and its metadata. @@ -220,7 +213,7 @@ func (s *FileService) Download(ctx context.Context, userID, fileID string) (io.R } if file.IsDir { - return nil, nil, &model.AppError{Status: http.StatusBadRequest, Message: "cannot download a directory"} + return nil, nil, model.NewBadRequestError("cannot download a directory") } reader, err := s.storage.Open(ctx, file.StoragePath) @@ -279,7 +272,7 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName // If parent is changing, verify the new parent. if newParentID != nil { if *newParentID == fileID { - return nil, &model.AppError{Status: http.StatusBadRequest, Message: "cannot move a directory into itself"} + return nil, model.NewBadRequestError("cannot move a directory into itself") } if err := s.verifyParent(ctx, userID, *newParentID); err != nil { return nil, err @@ -291,7 +284,7 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName if newName != "" || newParentID != nil { conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name) if err == nil && conflict.ID != fileID { - return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in the target directory"} + return nil, model.NewConflictError("a file with this name already exists in the target directory") } else if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, model.NewInternalError("check name conflict", err) } @@ -299,7 +292,7 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName if err := s.fileRepo.Update(ctx, file); err != nil { if errors.Is(err, model.ErrDuplicate) { - return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in the target directory"} + return nil, model.NewConflictError("a file with this name already exists in the target directory") } return nil, model.NewInternalError("update file", err) } @@ -325,7 +318,7 @@ func (s *FileService) Delete(ctx context.Context, userID, fileID string) error { return model.NewInternalError("check directory contents", err) } if total > 0 { - return &model.AppError{Status: http.StatusConflict, Message: "directory is not empty"} + return model.NewConflictError("directory is not empty") } } @@ -350,7 +343,7 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st // Check for name conflicts in the target directory. if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, dirName); err == nil { - return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"} + return nil, model.NewConflictError("a file with this name already exists in this directory") } else if !errors.Is(err, model.ErrNotFound) { return nil, model.NewInternalError("check name conflict", err) } @@ -366,7 +359,7 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st if err := s.fileRepo.Create(ctx, dir); err != nil { if errors.Is(err, model.ErrDuplicate) { - return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"} + return nil, model.NewConflictError("a file with this name already exists in this directory") } return nil, model.NewInternalError("create directory record", err) } @@ -379,13 +372,13 @@ func (s *FileService) getOwnedFile(ctx context.Context, userID, fileID string) ( file, err := s.fileRepo.FindByID(ctx, fileID) if err != nil { if errors.Is(err, model.ErrNotFound) { - return nil, &model.AppError{Status: http.StatusNotFound, Message: "file not found", Err: model.ErrNotFound} + return nil, model.NewNotFoundError("file not found", model.ErrNotFound) } return nil, model.NewInternalError("find file", err) } if file.UserID != userID { - return nil, &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden} + return nil, model.NewForbiddenError("access denied", model.ErrForbidden) } return file, nil @@ -396,17 +389,17 @@ func (s *FileService) verifyParent(ctx context.Context, userID, parentID string) parent, err := s.fileRepo.FindByID(ctx, parentID) if err != nil { if errors.Is(err, model.ErrNotFound) { - return &model.AppError{Status: http.StatusNotFound, Message: "parent directory not found"} + return model.NewNotFoundError("parent directory not found", model.ErrNotFound) } return model.NewInternalError("find parent", err) } if parent.UserID != userID { - return &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden} + return model.NewForbiddenError("access denied", model.ErrForbidden) } if !parent.IsDir { - return &model.AppError{Status: http.StatusBadRequest, Message: "parent is not a directory"} + return model.NewBadRequestError("parent is not a directory") } return nil @@ -416,13 +409,13 @@ func (s *FileService) verifyParent(ctx context.Context, userID, parentID string) // null bytes, or are reserved names "." and "..". func validateFileName(name string) error { if name == "" { - return &model.AppError{Status: http.StatusBadRequest, Message: "file name must not be empty"} + return model.NewBadRequestError("file name must not be empty") } if strings.ContainsAny(name, "/\\\x00") { - return &model.AppError{Status: http.StatusBadRequest, Message: "file name contains invalid characters"} + return model.NewBadRequestError("file name contains invalid characters") } if name == "." || name == ".." { - return &model.AppError{Status: http.StatusBadRequest, Message: fmt.Sprintf("file name is reserved: %s", name)} + return model.NewBadRequestError(fmt.Sprintf("file name is reserved: %s", name)) } return nil } diff --git a/internal/storage/local.go b/internal/storage/local.go index d8e781c..ac981e4 100644 --- a/internal/storage/local.go +++ b/internal/storage/local.go @@ -2,11 +2,13 @@ package storage import ( "context" + "errors" "fmt" "io" "os" "path/filepath" "strings" + "syscall" ) const ( @@ -14,6 +16,8 @@ const ( stagingPathPrefix = "staging" ) +var renameLocalFile = os.Rename + // LocalStorage implements StorageBackend using the local filesystem. type LocalStorage struct { basePath string @@ -87,13 +91,47 @@ func (s *LocalStorage) PromoteStaged(_ context.Context, stagedPath, finalPath st return fmt.Errorf("create parent directory: %w", err) } - if err := os.Rename(fullStagedPath, fullFinalPath); err != nil { + if err := renameLocalFile(fullStagedPath, fullFinalPath); err != nil { + if errors.Is(err, syscall.EXDEV) { + return promoteByCopy(fullStagedPath, fullFinalPath) + } return fmt.Errorf("promote staged file: %w", err) } return nil } +func promoteByCopy(stagedPath, finalPath string) error { + source, err := os.Open(stagedPath) + if err != nil { + return fmt.Errorf("open staged file: %w", err) + } + defer source.Close() + + destination, err := os.OpenFile(finalPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666) + if err != nil { + return fmt.Errorf("create final file: %w", err) + } + + if _, err := io.Copy(destination, source); err != nil { + destination.Close() + os.Remove(finalPath) + return fmt.Errorf("copy staged file: %w", err) + } + + if err := destination.Close(); err != nil { + os.Remove(finalPath) + return fmt.Errorf("close final file: %w", err) + } + + if err := os.Remove(stagedPath); err != nil { + os.Remove(finalPath) + return fmt.Errorf("delete staged file after copy: %w", err) + } + + return nil +} + // Open returns a reader for the file at path under the storage root. func (s *LocalStorage) Open(_ context.Context, path string) (io.ReadCloser, error) { fullPath := filepath.Join(s.basePath, path) diff --git a/internal/storage/local_test.go b/internal/storage/local_test.go index 5ee3d77..37c86a2 100644 --- a/internal/storage/local_test.go +++ b/internal/storage/local_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "strings" + "syscall" "testing" ) @@ -179,6 +180,50 @@ func TestPromoteRequiresDataFinalPrefix(t *testing.T) { } } +func TestPromoteStagedFallsBackToCopyOnEXDEV(t *testing.T) { + dir := t.TempDir() + store, err := NewLocalStorage(dir) + if err != nil { + t.Fatalf("NewLocalStorage: %v", err) + } + ctx := context.Background() + + content := "copy across filesystems" + _, err = store.SaveStaged(ctx, "staging/cross-device.txt", strings.NewReader(content)) + if err != nil { + t.Fatalf("SaveStaged: %v", err) + } + + originalRename := renameLocalFile + renameLocalFile = func(_, _ string) error { + return &os.LinkError{Op: "rename", Err: syscall.EXDEV} + } + t.Cleanup(func() { + renameLocalFile = originalRename + }) + + if err := store.PromoteStaged(ctx, "staging/cross-device.txt", "data/cross-device.txt"); err != nil { + t.Fatalf("PromoteStaged: %v", err) + } + + reader, err := store.Open(ctx, "data/cross-device.txt") + if err != nil { + t.Fatalf("Open promoted file: %v", err) + } + got, err := io.ReadAll(reader) + reader.Close() + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if string(got) != content { + t.Errorf("content = %q, want %q", string(got), content) + } + + if _, err := os.Stat(dir + "/staging/cross-device.txt"); !os.IsNotExist(err) { + t.Errorf("staged file should be removed after fallback promote, stat err = %v", err) + } +} + func TestOpenMissingFile(t *testing.T) { dir := t.TempDir() store, err := NewLocalStorage(dir) From 63ede5c237ffea8b13f747357b49e15fbce8404e Mon Sep 17 00:00:00 2001 From: Huxley Date: Sun, 5 Jul 2026 23:30:17 +0800 Subject: [PATCH 24/31] refactor(api): enforce protocol-neutral service boundaries - refactor: move HTTP status and DTO concerns out of model and service layers into API and handler code. - feat: add admin service boundary and route auth through services instead of direct repository access. - test: add architecture and error tests covering package boundaries, redaction, and service behavior. - docs: record the protocol-neutral boundary decision and update architecture and roadmap notes. --- docs/architecture.md | 15 +- docs/decisions.md | 19 ++ docs/roadmap.md | 11 +- internal/api/response.go | 86 +++++++-- internal/api/response_test.go | 65 +++++++ internal/app/webapp.go | 5 + internal/app/webapp_test.go | 4 +- internal/architecture_test.go | 68 +++++++ internal/handler/account.go | 45 ++++- internal/handler/account_test.go | 10 +- internal/handler/admin.go | 14 +- internal/handler/admin_test.go | 9 +- internal/handler/auth.go | 40 +++- internal/handler/auth_test.go | 9 +- internal/handler/file.go | 55 +++++- internal/handler/file_test.go | 24 +-- internal/middleware/auth.go | 72 +++++--- internal/middleware/auth_test.go | 271 ++++++++-------------------- internal/model/credential.go | 14 +- internal/model/errors.go | 65 +++++-- internal/model/errors_test.go | 79 ++++++++ internal/model/file.go | 24 +-- internal/model/file_test.go | 36 ++-- internal/model/session.go | 8 +- internal/model/user.go | 16 +- internal/model/user_test.go | 65 ++++++- internal/server/routes_protected.go | 7 +- internal/server/server_test.go | 5 +- internal/service/admin.go | 51 ++++++ internal/service/admin_test.go | 69 +++++++ internal/service/auth.go | 79 +++++--- internal/service/auth_test.go | 75 +++++++- internal/service/file.go | 24 +-- internal/service/file_test.go | 27 ++- 34 files changed, 1028 insertions(+), 438 deletions(-) create mode 100644 internal/architecture_test.go create mode 100644 internal/model/errors_test.go create mode 100644 internal/service/admin.go create mode 100644 internal/service/admin_test.go diff --git a/docs/architecture.md b/docs/architecture.md index b3cf1e9..6d2a1b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,12 @@ Repository (GORM data access) Storage (file I/O) Rules: - Handler has no business logic β€” parse request, call service, write response. +- Handler and middleware never access repositories directly; they depend on services. - Service has no HTTP awareness β€” operates on domain models and interfaces. +- `internal/model` and `internal/service` do not import `net/http`, Gin, or `internal/api`. +- JSON response DTOs live in HTTP packages. Domain and service result structs are not the public REST schema. +- Domain models may use `json:"-"` for defensive redaction of sensitive fields; this is not a REST response contract. +- Domain errors carry a protocol-neutral kind, a safe message, and an optional cause. HTTP status mapping happens only at the API boundary. - Repository abstracts the database; Storage abstracts where bytes live, including staged upload promotion. - `internal/server` is the composition root β€” wires all dependencies together. @@ -31,12 +36,12 @@ Rules: | **HTTP** | `internal/server` | Gin router init, route registration (public/protected split), graceful shutdown | βœ… | | | `internal/handler` | HTTP handlers (auth, file, admin, webdav...) | πŸ›  WIP | | | `internal/middleware` | Gin middleware (logger, jwt, cors, auth) | πŸ›  WIP | -| **Business** | `internal/service` | Business logic: `AuthService` (register, login, refresh, logout, passkey CRUD) | βœ… | -| | `internal/model` | Domain types (User, File, Credential, Session), error codes | βœ… | +| **Business** | `internal/service` | Business logic: `AuthService`, `FileService`, `AdminService`; protocol-neutral results and errors | βœ… | +| | `internal/model` | Domain types (User, File, Credential, Session), protocol-neutral error kinds | βœ… | | **Data** | `internal/repository` | Repository interfaces + GORM implementations (User, Session, File, Credential) | βœ… | | | `internal/storage` | Storage backend interface + local disk impl, with staging and promotion | πŸ›  WIP | | **Util** | `internal/auth` | JWT sign/verify (HS256), token type discrimination (access/refresh), password hashing (bcrypt), app passkey tokens | βœ… | -| | `internal/api` | Unified JSON error response helpers | βœ… | +| | `internal/api` | Unified JSON error response helpers and REST error-kind mapping | βœ… | ## API Routes (v0) @@ -72,7 +77,9 @@ Applied globally by `gin.Default()`: logger β†’ recovery Planned globally: cors -Applied to protected groups: auth (JWT validation, inject user into gin.Context) +Applied to protected groups: auth (extract bearer token, delegate access-token authentication to `AuthService`, inject principal into gin.Context) + +Applied to admin groups: admin (check authenticated principal from context) ## Server Responsibilities diff --git a/docs/decisions.md b/docs/decisions.md index b68eeb5..99ea246 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -87,3 +87,22 @@ - Interrupted, malformed, and oversized uploads leave only staging objects, which are safe to clean by path prefix and age. - Local storage promotes with `os.Rename` and falls back to copy/delete on cross-device `EXDEV`; future S3 storage can implement promotion with copy/delete while keeping business visibility controlled by the DB row. - A DB failure after promotion can still leave a long-term orphan object, but it is not visible through the file API and can be cleaned independently. + +## 2026-07-05: Protocol-Neutral Service Boundary + +**Context**: The service and model layers carried HTTP status codes and JSON response tags, and some middleware and handlers accessed repositories directly. That made the business layer harder to reuse for future WebDAV and Nextcloud-compatible APIs. + +**Decisions**: + +| Decision | Guidance | +|----------|----------| +| Protocol-neutral errors | `model.AppError` uses an error `Kind`, safe message, and optional cause. REST status codes are mapped in `internal/api` only. | +| API log references | REST error responses may include `error.log_id` for server-recorded errors: all 5xx responses and 4xx responses with internal causes. | +| Service boundaries | Handlers and middleware depend on services, not repositories. `AuthService` authenticates access tokens into a principal; `AdminService` owns admin user operations. | +| DTO ownership | Service and model structs are internal/domain data. HTTP handlers assemble response DTOs with JSON tags. | +| Architecture enforcement | Package-level tests reject HTTP imports in model/service and repository imports in handlers/middleware. Targeted model tests verify defensive JSON redaction for sensitive fields. | + +**Consequences**: +- REST remains a handler/API concern, and future protocols can reuse services without HTTP leakage. +- Error responses keep the same top-level shape, with optional `log_id` instead of embedding log references in the message. +- Admin and auth middleware behavior is testable through service contracts rather than database access. diff --git a/docs/roadmap.md b/docs/roadmap.md index c4f456c..fd99323 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,7 +8,7 @@ | JWT authentication | βœ… | access + refresh tokens, refresh token in DB, app passkey support | | Web API foundation | βœ… | WebApp composition, Gin router, graceful shutdown, `GET /api/v1/version` | | File upload/download/manage APIs | πŸ›  WIP | REST API via Gin | -| Admin endpoints | πŸ›  WIP | user CRUD for superusers | +| Admin endpoints | πŸ›  WIP | user service boundary in place for superusers | | WebDAV | πŸ›  WIP | future v0 or v1 | ## Implementation Tasks @@ -18,16 +18,17 @@ Package-level implementation order (each task includes unit tests): 1. `internal/config` β€” Viper loader, config struct βœ… 2. `internal/app` β€” runtime dependency container βœ… 3. `internal/model` β€” domain types, error codes βœ… -4. `internal/api` β€” error response helpers βœ… +4. `internal/api` β€” protocol-neutral error kind to REST response mapping βœ… 5. `internal/auth` β€” JWT utils βœ… 6. `internal/storage` β€” backend interface + local fs with staged upload promotion 7. `internal/repository` β€” interfaces + GORM/SQLite impl βœ… -8. `internal/service` β€” auth, file, admin services βœ… (auth done) -9. `internal/middleware` β€” logger, cors, auth βœ… (auth done) -10. `internal/handler` β€” auth, account, file, admin handlers πŸ›  (auth + account done) +8. `internal/service` β€” auth, file, admin services βœ… +9. `internal/middleware` β€” logger, cors, auth βœ… (auth done; principal boundary enforced) +10. `internal/handler` β€” auth, account, file, admin handlers πŸ›  (HTTP DTO mapping in place) 11. `internal/server` β€” Gin router, route registration, graceful shutdown βœ… 12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` βœ… (serve done) 13. Integration tests +14. Architecture boundary tests βœ… ## Future diff --git a/internal/api/response.go b/internal/api/response.go index f55022d..9492faf 100644 --- a/internal/api/response.go +++ b/internal/api/response.go @@ -21,48 +21,102 @@ type ErrorResponse struct { // ErrorBody contains human-readable error details. type ErrorBody struct { Message string `json:"message"` + LogID string `json:"log_id,omitempty"` } // Error writes a JSON error response. func Error(c *gin.Context, status int, message string) { + writeError(c, status, message, "") +} + +func writeError(c *gin.Context, status int, message, logID string) { c.JSON(status, ErrorResponse{ Error: ErrorBody{ Message: message, + LogID: logID, }, }) } // RespondError unpacks an error from the service layer and writes a JSON -// error response. It expects *model.AppError; unexpected non-AppError -// values are treated as 500 with a safe message. For 500+ errors, a -// random reference hash is included in the response so operators can -// correlate it with server logs. +// error response. It maps protocol-neutral domain error kinds to HTTP status +// codes at the API boundary. Unexpected non-AppError values are treated as 500 +// with a safe message. Recorded errors return a log_id for correlation. func RespondError(c *gin.Context, err error) { var ae *model.AppError if !errors.As(err, &ae) { - ref := randomHex(8) + logID := randomHex(8) slog.ErrorContext(c.Request.Context(), "non-AppError escaped to handler", - "ref", ref, "error", err, "type", fmt.Sprintf("%T", err)) - Error(c, http.StatusInternalServerError, "internal server error (ref: "+ref+")") + "log_id", logID, "error", err, "type", fmt.Sprintf("%T", err)) + writeError(c, http.StatusInternalServerError, "internal server error", logID) return } - // 500+ errors: log full detail with a reference hash, return sanitized message. - if ae.Status >= 500 { - ref := randomHex(8) + status := StatusForErrorKind(ae.Kind) + message := ae.Message + if status >= http.StatusInternalServerError { + message = "internal server error" + } + + if status >= http.StatusInternalServerError { + logID := randomHex(8) slog.ErrorContext(c.Request.Context(), "internal server error", - "ref", ref, slog.Any("error", ae.Err), "message", ae.Message) - Error(c, ae.Status, "internal server error (ref: "+ref+")") + "log_id", logID, slog.Any("error", ae.Err), "message", ae.Message) + writeError(c, status, message, logID) return } - // 4xx errors with internal cause: log at WARN for diagnostics. - if ae.Err != nil { + // 4xx errors with unexpected causes are recorded for diagnostics and return log_id. + if isLoggableCause(ae.Err) { + logID := randomHex(8) slog.WarnContext(c.Request.Context(), ae.Message, - "status", ae.Status, slog.Any("error", ae.Err)) + "log_id", logID, "status", status, slog.Any("error", ae.Err)) + writeError(c, status, message, logID) + return } - Error(c, ae.Status, ae.Message) + writeError(c, status, message, "") +} + +func isLoggableCause(err error) bool { + if err == nil { + return false + } + + for _, expected := range []error{ + model.ErrNotFound, + model.ErrDuplicate, + model.ErrUnauthorized, + model.ErrForbidden, + model.ErrUploadTooLarge, + } { + if errors.Is(err, expected) { + return false + } + } + return true +} + +// StatusForErrorKind maps a protocol-neutral error kind to a REST status code. +func StatusForErrorKind(kind model.ErrorKind) int { + switch kind { + case model.KindInvalidArgument: + return http.StatusBadRequest + case model.KindUnauthenticated: + return http.StatusUnauthorized + case model.KindPermissionDenied: + return http.StatusForbidden + case model.KindNotFound: + return http.StatusNotFound + case model.KindConflict: + return http.StatusConflict + case model.KindPayloadTooLarge: + return http.StatusRequestEntityTooLarge + case model.KindInternal: + return http.StatusInternalServerError + default: + return http.StatusInternalServerError + } } func randomHex(n int) string { diff --git a/internal/api/response_test.go b/internal/api/response_test.go index 487e567..c1bfe20 100644 --- a/internal/api/response_test.go +++ b/internal/api/response_test.go @@ -2,11 +2,14 @@ package api import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" + + "github.com/dhao2001/mygo/internal/model" ) func TestError(t *testing.T) { @@ -33,4 +36,66 @@ func TestError(t *testing.T) { if body.Error.Message != "invalid request" { t.Errorf("message = %q, want %q", body.Error.Message, "invalid request") } + if body.Error.LogID != "" { + t.Errorf("log_id = %q, want empty", body.Error.LogID) + } +} + +func TestRespondErrorMapsDomainKinds(t *testing.T) { + tests := []struct { + name string + err error + wantStatus int + wantLogID bool + }{ + {"invalid argument", model.NewInvalidArgumentError("bad input"), http.StatusBadRequest, false}, + {"unauthenticated", model.NewUnauthenticatedError("invalid token"), http.StatusUnauthorized, false}, + {"permission denied", model.NewPermissionDeniedError("access denied", nil), http.StatusForbidden, false}, + {"not found", model.NewNotFoundError("missing", nil), http.StatusNotFound, false}, + {"conflict", model.NewConflictError("duplicate"), http.StatusConflict, false}, + {"payload too large", model.NewPayloadTooLargeError("too large", nil), http.StatusRequestEntityTooLarge, false}, + {"internal", model.NewInternalError("lookup", errors.New("database offline")), http.StatusInternalServerError, true}, + {"unknown", errors.New("escaped error"), http.StatusInternalServerError, true}, + {"permission denied sentinel cause", model.NewPermissionDeniedError("access denied", model.ErrForbidden), http.StatusForbidden, false}, + {"not found sentinel cause", model.NewNotFoundError("missing", model.ErrNotFound), http.StatusNotFound, false}, + {"payload too large sentinel cause", model.NewPayloadTooLargeError("too large", model.ErrUploadTooLarge), http.StatusRequestEntityTooLarge, false}, + {"client with diagnostic cause", model.NewPermissionDeniedError("access denied", errors.New("policy backend failed")), http.StatusForbidden, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := exerciseRespondError(tt.err) + if rec.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, tt.wantStatus, rec.Body.String()) + } + + var body ErrorResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if tt.wantLogID && body.Error.LogID == "" { + t.Fatalf("log_id is empty, want populated; body = %s", rec.Body.String()) + } + if !tt.wantLogID && body.Error.LogID != "" { + t.Fatalf("log_id = %q, want empty", body.Error.LogID) + } + if rec.Code >= http.StatusInternalServerError && body.Error.Message != "internal server error" { + t.Fatalf("message = %q, want sanitized internal message", body.Error.Message) + } + }) + } +} + +func exerciseRespondError(err error) *httptest.ResponseRecorder { + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/error", func(c *gin.Context) { + RespondError(c, err) + }) + + req := httptest.NewRequest(http.MethodGet, "/error", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec } diff --git a/internal/app/webapp.go b/internal/app/webapp.go index ba554ff..5659e75 100644 --- a/internal/app/webapp.go +++ b/internal/app/webapp.go @@ -23,6 +23,7 @@ type WebApp struct { FileRepo repository.FileRepository CredentialRepo repository.CredentialRepository AuthService *service.AuthService + AdminService *service.AdminService FileService *service.FileService Storage storage.StorageBackend } @@ -58,6 +59,7 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) { } fileService := service.NewFileService(fileRepo, store, cfg.Storage.MaxUploadSize, slog.Default()) + adminService := service.NewAdminService(userRepo) return &WebApp{ Config: cfg, @@ -68,6 +70,7 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) { FileRepo: fileRepo, CredentialRepo: credentialRepo, AuthService: authService, + AdminService: adminService, FileService: fileService, Storage: store, }, nil @@ -80,6 +83,7 @@ func NewWebApp(cfg *config.Config, db *gorm.DB, fileRepo repository.FileRepository, credentialRepo repository.CredentialRepository, authService *service.AuthService, + adminService *service.AdminService, fileService *service.FileService, store storage.StorageBackend, ) *WebApp { @@ -92,6 +96,7 @@ func NewWebApp(cfg *config.Config, db *gorm.DB, FileRepo: fileRepo, CredentialRepo: credentialRepo, AuthService: authService, + AdminService: adminService, FileService: fileService, Storage: store, } diff --git a/internal/app/webapp_test.go b/internal/app/webapp_test.go index 3403fc3..a52c72a 100644 --- a/internal/app/webapp_test.go +++ b/internal/app/webapp_test.go @@ -9,7 +9,7 @@ import ( func TestNewWebApp(t *testing.T) { cfg := &config.Config{} - webApp := NewWebApp(cfg, nil, nil, nil, nil, nil, nil, nil, nil) + webApp := NewWebApp(cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil) if webApp.Config != cfg { t.Fatal("Config was not assigned") @@ -20,7 +20,7 @@ func TestNewWebApp(t *testing.T) { } func TestCloseNilDB(t *testing.T) { - webApp := NewWebApp(&config.Config{}, nil, nil, nil, nil, nil, nil, nil, nil) + webApp := NewWebApp(&config.Config{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) if err := webApp.Close(); err != nil { t.Errorf("Close with nil DB should not error: %v", err) } diff --git a/internal/architecture_test.go b/internal/architecture_test.go new file mode 100644 index 0000000..7e9528a --- /dev/null +++ b/internal/architecture_test.go @@ -0,0 +1,68 @@ +package internal_test + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "strconv" + "strings" + "testing" +) + +func TestArchitecture_ModelAndServiceAreProtocolNeutral(t *testing.T) { + for _, dir := range []string{"model", "service"} { + imports := packageImports(t, dir) + for _, forbidden := range []string{ + "net/http", + "github.com/gin-gonic/gin", + "github.com/dhao2001/mygo/internal/api", + } { + if imports[forbidden] { + t.Fatalf("internal/%s imports forbidden package %q", dir, forbidden) + } + } + } +} + +func TestArchitecture_HTTPDoesNotImportRepositories(t *testing.T) { + for _, dir := range []string{"handler", "middleware"} { + imports := packageImports(t, dir) + if imports["github.com/dhao2001/mygo/internal/repository"] { + t.Fatalf("internal/%s imports internal/repository; use service boundaries instead", dir) + } + } +} + +func packageImports(t *testing.T, dir string) map[string]bool { + t.Helper() + + imports := map[string]bool{} + pkgs := parsePackage(t, dir) + for _, pkg := range pkgs { + for _, file := range pkg.Files { + for _, spec := range file.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil { + t.Fatalf("unquote import path %s: %v", spec.Path.Value, err) + } + imports[path] = true + } + } + } + return imports +} + +func parsePackage(t *testing.T, dir string) map[string]*ast.Package { + t.Helper() + + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, dir, func(info os.FileInfo) bool { + name := info.Name() + return !strings.HasSuffix(name, "_test.go") + }, parser.ParseComments) + if err != nil { + t.Fatalf("parse internal/%s: %v", dir, err) + } + return pkgs +} diff --git a/internal/handler/account.go b/internal/handler/account.go index fc9906a..279f6d3 100644 --- a/internal/handler/account.go +++ b/internal/handler/account.go @@ -3,6 +3,7 @@ package handler import ( "log/slog" "net/http" + "time" "github.com/gin-gonic/gin" @@ -26,10 +27,29 @@ type createPasskeyRequest struct { Label string `json:"label" binding:"required"` } +type accountResponse struct { + UserID string `json:"user_id"` +} + +type passkeyResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Type string `json:"type"` + Label string `json:"label"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at"` +} + +type createdPasskeyResponse struct { + ID string `json:"id"` + Raw string `json:"raw"` + Label string `json:"label"` +} + // GetAccount handles GET /api/v1/account. func (h *AccountHandler) GetAccount(c *gin.Context) { userID := middleware.MustGetUserID(c) - c.JSON(http.StatusOK, gin.H{"user_id": userID}) + c.JSON(http.StatusOK, accountResponse{UserID: userID}) } // ListPasskeys handles GET /api/v1/account/passkeys. @@ -46,7 +66,7 @@ func (h *AccountHandler) ListPasskeys(c *gin.Context) { creds = []model.Credential{} } - c.JSON(http.StatusOK, creds) + c.JSON(http.StatusOK, toPasskeyResponses(creds)) } // CreatePasskey handles POST /api/v1/account/passkeys. @@ -66,7 +86,11 @@ func (h *AccountHandler) CreatePasskey(c *gin.Context) { return } - c.JSON(http.StatusCreated, pk) + c.JSON(http.StatusCreated, createdPasskeyResponse{ + ID: pk.ID, + Raw: pk.Raw, + Label: pk.Label, + }) } // RevokePasskey handles DELETE /api/v1/account/passkeys/:id. @@ -86,3 +110,18 @@ func (h *AccountHandler) RevokePasskey(c *gin.Context) { c.Status(http.StatusOK) } + +func toPasskeyResponses(creds []model.Credential) []passkeyResponse { + items := make([]passkeyResponse, 0, len(creds)) + for i := range creds { + items = append(items, passkeyResponse{ + ID: creds[i].ID, + UserID: creds[i].UserID, + Type: creds[i].Type, + Label: creds[i].Label, + LastUsedAt: creds[i].LastUsedAt, + CreatedAt: creds[i].CreatedAt, + }) + } + return items +} diff --git a/internal/handler/account_test.go b/internal/handler/account_test.go index 4c6c7b0..79c9439 100644 --- a/internal/handler/account_test.go +++ b/internal/handler/account_test.go @@ -10,8 +10,6 @@ import ( "github.com/gin-gonic/gin" "github.com/dhao2001/mygo/internal/middleware" - "github.com/dhao2001/mygo/internal/model" - "github.com/dhao2001/mygo/internal/service" ) func setupAccountRouter(t *testing.T) (*gin.Engine, []byte) { @@ -31,7 +29,7 @@ func setupAccountRouter(t *testing.T) (*gin.Engine, []byte) { } protected := r.Group("/api/v1") - protected.Use(middleware.AuthRequired(secret)) + protected.Use(middleware.AuthRequired(svc)) { account := protected.Group("/account") { @@ -65,7 +63,7 @@ func TestAccountEndpoint(t *testing.T) { rec = httptest.NewRecorder() r.ServeHTTP(rec, req) - var pair service.TokenPair + var pair testTokenPairResponse json.Unmarshal(rec.Body.Bytes(), &pair) // Get /account @@ -107,7 +105,7 @@ func TestPasskeyCRUD(t *testing.T) { rec = httptest.NewRecorder() r.ServeHTTP(rec, req) - var pair service.TokenPair + var pair testTokenPairResponse json.Unmarshal(rec.Body.Bytes(), &pair) authHeader := "Bearer " + pair.AccessToken @@ -134,7 +132,7 @@ func TestPasskeyCRUD(t *testing.T) { } // Revoke passkey - var creds []model.Credential + var creds []passkeyResponse json.Unmarshal(rec.Body.Bytes(), &creds) if len(creds) != 1 { t.Fatalf("expected 1 passkey, got %d", len(creds)) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 36575ef..ab93e0b 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -9,17 +9,17 @@ import ( "github.com/dhao2001/mygo/internal/api" "github.com/dhao2001/mygo/internal/model" - "github.com/dhao2001/mygo/internal/repository" + "github.com/dhao2001/mygo/internal/service" ) // AdminHandler handles admin-only endpoints for user management. type AdminHandler struct { - userRepo repository.UserRepository + adminService *service.AdminService } // NewAdminHandler creates an AdminHandler. -func NewAdminHandler(userRepo repository.UserRepository) *AdminHandler { - return &AdminHandler{userRepo: userRepo} +func NewAdminHandler(adminService *service.AdminService) *AdminHandler { + return &AdminHandler{adminService: adminService} } // adminUserResponse exposes all user fields, including status, for admin views. @@ -55,7 +55,7 @@ func toAdminResponse(u *model.User) adminUserResponse { func (h *AdminHandler) DeleteUser(c *gin.Context) { id := c.Param("id") - if err := h.userRepo.Delete(c.Request.Context(), id); err != nil { + if err := h.adminService.DeleteUser(c.Request.Context(), id); err != nil { api.RespondError(c, err) return } @@ -67,7 +67,7 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) { func (h *AdminHandler) GetUser(c *gin.Context) { id := c.Param("id") - user, err := h.userRepo.FindByIDIncludeDeleted(c.Request.Context(), id) + user, err := h.adminService.GetUser(c.Request.Context(), id) if err != nil { api.RespondError(c, err) return @@ -93,7 +93,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) { limit = 200 } - users, total, err := h.userRepo.ListIncludeDeleted(c.Request.Context(), offset, limit) + users, total, err := h.adminService.ListUsers(c.Request.Context(), offset, limit) if err != nil { api.RespondError(c, err) return diff --git a/internal/handler/admin_test.go b/internal/handler/admin_test.go index 76c50fb..9c5dbac 100644 --- a/internal/handler/admin_test.go +++ b/internal/handler/admin_test.go @@ -42,7 +42,8 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) { ) authHandler := NewAuthHandler(authService) - adminHandler := NewAdminHandler(userRepo) + adminService := service.NewAdminService(userRepo) + adminHandler := NewAdminHandler(adminService) gin.SetMode(gin.TestMode) r := gin.New() @@ -54,8 +55,8 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) { } admin := r.Group("/api/v1/admin") - admin.Use(middleware.AuthRequired(secret)) - admin.Use(middleware.AdminRequired(userRepo)) + admin.Use(middleware.AuthRequired(authService)) + admin.Use(middleware.AdminRequired()) { admin.GET("/users", adminHandler.ListUsers) admin.GET("/users/:id", adminHandler.GetUser) @@ -104,7 +105,7 @@ func loginHelper(t *testing.T, r *gin.Engine, email, password string) string { t.Fatalf("login %s: status = %d, body = %s", email, rec.Code, rec.Body.String()) } - var pair service.TokenPair + var pair testTokenPairResponse if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil { t.Fatalf("unmarshal login response: %v", err) } diff --git a/internal/handler/auth.go b/internal/handler/auth.go index fe225a3..9f5d835 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -3,10 +3,12 @@ package handler import ( "log/slog" "net/http" + "time" "github.com/gin-gonic/gin" "github.com/dhao2001/mygo/internal/api" + "github.com/dhao2001/mygo/internal/model" "github.com/dhao2001/mygo/internal/service" ) @@ -35,6 +37,20 @@ type tokenRequest struct { RefreshToken string `json:"refresh_token" binding:"required"` } +type userResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + IsAdmin bool `json:"is_admin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type tokenPairResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + // Register handles POST /api/v1/auth/register. func (h *AuthHandler) Register(c *gin.Context) { var req registerRequest @@ -50,7 +66,7 @@ func (h *AuthHandler) Register(c *gin.Context) { return } - c.JSON(http.StatusCreated, user) + c.JSON(http.StatusCreated, toUserResponse(user)) } // Login handles POST /api/v1/auth/login. @@ -68,7 +84,7 @@ func (h *AuthHandler) Login(c *gin.Context) { return } - c.JSON(http.StatusOK, pair) + c.JSON(http.StatusOK, toTokenPairResponse(pair)) } // Refresh handles POST /api/v1/auth/refresh. @@ -86,7 +102,7 @@ func (h *AuthHandler) Refresh(c *gin.Context) { return } - c.JSON(http.StatusOK, pair) + c.JSON(http.StatusOK, toTokenPairResponse(pair)) } // Logout handles POST /api/v1/auth/logout. @@ -105,3 +121,21 @@ func (h *AuthHandler) Logout(c *gin.Context) { c.Status(http.StatusOK) } + +func toUserResponse(user *model.User) userResponse { + return userResponse{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + IsAdmin: user.IsAdmin, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} + +func toTokenPairResponse(pair *service.TokenPair) tokenPairResponse { + return tokenPairResponse{ + AccessToken: pair.AccessToken, + RefreshToken: pair.RefreshToken, + } +} diff --git a/internal/handler/auth_test.go b/internal/handler/auth_test.go index 7078a85..233ba48 100644 --- a/internal/handler/auth_test.go +++ b/internal/handler/auth_test.go @@ -17,6 +17,11 @@ import ( "github.com/dhao2001/mygo/internal/service" ) +type testTokenPairResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + func setupTestAuthService(t *testing.T) (*service.AuthService, []byte) { t.Helper() @@ -140,7 +145,7 @@ func TestLoginHandler(t *testing.T) { t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) } - var pair service.TokenPair + var pair testTokenPairResponse if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil { t.Fatalf("unmarshal response: %v", err) } @@ -196,7 +201,7 @@ func TestRefreshHandler(t *testing.T) { rec = httptest.NewRecorder() r.ServeHTTP(rec, req) - var pair service.TokenPair + var pair testTokenPairResponse json.Unmarshal(rec.Body.Bytes(), &pair) // Refresh diff --git a/internal/handler/file.go b/internal/handler/file.go index e53ac5f..97d1193 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "strconv" + "time" "github.com/gin-gonic/gin" @@ -52,6 +53,24 @@ type updateFileRequest struct { ParentID *string `json:"parent_id"` } +type fileInfoResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + ParentID *string `json:"parent_id"` + Name string `json:"name"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + IsDir bool `json:"is_dir"` + Hash string `json:"hash,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type fileListResponse struct { + Files []fileInfoResponse `json:"files"` + Total int64 `json:"total"` +} + // Upload handles POST /api/v1/files. // If the content type is multipart/form-data, it uploads a file. // If the content type is application/json, it creates a directory. @@ -81,7 +100,7 @@ func (h *FileHandler) Upload(c *gin.Context) { return } - c.JSON(http.StatusCreated, dir) + c.JSON(http.StatusCreated, toFileInfoResponse(dir)) return } @@ -130,7 +149,7 @@ func (h *FileHandler) Upload(c *gin.Context) { return } - c.JSON(http.StatusCreated, info) + c.JSON(http.StatusCreated, toFileInfoResponse(info)) } func nextUploadFilePart(reader *multipart.Reader) (*multipart.Part, string, error) { @@ -217,7 +236,7 @@ func (h *FileHandler) List(c *gin.Context) { return } - c.JSON(http.StatusOK, list) + c.JSON(http.StatusOK, toFileListResponse(list)) } // Get handles GET /api/v1/files/:id. @@ -231,7 +250,7 @@ func (h *FileHandler) Get(c *gin.Context) { return } - c.JSON(http.StatusOK, info) + c.JSON(http.StatusOK, toFileInfoResponse(info)) } // Download handles GET /api/v1/files/:id/content. @@ -299,7 +318,7 @@ func (h *FileHandler) Update(c *gin.Context) { return } - c.JSON(http.StatusOK, info) + c.JSON(http.StatusOK, toFileInfoResponse(info)) } // Delete handles DELETE /api/v1/files/:id. @@ -314,3 +333,29 @@ func (h *FileHandler) Delete(c *gin.Context) { c.Status(http.StatusNoContent) } + +func toFileInfoResponse(info *service.FileInfo) fileInfoResponse { + return fileInfoResponse{ + ID: info.ID, + UserID: info.UserID, + ParentID: info.ParentID, + Name: info.Name, + Size: info.Size, + MimeType: info.MimeType, + IsDir: info.IsDir, + Hash: info.Hash, + CreatedAt: info.CreatedAt, + UpdatedAt: info.UpdatedAt, + } +} + +func toFileListResponse(list *service.FileList) fileListResponse { + items := make([]fileInfoResponse, 0, len(list.Files)) + for i := range list.Files { + items = append(items, toFileInfoResponse(&list.Files[i])) + } + return fileListResponse{ + Files: items, + Total: list.Total, + } +} diff --git a/internal/handler/file_test.go b/internal/handler/file_test.go index 7df27cc..2701936 100644 --- a/internal/handler/file_test.go +++ b/internal/handler/file_test.go @@ -171,7 +171,7 @@ func TestFileHandler_Upload(t *testing.T) { t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String()) } - var info service.FileInfo + var info fileInfoResponse if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -318,7 +318,7 @@ func TestFileHandler_CreateDir(t *testing.T) { t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String()) } - var info service.FileInfo + var info fileInfoResponse if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -353,7 +353,7 @@ func TestFileHandler_List(t *testing.T) { t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) } - var list service.FileList + var list fileListResponse if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -378,7 +378,7 @@ func TestFileHandler_Get(t *testing.T) { rec := httptest.NewRecorder() r.ServeHTTP(rec, req) - var uploaded service.FileInfo + var uploaded fileInfoResponse json.Unmarshal(rec.Body.Bytes(), &uploaded) // Get metadata. @@ -391,7 +391,7 @@ func TestFileHandler_Get(t *testing.T) { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) } - var info service.FileInfo + var info fileInfoResponse if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -430,7 +430,7 @@ func TestFileHandler_Download(t *testing.T) { rec := httptest.NewRecorder() r.ServeHTTP(rec, req) - var uploaded service.FileInfo + var uploaded fileInfoResponse json.Unmarshal(rec.Body.Bytes(), &uploaded) // Download. @@ -466,7 +466,7 @@ func TestFileHandler_DownloadReadErrorAfterResponseStarted(t *testing.T) { rec := httptest.NewRecorder() r.ServeHTTP(rec, req) - var uploaded service.FileInfo + var uploaded fileInfoResponse if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil { t.Fatalf("unmarshal upload: %v", err) } @@ -502,7 +502,7 @@ func TestFileHandler_Update(t *testing.T) { rec := httptest.NewRecorder() r.ServeHTTP(rec, req) - var uploaded service.FileInfo + var uploaded fileInfoResponse json.Unmarshal(rec.Body.Bytes(), &uploaded) // Rename. @@ -517,7 +517,7 @@ func TestFileHandler_Update(t *testing.T) { t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) } - var updated service.FileInfo + var updated fileInfoResponse if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -542,7 +542,7 @@ func TestFileHandler_UpdateNoFields(t *testing.T) { rec := httptest.NewRecorder() r.ServeHTTP(rec, req) - var uploaded service.FileInfo + var uploaded fileInfoResponse json.Unmarshal(rec.Body.Bytes(), &uploaded) updateBody, _ := json.Marshal(gin.H{}) @@ -573,7 +573,7 @@ func TestFileHandler_Delete(t *testing.T) { rec := httptest.NewRecorder() r.ServeHTTP(rec, req) - var uploaded service.FileInfo + var uploaded fileInfoResponse json.Unmarshal(rec.Body.Bytes(), &uploaded) // Delete. @@ -613,7 +613,7 @@ func TestFileHandler_ForbiddenAccess(t *testing.T) { rec := httptest.NewRecorder() r.ServeHTTP(rec, req) - var uploaded service.FileInfo + var uploaded fileInfoResponse json.Unmarshal(rec.Body.Bytes(), &uploaded) // Try to access as user2. diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 20ba4cd..92f92f6 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,21 +1,28 @@ package middleware import ( + "context" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/dhao2001/mygo/internal/api" - "github.com/dhao2001/mygo/internal/auth" - "github.com/dhao2001/mygo/internal/repository" + "github.com/dhao2001/mygo/internal/service" ) -const userIDKey = "user_id" +const ( + userIDKey = "user_id" + principalKey = "principal" +) + +type accessAuthenticator interface { + AuthenticateAccessToken(ctx context.Context, token string) (*service.Principal, error) +} // AuthRequired returns a Gin middleware that validates JWT access tokens. -// On success, it injects the user ID into the context via c.Get("user_id"). -func AuthRequired(jwtSecret []byte) gin.HandlerFunc { +// On success, it injects the principal and user ID into the context. +func AuthRequired(authenticator accessAuthenticator) gin.HandlerFunc { return func(c *gin.Context) { header := c.GetHeader("Authorization") if header == "" { @@ -31,20 +38,15 @@ func AuthRequired(jwtSecret []byte) gin.HandlerFunc { return } - claims, err := auth.ParseToken(parts[1], jwtSecret) + principal, err := authenticator.AuthenticateAccessToken(c.Request.Context(), parts[1]) if err != nil { - api.Error(c, http.StatusUnauthorized, "invalid or expired token") + api.RespondError(c, err) c.Abort() return } - if claims.Type != auth.TokenAccess { - api.Error(c, http.StatusUnauthorized, "invalid token type") - c.Abort() - return - } - - c.Set(userIDKey, claims.UserID) + c.Set(principalKey, *principal) + c.Set(userIDKey, principal.UserID) c.Next() } } @@ -65,32 +67,42 @@ func GetUserID(c *gin.Context) string { func MustGetUserID(c *gin.Context) string { userID := GetUserID(c) if userID == "" { - panic("user_id not found in context β€” is AuthRequired middleware applied?") + panic("user_id not found in context - is AuthRequired middleware applied?") } return userID } -// AdminRequired returns a Gin middleware that gates access to admin-only -// endpoints. It must be applied AFTER AuthRequired. It fetches the user from -// the repository, and returns 403 if the user is not an admin. Soft-deleted -// users are not found by FindByID and will receive a 401 response. -func AdminRequired(userRepo repository.UserRepository) gin.HandlerFunc { +// GetPrincipal extracts the authenticated principal injected by AuthRequired. +func GetPrincipal(c *gin.Context) (service.Principal, bool) { + v, ok := c.Get(principalKey) + if !ok || v == nil { + return service.Principal{}, false + } + principal, ok := v.(service.Principal) + return principal, ok +} + +// MustGetPrincipal extracts the authenticated principal injected by AuthRequired. +func MustGetPrincipal(c *gin.Context) service.Principal { + principal, ok := GetPrincipal(c) + if !ok { + panic("principal not found in context - is AuthRequired middleware applied?") + } + return principal +} + +// AdminRequired returns a Gin middleware that gates access to admin-only endpoints. +// It must be applied after AuthRequired. +func AdminRequired() gin.HandlerFunc { return func(c *gin.Context) { - userID := GetUserID(c) - if userID == "" { + principal, ok := GetPrincipal(c) + if !ok { api.Error(c, http.StatusUnauthorized, "missing user context") c.Abort() return } - user, err := userRepo.FindByID(c.Request.Context(), userID) - if err != nil { - api.Error(c, http.StatusUnauthorized, "user not found") - c.Abort() - return - } - - if !user.IsAdmin { + if !principal.IsAdmin { api.Error(c, http.StatusForbidden, "admin access required") c.Abort() return diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go index 5c1835d..919139a 100644 --- a/internal/middleware/auth_test.go +++ b/internal/middleware/auth_test.go @@ -7,29 +7,47 @@ import ( "net/http/httptest" "strings" "testing" - "time" "github.com/gin-gonic/gin" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - "github.com/dhao2001/mygo/internal/auth" "github.com/dhao2001/mygo/internal/model" - "github.com/dhao2001/mygo/internal/repository" + "github.com/dhao2001/mygo/internal/service" ) -func setupTestRouter(secret []byte) *gin.Engine { +type stubAccessAuthenticator struct { + wantToken string + principal service.Principal + err error + called bool +} + +func (s *stubAccessAuthenticator) AuthenticateAccessToken(_ context.Context, token string) (*service.Principal, error) { + s.called = true + if s.wantToken != "" && token != s.wantToken { + return nil, model.NewUnauthenticatedError("invalid token") + } + if s.err != nil { + return nil, s.err + } + return &s.principal, nil +} + +func setupTestRouter(authenticator accessAuthenticator) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() - r.Use(AuthRequired(secret)) + r.Use(AuthRequired(authenticator)) r.GET("/protected", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"user_id": MustGetUserID(c)}) + c.JSON(http.StatusOK, gin.H{ + "user_id": MustGetUserID(c), + "is_admin": MustGetPrincipal(c).IsAdmin, + }) }) return r } func TestAuthRequiredNoHeader(t *testing.T) { - r := setupTestRouter([]byte("test-secret")) + authenticator := &stubAccessAuthenticator{} + r := setupTestRouter(authenticator) req := httptest.NewRequest(http.MethodGet, "/protected", nil) rec := httptest.NewRecorder() @@ -38,10 +56,14 @@ func TestAuthRequiredNoHeader(t *testing.T) { if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } + if authenticator.called { + t.Fatal("authenticator should not be called without a bearer token") + } } func TestAuthRequiredInvalidFormat(t *testing.T) { - r := setupTestRouter([]byte("test-secret")) + authenticator := &stubAccessAuthenticator{} + r := setupTestRouter(authenticator) req := httptest.NewRequest(http.MethodGet, "/protected", nil) req.Header.Set("Authorization", "invalid") @@ -51,10 +73,14 @@ func TestAuthRequiredInvalidFormat(t *testing.T) { if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } + if authenticator.called { + t.Fatal("authenticator should not be called for malformed authorization header") + } } func TestAuthRequiredNotBearer(t *testing.T) { - r := setupTestRouter([]byte("test-secret")) + authenticator := &stubAccessAuthenticator{} + r := setupTestRouter(authenticator) req := httptest.NewRequest(http.MethodGet, "/protected", nil) req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") @@ -64,105 +90,51 @@ func TestAuthRequiredNotBearer(t *testing.T) { if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } + if authenticator.called { + t.Fatal("authenticator should not be called for non-bearer authorization") + } } -func TestAuthRequiredExpiredToken(t *testing.T) { - secret := []byte("test-secret") - token, err := auth.GenerateAccessToken("user-1", secret, -1*time.Minute) - if err != nil { - t.Fatalf("GenerateAccessToken = %v", err) - } +func TestAuthRequiredServiceRejectsToken(t *testing.T) { + authenticator := &stubAccessAuthenticator{err: model.NewUnauthenticatedError("invalid or expired token")} + r := setupTestRouter(authenticator) - r := setupTestRouter(secret) req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Authorization", "Bearer expired") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } + if !authenticator.called { + t.Fatal("authenticator was not called") + } } func TestAuthRequiredValidToken(t *testing.T) { - secret := []byte("test-secret") - token, err := auth.GenerateAccessToken("user-1", secret, 15*time.Minute) - if err != nil { - t.Fatalf("GenerateAccessToken = %v", err) + authenticator := &stubAccessAuthenticator{ + wantToken: "valid", + principal: service.Principal{ + UserID: "user-1", + IsAdmin: true, + }, } + r := setupTestRouter(authenticator) - r := setupTestRouter(secret) req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Authorization", "Bearer valid") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) } -} - -func TestAuthRequiredRefreshTokenRejected(t *testing.T) { - secret := []byte("test-secret") - token, err := auth.GenerateRefreshToken("user-1", secret, 7*24*time.Hour) - if err != nil { - t.Fatalf("GenerateRefreshToken = %v", err) + if !strings.Contains(rec.Body.String(), "user-1") { + t.Errorf("response body %q does not contain user id", rec.Body.String()) } - - r := setupTestRouter(secret) - req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("Authorization", "Bearer "+token) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusUnauthorized { - t.Errorf("status = %d, want %d (refresh token should be rejected)", rec.Code, http.StatusUnauthorized) - } -} - -func TestGetUserID(t *testing.T) { - secret := []byte("test-secret") - token, err := auth.GenerateAccessToken("alice-42", secret, 15*time.Minute) - if err != nil { - t.Fatalf("GenerateAccessToken = %v", err) - } - - r := setupTestRouter(secret) - req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("Authorization", "Bearer "+token) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d", rec.Code) - } - - body := rec.Body.String() - if !strings.Contains(body, "alice-42") { - t.Errorf("response body %q does not contain user id", body) - } -} - -func TestMustGetUserID(t *testing.T) { - secret := []byte("test-secret") - token, err := auth.GenerateAccessToken("bob-99", secret, 15*time.Minute) - if err != nil { - t.Fatalf("GenerateAccessToken = %v", err) - } - - r := setupTestRouter(secret) - req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("Authorization", "Bearer "+token) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d", rec.Code) - } - - body := rec.Body.String() - if !strings.Contains(body, "bob-99") { - t.Errorf("response body %q does not contain user id", body) + if !strings.Contains(rec.Body.String(), "true") { + t.Errorf("response body %q does not contain admin flag", rec.Body.String()) } } @@ -170,7 +142,7 @@ func TestMustGetUserIDPanics(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() r.GET("/naked", func(c *gin.Context) { - MustGetUserID(c) // should panic β€” no AuthRequired middleware applied + MustGetUserID(c) }) req := httptest.NewRequest(http.MethodGet, "/naked", nil) @@ -184,75 +156,27 @@ func TestMustGetUserIDPanics(t *testing.T) { r.ServeHTTP(rec, req) } -func TestAuthRequiredWrongSecret(t *testing.T) { - secret := []byte("test-secret") - token, err := auth.GenerateAccessToken("user-1", secret, 15*time.Minute) - if err != nil { - t.Fatalf("GenerateAccessToken = %v", err) - } - - // Use a different secret for the middleware - r := setupTestRouter([]byte("different-secret")) - req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("Authorization", "Bearer "+token) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusUnauthorized { - t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) - } -} - -// --- AdminRequired tests --- - -// errorBody extracts the error.message field from a JSON response body. type errorBody struct { Error struct { Message string `json:"message"` } `json:"error"` } -func setupAdminTestDB(t *testing.T) repository.UserRepository { - t.Helper() - - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(&model.User{}); err != nil { - t.Fatalf("migrate: %v", err) - } - - return repository.NewUserRepository(db) -} - -// injectUserIDMiddleware simulates AuthRequired by injecting a user_id into the context. -func injectUserIDMiddleware(userID string) gin.HandlerFunc { +func injectPrincipalMiddleware(principal *service.Principal) gin.HandlerFunc { return func(c *gin.Context) { - c.Set(userIDKey, userID) + if principal != nil { + c.Set(principalKey, *principal) + c.Set(userIDKey, principal.UserID) + } c.Next() } } func TestAdminRequired_AdminPasses(t *testing.T) { - repo := setupAdminTestDB(t) - ctx := context.Background() - - user := &model.User{ - ID: "admin-1", - Username: "admin", - Email: "admin@example.com", - IsAdmin: true, - Status: model.StatusActive, - } - if err := repo.Create(ctx, user); err != nil { - t.Fatalf("Create = %v", err) - } - gin.SetMode(gin.TestMode) r := gin.New() - r.Use(injectUserIDMiddleware("admin-1")) - r.Use(AdminRequired(repo)) + r.Use(injectPrincipalMiddleware(&service.Principal{UserID: "admin-1", IsAdmin: true})) + r.Use(AdminRequired()) r.GET("/admin", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) @@ -267,24 +191,10 @@ func TestAdminRequired_AdminPasses(t *testing.T) { } func TestAdminRequired_NonAdminForbidden(t *testing.T) { - repo := setupAdminTestDB(t) - ctx := context.Background() - - user := &model.User{ - ID: "user-1", - Username: "regular", - Email: "regular@example.com", - IsAdmin: false, - Status: model.StatusActive, - } - if err := repo.Create(ctx, user); err != nil { - t.Fatalf("Create = %v", err) - } - gin.SetMode(gin.TestMode) r := gin.New() - r.Use(injectUserIDMiddleware("user-1")) - r.Use(AdminRequired(repo)) + r.Use(injectPrincipalMiddleware(&service.Principal{UserID: "user-1", IsAdmin: false})) + r.Use(AdminRequired()) r.GET("/admin", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) @@ -306,49 +216,10 @@ func TestAdminRequired_NonAdminForbidden(t *testing.T) { } } -func TestAdminRequired_SoftDeletedAdmin(t *testing.T) { - repo := setupAdminTestDB(t) - ctx := context.Background() - - user := &model.User{ - ID: "admin-2", - Username: "deleted_admin", - Email: "deleted_admin@example.com", - IsAdmin: true, - Status: model.StatusActive, - } - if err := repo.Create(ctx, user); err != nil { - t.Fatalf("Create = %v", err) - } - - // Soft-delete the admin user - if err := repo.Delete(ctx, "admin-2"); err != nil { - t.Fatalf("Delete = %v", err) - } - +func TestAdminRequired_NoPrincipal(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - r.Use(injectUserIDMiddleware("admin-2")) - r.Use(AdminRequired(repo)) - r.GET("/admin", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) - - req := httptest.NewRequest(http.MethodGet, "/admin", nil) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusUnauthorized { - t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) - } -} - -func TestAdminRequired_NoUserID(t *testing.T) { - repo := setupAdminTestDB(t) - - gin.SetMode(gin.TestMode) - r := gin.New() - r.Use(AdminRequired(repo)) + r.Use(AdminRequired()) r.GET("/admin", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) diff --git a/internal/model/credential.go b/internal/model/credential.go index 9160810..f75556c 100644 --- a/internal/model/credential.go +++ b/internal/model/credential.go @@ -8,11 +8,11 @@ import ( // The primary password is stored on the User model; additional credentials // (app passkeys, WebAuthn, OAuth) are stored here with a type discriminator. type Credential struct { - ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` - UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"` - Type string `gorm:"index;type:varchar(32);not null" json:"type"` - Label string `gorm:"type:varchar(128)" json:"label"` - SecretHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"` - LastUsedAt *time.Time `json:"last_used_at"` - CreatedAt time.Time `json:"created_at"` + ID string `gorm:"primaryKey;type:varchar(36)"` + UserID string `gorm:"index;type:varchar(36);not null"` + Type string `gorm:"index;type:varchar(32);not null"` + Label string `gorm:"type:varchar(128)"` + SecretHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"` + LastUsedAt *time.Time + CreatedAt time.Time } diff --git a/internal/model/errors.go b/internal/model/errors.go index 3389528..e8a0777 100644 --- a/internal/model/errors.go +++ b/internal/model/errors.go @@ -3,7 +3,6 @@ package model import ( "errors" "fmt" - "net/http" ) var ( @@ -14,48 +13,76 @@ var ( ErrUploadTooLarge = errors.New("upload exceeds maximum size") ) -// AppError is a service-layer error that carries an HTTP status, a user-safe -// message, and an optional internal cause for logging. Handlers unpack it -// transparently via api.RespondError. +// ErrorKind classifies domain errors without tying them to a transport. +type ErrorKind string + +// Protocol-neutral error kinds. +const ( + KindInvalidArgument ErrorKind = "invalid_argument" + KindUnauthenticated ErrorKind = "unauthenticated" + KindPermissionDenied ErrorKind = "permission_denied" + KindNotFound ErrorKind = "not_found" + KindConflict ErrorKind = "conflict" + KindPayloadTooLarge ErrorKind = "payload_too_large" + KindInternal ErrorKind = "internal" +) + +// AppError is a service-layer error that carries a protocol-neutral kind, a +// user-safe message, and an optional internal cause for logging. type AppError struct { - Status int // HTTP status code - Message string // safe for API response - Err error // internal cause (nil = user-caused, skip logging) + Kind ErrorKind + Message string + Err error } func (e *AppError) Error() string { return e.Message } func (e *AppError) Unwrap() error { return e.Err } -// NewBadRequestError creates a 400 AppError. +// NewInvalidArgumentError creates an invalid-argument AppError. +func NewInvalidArgumentError(message string) *AppError { + return &AppError{Kind: KindInvalidArgument, Message: message} +} + +// NewBadRequestError creates an invalid-argument AppError. func NewBadRequestError(message string) *AppError { - return &AppError{Status: http.StatusBadRequest, Message: message} + return NewInvalidArgumentError(message) } -// NewConflictError creates a 409 AppError. +// NewUnauthenticatedError creates an unauthenticated AppError. +func NewUnauthenticatedError(message string) *AppError { + return &AppError{Kind: KindUnauthenticated, Message: message} +} + +// NewConflictError creates a conflict AppError. func NewConflictError(message string) *AppError { - return &AppError{Status: http.StatusConflict, Message: message} + return &AppError{Kind: KindConflict, Message: message} } -// NewNotFoundError creates a 404 AppError. +// NewNotFoundError creates a not-found AppError. func NewNotFoundError(message string, cause error) *AppError { - return &AppError{Status: http.StatusNotFound, Message: message, Err: cause} + return &AppError{Kind: KindNotFound, Message: message, Err: cause} } -// NewForbiddenError creates a 403 AppError. +// NewPermissionDeniedError creates a permission-denied AppError. +func NewPermissionDeniedError(message string, cause error) *AppError { + return &AppError{Kind: KindPermissionDenied, Message: message, Err: cause} +} + +// NewForbiddenError creates a permission-denied AppError. func NewForbiddenError(message string, cause error) *AppError { - return &AppError{Status: http.StatusForbidden, Message: message, Err: cause} + return NewPermissionDeniedError(message, cause) } -// NewPayloadTooLargeError creates a 413 AppError. +// NewPayloadTooLargeError creates a payload-too-large AppError. func NewPayloadTooLargeError(message string, cause error) *AppError { - return &AppError{Status: http.StatusRequestEntityTooLarge, Message: message, Err: cause} + return &AppError{Kind: KindPayloadTooLarge, Message: message, Err: cause} } -// NewInternalError creates a 500 AppError with a wrapped internal cause. +// NewInternalError creates an internal AppError with a wrapped internal cause. // msg describes the operation that failed; cause is the underlying error. func NewInternalError(msg string, cause error) *AppError { return &AppError{ - Status: http.StatusInternalServerError, + Kind: KindInternal, Message: "internal server error", Err: fmt.Errorf("%s: %w", msg, cause), } diff --git a/internal/model/errors_test.go b/internal/model/errors_test.go new file mode 100644 index 0000000..f7b4c44 --- /dev/null +++ b/internal/model/errors_test.go @@ -0,0 +1,79 @@ +package model + +import ( + "errors" + "testing" +) + +func TestAppErrorConstructors(t *testing.T) { + cause := errors.New("database offline") + + tests := []struct { + name string + err *AppError + kind ErrorKind + message string + cause error + }{ + { + name: "invalid argument", + err: NewInvalidArgumentError("bad input"), + kind: KindInvalidArgument, + message: "bad input", + }, + { + name: "unauthenticated", + err: NewUnauthenticatedError("invalid token"), + kind: KindUnauthenticated, + message: "invalid token", + }, + { + name: "permission denied", + err: NewPermissionDeniedError("access denied", ErrForbidden), + kind: KindPermissionDenied, + message: "access denied", + cause: ErrForbidden, + }, + { + name: "not found", + err: NewNotFoundError("missing", ErrNotFound), + kind: KindNotFound, + message: "missing", + cause: ErrNotFound, + }, + { + name: "conflict", + err: NewConflictError("duplicate"), + kind: KindConflict, + message: "duplicate", + }, + { + name: "payload too large", + err: NewPayloadTooLargeError("too large", ErrUploadTooLarge), + kind: KindPayloadTooLarge, + message: "too large", + cause: ErrUploadTooLarge, + }, + { + name: "internal", + err: NewInternalError("load record", cause), + kind: KindInternal, + message: "internal server error", + cause: cause, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.err.Kind != tt.kind { + t.Fatalf("Kind = %q, want %q", tt.err.Kind, tt.kind) + } + if tt.err.Message != tt.message { + t.Fatalf("Message = %q, want %q", tt.err.Message, tt.message) + } + if tt.cause != nil && !errors.Is(tt.err, tt.cause) { + t.Fatalf("errors.Is(%v, %v) = false", tt.err, tt.cause) + } + }) + } +} diff --git a/internal/model/file.go b/internal/model/file.go index 413cf0d..4951c03 100644 --- a/internal/model/file.go +++ b/internal/model/file.go @@ -9,16 +9,16 @@ const StatusUserDeleted = "user_deleted" // File represents a file or directory entry in the virtual filesystem. type File struct { - ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` - UserID string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:1;type:varchar(36);not null" json:"user_id"` - ParentID *string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:2;type:varchar(36)" json:"parent_id"` - Name string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_parent_name,priority:3" json:"name"` - Size int64 `gorm:"default:0" json:"size"` - MimeType string `gorm:"type:varchar(127)" json:"mime_type"` - StoragePath string `gorm:"type:varchar(512)" json:"storage_path"` - Hash string `gorm:"type:varchar(64)" json:"-"` - Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"` - IsDir bool `gorm:"default:false" json:"is_dir"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `gorm:"primaryKey;type:varchar(36)"` + UserID string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:1;type:varchar(36);not null"` + ParentID *string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:2;type:varchar(36)"` + Name string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_parent_name,priority:3"` + Size int64 `gorm:"default:0"` + MimeType string `gorm:"type:varchar(127)"` + StoragePath string `gorm:"type:varchar(512)" json:"-"` + Hash string `gorm:"type:varchar(64)" json:"-"` + Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"` + IsDir bool `gorm:"default:false"` + CreatedAt time.Time + UpdatedAt time.Time } diff --git a/internal/model/file_test.go b/internal/model/file_test.go index 9ff7711..c437da9 100644 --- a/internal/model/file_test.go +++ b/internal/model/file_test.go @@ -6,18 +6,20 @@ import ( "time" ) -func TestFile_StatusExcludedFromJSON(t *testing.T) { +func TestFileJSONOmitsInternalFields(t *testing.T) { f := &File{ - ID: "1", - UserID: "u1", - ParentID: nil, - Name: "test.txt", - Size: 100, - MimeType: "text/plain", - Status: StatusActive, - IsDir: false, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: "1", + UserID: "u1", + ParentID: nil, + Name: "test.txt", + Size: 100, + MimeType: "text/plain", + StoragePath: "users/u1/files/1", + Hash: "abc123", + Status: StatusActive, + IsDir: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } data, err := json.Marshal(f) @@ -30,13 +32,11 @@ func TestFile_StatusExcludedFromJSON(t *testing.T) { t.Fatalf("json.Unmarshal: %v", err) } - if _, ok := result["status"]; ok { - t.Errorf("Status field should be excluded from JSON (json:\"-\"), but it appeared in output") - } - - if _, ok := result["hash"]; ok { - t.Errorf("Hash field should be excluded from JSON (json:\"-\"), but it appeared in output") - } + assertJSONKeysAbsent(t, result, + "StoragePath", "storage_path", + "Hash", "hash", + "Status", "status", + ) } func TestStatusConstants(t *testing.T) { diff --git a/internal/model/session.go b/internal/model/session.go index 736b18f..c60f6cc 100644 --- a/internal/model/session.go +++ b/internal/model/session.go @@ -6,9 +6,9 @@ import ( // Session stores a refresh token for a user session. type Session struct { - ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` - UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"` + ID string `gorm:"primaryKey;type:varchar(36)"` + UserID string `gorm:"index;type:varchar(36);not null"` TokenHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"` - ExpiresAt time.Time `gorm:"not null" json:"expires_at"` - CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `gorm:"not null"` + CreatedAt time.Time } diff --git a/internal/model/user.go b/internal/model/user.go index 77673bd..79f47f3 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -6,14 +6,14 @@ import ( // User represents a registered account. type User struct { - ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` - Username string `gorm:"uniqueIndex;type:varchar(64);not null" json:"username"` - Email string `gorm:"uniqueIndex;type:varchar(255);not null" json:"email"` - PasswordHash string `gorm:"type:varchar(255);not null" json:"-"` - IsAdmin bool `gorm:"default:false" json:"is_admin"` - Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `gorm:"primaryKey;type:varchar(36)"` + Username string `gorm:"uniqueIndex;type:varchar(64);not null"` + Email string `gorm:"uniqueIndex;type:varchar(255);not null"` + PasswordHash string `gorm:"type:varchar(255);not null" json:"-"` + IsAdmin bool `gorm:"default:false"` + Status string `gorm:"type:varchar(32);not null;default:active;index"` + CreatedAt time.Time + UpdatedAt time.Time } // User status constants. diff --git a/internal/model/user_test.go b/internal/model/user_test.go index e63fca6..b41bc76 100644 --- a/internal/model/user_test.go +++ b/internal/model/user_test.go @@ -5,12 +5,13 @@ import ( "testing" ) -func TestUserJSONOmitsStatusField(t *testing.T) { +func TestUserJSONOmitsPasswordHash(t *testing.T) { u := User{ - ID: "user-1", - Username: "alice", - Email: "alice@example.com", - Status: StatusActive, + ID: "user-1", + Username: "alice", + Email: "alice@example.com", + PasswordHash: "hash-secret", + Status: StatusActive, } raw, err := json.Marshal(u) @@ -23,7 +24,57 @@ func TestUserJSONOmitsStatusField(t *testing.T) { t.Fatalf("json.Unmarshal: %v", err) } - if _, ok := m["status"]; ok { - t.Error("Status field should not appear in JSON output") + assertJSONKeysAbsent(t, m, "PasswordHash", "password_hash") +} + +func TestSessionJSONOmitsTokenHash(t *testing.T) { + s := Session{ + ID: "session-1", + UserID: "user-1", + TokenHash: "token-hash-secret", + } + + raw, err := json.Marshal(s) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + + assertJSONKeysAbsent(t, m, "TokenHash", "token_hash") +} + +func TestCredentialJSONOmitsSecretHash(t *testing.T) { + c := Credential{ + ID: "credential-1", + UserID: "user-1", + Type: "app_passkey", + Label: "Phone", + SecretHash: "credential-secret", + } + + raw, err := json.Marshal(c) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + + assertJSONKeysAbsent(t, m, "SecretHash", "secret_hash") +} + +func assertJSONKeysAbsent(t *testing.T, m map[string]interface{}, keys ...string) { + t.Helper() + + for _, key := range keys { + if _, ok := m[key]; ok { + t.Errorf("%s field should not appear in JSON output", key) + } } } diff --git a/internal/server/routes_protected.go b/internal/server/routes_protected.go index 4c889c1..7feba08 100644 --- a/internal/server/routes_protected.go +++ b/internal/server/routes_protected.go @@ -9,12 +9,11 @@ import ( ) func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) { - jwtSecret := []byte(webApp.Config.JWT.Secret) accountHandler := handler.NewAccountHandler(webApp.AuthService) fileHandler := handler.NewFileHandler(webApp.FileService) - adminHandler := handler.NewAdminHandler(webApp.UserRepo) + adminHandler := handler.NewAdminHandler(webApp.AdminService) - rg.Use(middleware.AuthRequired(jwtSecret)) + rg.Use(middleware.AuthRequired(webApp.AuthService)) account := rg.Group("/account") { @@ -39,7 +38,7 @@ func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) { } admin := rg.Group("/admin") - admin.Use(middleware.AdminRequired(webApp.UserRepo)) + admin.Use(middleware.AdminRequired()) { admin.GET("/users", adminHandler.ListUsers) admin.GET("/users/:id", adminHandler.GetUser) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6012a11..12dd639 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -25,7 +25,7 @@ func TestVersionRoute(t *testing.T) { }, } authService := service.NewAuthService(nil, nil, nil, nil, 15*time.Minute, 7*24*time.Hour) - webApp := app.NewWebApp(cfg, nil, nil, nil, nil, nil, authService, nil, nil) + webApp := app.NewWebApp(cfg, nil, nil, nil, nil, nil, authService, nil, nil, nil) router := NewRouter(webApp) req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil) @@ -84,6 +84,7 @@ func TestAdminRoutes(t *testing.T) { refreshTTL := 168 * time.Hour authService := service.NewAuthService(userRepo, sessionRepo, credentialRepo, jwtSecret, accessTTL, refreshTTL) + adminService := service.NewAdminService(userRepo) cfg := &config.Config{ JWT: config.JWTConfig{ @@ -93,7 +94,7 @@ func TestAdminRoutes(t *testing.T) { }, } - webApp := app.NewWebApp(cfg, db, userRepo, sessionRepo, nil, credentialRepo, authService, nil, nil) + webApp := app.NewWebApp(cfg, db, userRepo, sessionRepo, nil, credentialRepo, authService, adminService, nil, nil) router := NewRouter(webApp) // Helper: register a user via POST /api/v1/auth/register and return the user ID. diff --git a/internal/service/admin.go b/internal/service/admin.go new file mode 100644 index 0000000..0adf922 --- /dev/null +++ b/internal/service/admin.go @@ -0,0 +1,51 @@ +package service + +import ( + "context" + "errors" + + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/repository" +) + +// AdminService handles administrator user-management operations. +type AdminService struct { + userRepo repository.UserRepository +} + +// NewAdminService creates an AdminService. +func NewAdminService(userRepo repository.UserRepository) *AdminService { + return &AdminService{userRepo: userRepo} +} + +// GetUser returns a user by ID, including deleted users. +func (s *AdminService) GetUser(ctx context.Context, id string) (*model.User, error) { + user, err := s.userRepo.FindByIDIncludeDeleted(ctx, id) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return nil, model.NewNotFoundError("user not found", model.ErrNotFound) + } + return nil, model.NewInternalError("find admin user", err) + } + return user, nil +} + +// ListUsers returns all users, including deleted users, with pagination. +func (s *AdminService) ListUsers(ctx context.Context, offset, limit int) ([]model.User, int64, error) { + users, total, err := s.userRepo.ListIncludeDeleted(ctx, offset, limit) + if err != nil { + return nil, 0, model.NewInternalError("list admin users", err) + } + return users, total, nil +} + +// DeleteUser soft-deletes a user. +func (s *AdminService) DeleteUser(ctx context.Context, id string) error { + if _, err := s.GetUser(ctx, id); err != nil { + return err + } + if err := s.userRepo.Delete(ctx, id); err != nil { + return model.NewInternalError("delete admin user", err) + } + return nil +} diff --git a/internal/service/admin_test.go b/internal/service/admin_test.go new file mode 100644 index 0000000..796d9b4 --- /dev/null +++ b/internal/service/admin_test.go @@ -0,0 +1,69 @@ +package service + +import ( + "context" + "errors" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/repository" +) + +func setupAdminService(t *testing.T) (*AdminService, repository.UserRepository) { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.User{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + userRepo := repository.NewUserRepository(db) + return NewAdminService(userRepo), userRepo +} + +func TestAdminServiceGetUserMissingReturnsNotFound(t *testing.T) { + svc, _ := setupAdminService(t) + + _, err := svc.GetUser(context.Background(), "missing") + var ae *model.AppError + if !errors.As(err, &ae) { + t.Fatalf("expected AppError, got %T: %v", err, err) + } + if ae.Kind != model.KindNotFound { + t.Fatalf("kind = %q, want %q", ae.Kind, model.KindNotFound) + } +} + +func TestAdminServiceListIncludesDeletedUsers(t *testing.T) { + svc, repo := setupAdminService(t) + ctx := context.Background() + + active := &model.User{ID: "active-1", Username: "active", Email: "active@example.com", Status: model.StatusActive} + deleted := &model.User{ID: "deleted-1", Username: "deleted", Email: "deleted@example.com", Status: model.StatusActive} + if err := repo.Create(ctx, active); err != nil { + t.Fatalf("create active: %v", err) + } + if err := repo.Create(ctx, deleted); err != nil { + t.Fatalf("create deleted: %v", err) + } + if err := repo.Delete(ctx, deleted.ID); err != nil { + t.Fatalf("delete user: %v", err) + } + + users, total, err := svc.ListUsers(ctx, 0, 10) + if err != nil { + t.Fatalf("ListUsers = %v", err) + } + if total != 2 { + t.Fatalf("total = %d, want 2", total) + } + if len(users) != 2 { + t.Fatalf("len(users) = %d, want 2", len(users)) + } +} diff --git a/internal/service/auth.go b/internal/service/auth.go index 584ce04..d55340d 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -3,7 +3,6 @@ package service import ( "context" "errors" - "net/http" "strings" "time" @@ -16,15 +15,21 @@ import ( // TokenPair contains the access and refresh tokens returned after authentication. type TokenPair struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` + AccessToken string + RefreshToken string } // CreatedPasskey contains the raw token for a newly created app passkey. type CreatedPasskey struct { - ID string `json:"id"` - Raw string `json:"raw"` - Label string `json:"label"` + ID string + Raw string + Label string +} + +// Principal is the authenticated user identity used by transport layers. +type Principal struct { + UserID string + IsAdmin bool } // AuthService handles user authentication and session management. @@ -59,7 +64,7 @@ func NewAuthService( // Register creates a new user account. func (s *AuthService) Register(ctx context.Context, username, email, password string) (*model.User, error) { if username == "" || email == "" || password == "" { - return nil, &model.AppError{Status: http.StatusBadRequest, Message: "username, email, and password are required"} + return nil, model.NewInvalidArgumentError("username, email, and password are required") } passwordHash, err := auth.HashPassword(password) @@ -77,7 +82,7 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st if err := s.userRepo.Create(ctx, user); err != nil { if errors.Is(err, model.ErrDuplicate) { - return nil, &model.AppError{Status: http.StatusConflict, Message: "username or email already exists"} + return nil, model.NewConflictError("username or email already exists") } return nil, model.NewInternalError("create user", err) } @@ -90,17 +95,17 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token user, err := s.userRepo.FindByEmail(ctx, email) if err != nil { if errors.Is(err, model.ErrNotFound) { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"} + return nil, model.NewUnauthenticatedError("invalid email or password") } return nil, model.NewInternalError("find user", err) } if user.Status != model.StatusActive { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"} + return nil, model.NewUnauthenticatedError("invalid email or password") } if err := auth.VerifyPassword(user.PasswordHash, password); err != nil { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"} + return nil, model.NewUnauthenticatedError("invalid email or password") } return s.issueTokens(ctx, user.ID) @@ -111,32 +116,32 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*TokenPair, error) { claims, err := auth.ParseToken(refreshTokenStr, s.jwtSecret) if err != nil { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} + return nil, model.NewUnauthenticatedError("invalid token") } if claims.Type != auth.TokenRefresh { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} + return nil, model.NewUnauthenticatedError("invalid token") } tokenHash := auth.HashToken(refreshTokenStr) session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash) if err != nil { if errors.Is(err, model.ErrNotFound) { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} + return nil, model.NewUnauthenticatedError("invalid token") } return nil, model.NewInternalError("find session", err) } if session.UserID != claims.UserID { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} + return nil, model.NewUnauthenticatedError("invalid token") } user, err := s.userRepo.FindByID(ctx, claims.UserID) if err != nil { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} + return nil, model.NewUnauthenticatedError("invalid token") } if user.Status != model.StatusActive { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} + return nil, model.NewUnauthenticatedError("invalid token") } if err := s.sessionRepo.Delete(ctx, session.ID); err != nil { @@ -189,14 +194,14 @@ func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) ( // LoginWithPasskey authenticates a user using an app passkey token. func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*TokenPair, error) { if !strings.HasPrefix(tokenStr, "mygo_") { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey format"} + return nil, model.NewUnauthenticatedError("invalid passkey format") } tokenHash := auth.HashToken(tokenStr) cred, err := s.credentialRepo.FindByHash(ctx, tokenHash) if err != nil { if errors.Is(err, model.ErrNotFound) { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"} + return nil, model.NewUnauthenticatedError("invalid passkey") } return nil, model.NewInternalError("find credential", err) } @@ -206,11 +211,11 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T return nil, model.NewInternalError("find user", err) } if user.Status != model.StatusActive { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"} + return nil, model.NewUnauthenticatedError("invalid passkey") } if cred.Type != "app_passkey" { - return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"} + return nil, model.NewUnauthenticatedError("invalid credential type") } if err := s.credentialRepo.UpdateLastUsed(ctx, cred.ID); err != nil { @@ -230,18 +235,46 @@ func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string) cred, err := s.credentialRepo.FindByID(ctx, credID) if err != nil { if errors.Is(err, model.ErrNotFound) { - return &model.AppError{Status: http.StatusNotFound, Message: "passkey not found", Err: model.ErrNotFound} + return model.NewNotFoundError("passkey not found", model.ErrNotFound) } return model.NewInternalError("find credential", err) } if cred.UserID != userID { - return &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden} + return model.NewPermissionDeniedError("access denied", model.ErrForbidden) } return s.credentialRepo.Delete(ctx, credID) } +// AuthenticateAccessToken validates an access token and returns the active user principal. +func (s *AuthService) AuthenticateAccessToken(ctx context.Context, accessTokenStr string) (*Principal, error) { + claims, err := auth.ParseToken(accessTokenStr, s.jwtSecret) + if err != nil { + return nil, model.NewUnauthenticatedError("invalid or expired token") + } + + if claims.Type != auth.TokenAccess { + return nil, model.NewUnauthenticatedError("invalid token type") + } + + user, err := s.userRepo.FindByID(ctx, claims.UserID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return nil, model.NewUnauthenticatedError("user not found") + } + return nil, model.NewInternalError("find access token user", err) + } + if user.Status != model.StatusActive { + return nil, model.NewUnauthenticatedError("user not found") + } + + return &Principal{ + UserID: user.ID, + IsAdmin: user.IsAdmin, + }, nil +} + func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPair, error) { accessToken, err := auth.GenerateAccessToken(userID, s.jwtSecret, s.accessTTL) if err != nil { diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go index 767edce..cb126f1 100644 --- a/internal/service/auth_test.go +++ b/internal/service/auth_test.go @@ -3,7 +3,6 @@ package service import ( "context" "errors" - "net/http" "testing" "time" @@ -347,8 +346,8 @@ func TestAuthService_RevokePasskeyNotOwner(t *testing.T) { err = svc.RevokePasskey(ctx, claimsBob.UserID, pk.ID) var ae *model.AppError - if !errors.As(err, &ae) || ae.Status != http.StatusForbidden { - t.Fatalf("expected AppError 403 Forbidden, got %v", err) + if !errors.As(err, &ae) || ae.Kind != model.KindPermissionDenied { + t.Fatalf("expected permission denied AppError, got %v", err) } } @@ -434,8 +433,8 @@ func TestAuthService_LoginDisabledUser(t *testing.T) { if !errors.As(err, &ae) { t.Fatalf("expected AppError, got %T: %v", err, err) } - if ae.Status != http.StatusUnauthorized { - t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized) + if ae.Kind != model.KindUnauthenticated { + t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated) } if ae.Message != "invalid email or password" { t.Errorf("message = %q, want %q", ae.Message, "invalid email or password") @@ -519,8 +518,8 @@ func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) { if !errors.As(err, &ae) { t.Fatalf("expected AppError, got %T: %v", err, err) } - if ae.Status != http.StatusUnauthorized { - t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized) + if ae.Kind != model.KindUnauthenticated { + t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated) } if ae.Message != "invalid passkey" { t.Errorf("message = %q, want %q", ae.Message, "invalid passkey") @@ -554,10 +553,68 @@ func TestAuthService_RefreshDisabledUser(t *testing.T) { if !errors.As(err, &ae) { t.Fatalf("expected AppError, got %T: %v", err, err) } - if ae.Status != http.StatusUnauthorized { - t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized) + if ae.Kind != model.KindUnauthenticated { + t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated) } if ae.Message != "invalid token" { t.Errorf("message = %q, want %q", ae.Message, "invalid token") } } + +func TestAuthService_AuthenticateAccessToken(t *testing.T) { + svc, userRepo := setupAuthServiceWithRepos(t) + ctx := context.Background() + + user, err := svc.Register(ctx, "alice", "alice@example.com", "password123") + if err != nil { + t.Fatalf("Register = %v", err) + } + user.IsAdmin = true + if err := userRepo.Update(ctx, user); err != nil { + t.Fatalf("promote user: %v", err) + } + + pair, err := svc.Login(ctx, "alice@example.com", "password123") + if err != nil { + t.Fatalf("Login = %v", err) + } + + principal, err := svc.AuthenticateAccessToken(ctx, pair.AccessToken) + if err != nil { + t.Fatalf("AuthenticateAccessToken = %v", err) + } + if principal.UserID != user.ID { + t.Fatalf("UserID = %q, want %q", principal.UserID, user.ID) + } + if !principal.IsAdmin { + t.Fatal("IsAdmin = false, want true") + } +} + +func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) { + svc, userRepo := setupAuthServiceWithRepos(t) + ctx := context.Background() + + user, err := svc.Register(ctx, "alice", "alice@example.com", "password123") + if err != nil { + t.Fatalf("Register = %v", err) + } + + pair, err := svc.Login(ctx, "alice@example.com", "password123") + if err != nil { + t.Fatalf("Login = %v", err) + } + + if err := userRepo.Delete(ctx, user.ID); err != nil { + t.Fatalf("Delete = %v", err) + } + + _, err = svc.AuthenticateAccessToken(ctx, pair.AccessToken) + var ae *model.AppError + if !errors.As(err, &ae) { + t.Fatalf("expected AppError, got %T: %v", err, err) + } + if ae.Kind != model.KindUnauthenticated { + t.Fatalf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated) + } +} diff --git a/internal/service/file.go b/internal/service/file.go index a2911a2..25adc1c 100644 --- a/internal/service/file.go +++ b/internal/service/file.go @@ -22,22 +22,22 @@ import ( // FileInfo is the public representation of a file or directory. type FileInfo struct { - ID string `json:"id"` - UserID string `json:"user_id"` - ParentID *string `json:"parent_id"` - Name string `json:"name"` - Size int64 `json:"size"` - MimeType string `json:"mime_type"` - IsDir bool `json:"is_dir"` - Hash string `json:"hash,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string + UserID string + ParentID *string + Name string + Size int64 + MimeType string + IsDir bool + Hash string + CreatedAt time.Time + UpdatedAt time.Time } // FileList is a paginated list of files. type FileList struct { - Files []FileInfo `json:"files"` - Total int64 `json:"total"` + Files []FileInfo + Total int64 } // FileService handles file management business logic. diff --git a/internal/service/file_test.go b/internal/service/file_test.go index debc957..655dabb 100644 --- a/internal/service/file_test.go +++ b/internal/service/file_test.go @@ -5,7 +5,6 @@ import ( "context" "errors" "io" - "net/http" "strings" "sync" "testing" @@ -18,16 +17,16 @@ import ( "github.com/dhao2001/mygo/internal/storage" ) -// assertAppError checks that err is an *model.AppError with the expected status. -func assertAppError(t *testing.T, err error, wantStatus int) bool { +// assertAppError checks that err is an *model.AppError with the expected kind. +func assertAppError(t *testing.T, err error, wantKind model.ErrorKind) bool { t.Helper() var ae *model.AppError if !errors.As(err, &ae) { t.Errorf("expected *model.AppError, got %T: %v", err, err) return false } - if ae.Status != wantStatus { - t.Errorf("status = %d, want %d; message = %q", ae.Status, wantStatus, ae.Message) + if ae.Kind != wantKind { + t.Errorf("kind = %q, want %q; message = %q", ae.Kind, wantKind, ae.Message) return false } return true @@ -151,7 +150,7 @@ func TestFileService_UploadRejectsOversizedFileAndCleansStaging(t *testing.T) { ctx := context.Background() _, err := svc.Upload(ctx, "user1", nil, "too-large.txt", strings.NewReader("123456")) - assertAppError(t, err, http.StatusRequestEntityTooLarge) + assertAppError(t, err, model.KindPayloadTooLarge) store.mu.RLock() defer store.mu.RUnlock() @@ -285,7 +284,7 @@ func TestFileService_DownloadForbidden(t *testing.T) { } _, _, err = svc.Download(ctx, "user2", info.ID) - assertAppError(t, err, http.StatusForbidden) + assertAppError(t, err, model.KindPermissionDenied) } func TestFileService_DownloadDirectory(t *testing.T) { @@ -326,7 +325,7 @@ func TestFileService_GetNotFound(t *testing.T) { ctx := context.Background() _, err := svc.Get(ctx, "user1", "nonexistent") - assertAppError(t, err, http.StatusNotFound) + assertAppError(t, err, model.KindNotFound) } func TestFileService_GetForbidden(t *testing.T) { @@ -339,7 +338,7 @@ func TestFileService_GetForbidden(t *testing.T) { } _, err = svc.Get(ctx, "user2", info.ID) - assertAppError(t, err, http.StatusForbidden) + assertAppError(t, err, model.KindPermissionDenied) } func TestFileService_List(t *testing.T) { @@ -437,7 +436,7 @@ func TestFileService_Delete(t *testing.T) { } _, err = svc.Get(ctx, "user1", info.ID) - assertAppError(t, err, http.StatusNotFound) + assertAppError(t, err, model.KindNotFound) } func TestFileService_DeleteNonEmptyDirectory(t *testing.T) { @@ -469,7 +468,7 @@ func TestFileService_DeleteForbidden(t *testing.T) { } err = svc.Delete(ctx, "user2", info.ID) - assertAppError(t, err, http.StatusForbidden) + assertAppError(t, err, model.KindPermissionDenied) } func TestFileService_CreateDir(t *testing.T) { @@ -513,7 +512,7 @@ func TestFileService_VerifyParentForbidden(t *testing.T) { } _, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data")) - assertAppError(t, err, http.StatusForbidden) + assertAppError(t, err, model.KindPermissionDenied) } func TestFileService_SoftDelete(t *testing.T) { @@ -530,7 +529,7 @@ func TestFileService_SoftDelete(t *testing.T) { } _, err = svc.Get(ctx, "user1", info.ID) - assertAppError(t, err, http.StatusNotFound) + assertAppError(t, err, model.KindNotFound) } func TestFileService_SoftDeleteKeepsStorage(t *testing.T) { @@ -590,5 +589,5 @@ func TestFileService_SoftDeleteForbidden(t *testing.T) { } err = svc.Delete(ctx, "user2", info.ID) - assertAppError(t, err, http.StatusForbidden) + assertAppError(t, err, model.KindPermissionDenied) } From 3ad61244dc2b5e129800f204ba337b0dbad187e4 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sun, 5 Jul 2026 23:57:19 +0800 Subject: [PATCH 25/31] fix: properly handle repository errors and remove unused hash field - fix: wrap errors from sessionRepo.Delete, credentialRepo operations with internal error context in Logout, ListPasskeys, and RevokePasskey - fix: remove Hash field from fileInfoResponse DTO (unused) --- internal/handler/file.go | 2 -- internal/service/auth.go | 16 +++++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/internal/handler/file.go b/internal/handler/file.go index 97d1193..d058ab3 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -61,7 +61,6 @@ type fileInfoResponse struct { Size int64 `json:"size"` MimeType string `json:"mime_type"` IsDir bool `json:"is_dir"` - Hash string `json:"hash,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -343,7 +342,6 @@ func toFileInfoResponse(info *service.FileInfo) fileInfoResponse { Size: info.Size, MimeType: info.MimeType, IsDir: info.IsDir, - Hash: info.Hash, CreatedAt: info.CreatedAt, UpdatedAt: info.UpdatedAt, } diff --git a/internal/service/auth.go b/internal/service/auth.go index d55340d..6ab7d3c 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -162,7 +162,10 @@ func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error return model.NewInternalError("find session", err) } - return s.sessionRepo.Delete(ctx, session.ID) + if err := s.sessionRepo.Delete(ctx, session.ID); err != nil { + return model.NewInternalError("delete session", err) + } + return nil } // CreatePasskey creates a new app passkey for the authenticated user. @@ -227,7 +230,11 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T // ListPasskeys returns all app passkeys for a user. func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) { - return s.credentialRepo.FindByUserIDAndType(ctx, userID, "app_passkey") + creds, err := s.credentialRepo.FindByUserIDAndType(ctx, userID, "app_passkey") + if err != nil { + return nil, model.NewInternalError("list passkeys", err) + } + return creds, nil } // RevokePasskey deletes an app passkey owned by the user. @@ -244,7 +251,10 @@ func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string) return model.NewPermissionDeniedError("access denied", model.ErrForbidden) } - return s.credentialRepo.Delete(ctx, credID) + if err := s.credentialRepo.Delete(ctx, credID); err != nil { + return model.NewInternalError("delete credential", err) + } + return nil } // AuthenticateAccessToken validates an access token and returns the active user principal. From 803f195af1bccd215f000837087d4d09dc0a645d Mon Sep 17 00:00:00 2001 From: Huxley Date: Mon, 6 Jul 2026 00:11:06 +0800 Subject: [PATCH 26/31] refactor: rename internal/log to internal/logging to avoid stdlib shadowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor: rename `internal/log` β†’ `internal/logging`, package `log` β†’ `logging` - refactor: update imports in `cmd/serve.go` and `internal/middleware/requestid.go` --- cmd/serve.go | 4 ++-- internal/{log => logging}/context.go | 2 +- internal/{log => logging}/logger.go | 2 +- internal/middleware/requestid.go | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) rename internal/{log => logging}/context.go (98%) rename internal/{log => logging}/logger.go (99%) diff --git a/cmd/serve.go b/cmd/serve.go index 2426de5..fa58a22 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -12,7 +12,7 @@ import ( "github.com/dhao2001/mygo/internal/app" "github.com/dhao2001/mygo/internal/config" - mygolog "github.com/dhao2001/mygo/internal/log" + "github.com/dhao2001/mygo/internal/logging" "github.com/dhao2001/mygo/internal/server" ) @@ -29,7 +29,7 @@ var serveCmd = &cobra.Command{ } // Set up structured logging before anything else. - appLogger := mygolog.NewLogger(cfg.Log) + appLogger := logging.NewLogger(cfg.Log) slog.SetDefault(appLogger) slog.Info("mygo server starting") if loadInfo.EphemeralJWTSecret { diff --git a/internal/log/context.go b/internal/logging/context.go similarity index 98% rename from internal/log/context.go rename to internal/logging/context.go index 99f6082..83ba33a 100644 --- a/internal/log/context.go +++ b/internal/logging/context.go @@ -1,4 +1,4 @@ -package log +package logging import ( "context" diff --git a/internal/log/logger.go b/internal/logging/logger.go similarity index 99% rename from internal/log/logger.go rename to internal/logging/logger.go index dfed508..4f3c96f 100644 --- a/internal/log/logger.go +++ b/internal/logging/logger.go @@ -1,4 +1,4 @@ -package log +package logging import ( "context" diff --git a/internal/middleware/requestid.go b/internal/middleware/requestid.go index 2507291..1f86379 100644 --- a/internal/middleware/requestid.go +++ b/internal/middleware/requestid.go @@ -4,7 +4,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - mygolog "github.com/dhao2001/mygo/internal/log" + "github.com/dhao2001/mygo/internal/logging" ) // RequestID returns a Gin middleware that ensures every request has a @@ -19,7 +19,7 @@ func RequestID() gin.HandlerFunc { } // Inject into Go context for slog.*Context calls. - c.Request = c.Request.WithContext(mygolog.WithRequestID(c.Request.Context(), reqID)) + c.Request = c.Request.WithContext(logging.WithRequestID(c.Request.Context(), reqID)) // Also set in Gin context for direct access. c.Set("req_id", reqID) From 5674fa2eb5dbcef1ca0743f25c456e16525a3163 Mon Sep 17 00:00:00 2001 From: Huxley Date: Tue, 14 Jul 2026 11:55:16 +0800 Subject: [PATCH 27/31] skill: add mygo-api-repo-review skill. --- .agents/skills/mygo-api-repo-review/SKILL.md | 85 +++++++++++++++++++ .../mygo-api-repo-review/agents/openai.yaml | 6 ++ .../references/report-template.md | 52 ++++++++++++ .../references/review-calibration.md | 27 ++++++ AGENTS.md | 6 ++ docs/development.md | 16 ++++ 6 files changed, 192 insertions(+) create mode 100644 .agents/skills/mygo-api-repo-review/SKILL.md create mode 100644 .agents/skills/mygo-api-repo-review/agents/openai.yaml create mode 100644 .agents/skills/mygo-api-repo-review/references/report-template.md create mode 100644 .agents/skills/mygo-api-repo-review/references/review-calibration.md diff --git a/.agents/skills/mygo-api-repo-review/SKILL.md b/.agents/skills/mygo-api-repo-review/SKILL.md new file mode 100644 index 0000000..d2b5edd --- /dev/null +++ b/.agents/skills/mygo-api-repo-review/SKILL.md @@ -0,0 +1,85 @@ +--- +name: mygo-api-repo-review +description: Review the MyGO backend repository for material, evidence-backed architecture, security, resilience, data-consistency, performance, and implementation flaws. Use only when explicitly asked to run a MyGO repository audit in diff, main, or full mode, optionally limited to a target path, package, or component; do not use for ordinary implementation or debugging tasks. +--- + +# MyGO API Repository Review + +Perform a read-only, evidence-first review of the MyGO backend. Find real defects and systemic risks without manufacturing findings from stylistic preferences, unsupported hypotheticals, or intentional design choices. + +## Resolve the review scope + +Resolve two independent inputs: `mode` and `target`. + +- Default `mode` to `diff` when the user does not provide one. +- Default `target` to `all`. +- Treat `partly` as a request for a limited `target`, not as a fourth mode. Require or safely infer the path, Go package, or logical component to inspect. + +Use these mode semantics: + +- `diff`: compare the current workspace with `HEAD`. Include staged changes, unstaged changes, and relevant untracked files. +- `main`: compare the current workspace with the merge base of `HEAD` and the local `main` branch. Record the local `main` and merge-base commit IDs. Do not fetch or silently substitute a remote ref. If local `main` is unavailable, report the limitation. +- `full`: inspect the current repository as a whole, including tracked code, tests, configuration, and documentation plus relevant untracked source files. Exclude repository metadata, caches, runtime data, build outputs, and generated artifacts unless they are directly relevant. + +For a limited target, inspect not only the named files but also the directly affected callers, dependencies, interfaces, tests, configuration, and documentation needed to judge the target correctly. + +## Preserve the workspace + +Keep the review read-only unless the user separately asks for fixes. + +1. Capture the initial branch, `HEAD`, and complete Git status, including untracked files. +2. Do not run commands intended to rewrite source or dependency files, including `go fmt ./...` and `go mod tidy`. +3. Do not fetch, pull, commit, stage, or discard changes. +4. Capture Git status again before reporting. If the workspace changed, identify the change and do not erase it. + +## Establish context + +Read `AGENTS.md`, `docs/roadmap.md`, `docs/architecture.md`, `docs/decisions.md`, and `docs/development.md` before judging the implementation. Treat documented decisions as intent, not as proof that their implementation is correct. + +Map the reviewed packages, dependency direction, public entry points, state owners, external resources, and important lifecycle operations. Use `rg` and targeted file reads; do not read `go.sum` in full. + +## Review objective + +Find material, evidence-backed flaws in the repository's architecture, design, and implementation. Prioritize problems that can meaningfully harm correctness, security, resilience, data integrity, scalability, extensibility, decoupling, customizability, or maintainability. Favor systemic causes and cross-component interactions over local code smells. + +Do not begin from a predetermined list of expected bugs. Reconstruct intended behavior, boundaries, invariants, state transitions, and trust assumptions from code, tests, configuration, architecture documentation, technical decisions, and the roadmap. + +## Analyze in phases + +1. Build a working system model before producing findings. +2. Perform an open-ended pass by tracing important control, data, state, ownership, resource, and failure flows across component boundaries. +3. Perform a coverage pass using the review lenses below. +4. Challenge each candidate by seeking evidence that the behavior is intentional, unreachable, contained, or already protected. +5. Classify and report findings only after discovery and falsification. + +Treat these lenses as minimum coverage, not as an exhaustive checklist, required taxonomy, or finding quota: + +- **Architecture and evolution:** Evaluate whether responsibilities, dependencies, boundaries, data flows, or resource use create material obstacles to supported behavior, realistic scaling, roadmap work, extensibility, decoupling, customization, or maintenance. +- **Security:** Evaluate whether trust, identity, authorization, ownership, confidentiality, integrity, availability, or exposure assumptions can be violated through a reachable execution path or component interaction. +- **Resilience:** Evaluate whether plausible partial failures, dependency failures, cancellation, concurrency, malformed inputs, or interrupted lifecycle operations can cause disproportionate impact, cascading failure, loss of service, or an unrecoverable state. +- **Data consistency:** Evaluate whether persistent state, external resources, ownership, and lifecycle transitions preserve required invariants across normal, concurrent, retried, interrupted, and partially failed execution paths. + +Continue searching for material problems outside these lenses. Do not manufacture a finding for every lens. + +## Use parallel review selectively + +Keep small `diff` and narrowly targeted reviews in the main agent by default. For a broad `main` or `full` review, use read-only subagents when they are available and parallel review materially improves coverage. + +Give every reviewer the complete, non-exhaustive review objective and one primary emphasis such as architecture and performance, security, or resilience and data consistency. Make the emphasis a starting perspective rather than an exclusive boundary. Wait for all reviewers, then independently verify, deduplicate, and rank their candidates in the main agent. + +## Verify deterministically + +Run the repository-wide checks below unless the user explicitly limits command execution or the environment prevents them: + +- `go build ./...` +- `go vet ./...` +- `go test ./...` +- a non-mutating `gofmt -l` check over tracked Go files + +Record each check and its result. When a test fails, inspect the test first and follow the debugging principles in `AGENTS.md`. Do not convert a command failure into an architecture finding without validating its cause. Put checks that cannot run and their reasons under `Unverified areas`. + +## Filter and report + +After discovering candidate findings, read [review-calibration.md](references/review-calibration.md) and apply its evidence and falsification rules. Before writing the final response, read [report-template.md](references/report-template.md) and follow it exactly. + +Report a candidate only when another developer can verify its location, causal path, and material impact. Place unresolved hypotheses under `Unverified areas` with the evidence needed to resolve them. If no candidate qualifies, state `Findings: None` rather than lowering the threshold. diff --git a/.agents/skills/mygo-api-repo-review/agents/openai.yaml b/.agents/skills/mygo-api-repo-review/agents/openai.yaml new file mode 100644 index 0000000..57ccf78 --- /dev/null +++ b/.agents/skills/mygo-api-repo-review/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "MyGO API Repository Review" + short_description: "Evidence-based MyGO backend repository review" + default_prompt: "Use $mygo-api-repo-review to review the current working tree against HEAD." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/mygo-api-repo-review/references/report-template.md b/.agents/skills/mygo-api-repo-review/references/report-template.md new file mode 100644 index 0000000..11da834 --- /dev/null +++ b/.agents/skills/mygo-api-repo-review/references/report-template.md @@ -0,0 +1,52 @@ +# Review Report Template + +Write the report in the language requested by the user; otherwise match the language of the review request. Preserve source identifiers, commands, file paths, and code symbols exactly. + +Use category-based finding IDs: + +- `ARCH`: architecture and evolution +- `SEC`: security +- `REL`: resilience +- `DATA`: data consistency +- `PERF`: performance and scaling +- `IMPL`: implementation correctness + +Number IDs within the report, for example `SEC-001`. Assign one primary category to each root cause. + +Use these severity levels independently of category: + +- `Critical`: a reachable issue with catastrophic impact, such as broad authorization compromise, widespread sensitive-data exposure, irreversible systemic data loss, or reliable total-service failure. +- `High`: a realistic issue causing material authorization or ownership bypass, user-data exposure or corruption, service-wide outage, or a fundamental design failure that blocks supported behavior. +- `Medium`: a material but contained correctness, consistency, resilience, scalability, or maintainability failure with a plausible trigger. +- `Low`: a verified, bounded defect with limited impact. Do not use `Low` for style preferences or optional improvements. + +Sort findings by severity, then by impact within the same severity. Security is a category, not a severity; a security finding can have any severity. + +Use this structure: + +```markdown +- Summary + - Mode: diff | main | full + - Target: all | + - Baseline: + - Reviewed snapshot: + - Verification: + +- Findings + - ID: + - Severity: Critical | High | Medium | Low + - Location: + - Analysis: + - Recommendation: + +- Architecture drift + - + +- Documentation drift + - + +- Unverified areas + - +``` + +If there are no qualifying findings, write `Findings: None`. Write `None` for any other empty section. Do not omit verification failures or review limitations. diff --git a/.agents/skills/mygo-api-repo-review/references/review-calibration.md b/.agents/skills/mygo-api-repo-review/references/review-calibration.md new file mode 100644 index 0000000..a048d4f --- /dev/null +++ b/.agents/skills/mygo-api-repo-review/references/review-calibration.md @@ -0,0 +1,27 @@ +# Review Calibration + +Use this reference only after the open-ended discovery pass. Apply it to challenge candidate findings and suppress false positives. + +## Finding eligibility + +Accept a candidate as a finding only when all of these are present: + +- Concrete evidence in current code or documented design. +- A violated invariant or reachable, plausible failure scenario. +- A material consequence under supported behavior or realistic roadmap use. +- A precise source location that lets another developer verify the analysis. +- A recommendation that addresses the underlying cause. + +Actively seek the strongest disconfirming evidence before accepting a candidate. Check whether another layer enforces the invariant, the path is unreachable, the behavior is explicitly unsupported, or the tradeoff is intentional and adequately contained. + +## Calibration rules + +- Do not report a missing abstraction, restriction, validation, limit, test, or defensive mechanism solely because it is absent. Demonstrate the failure path or violated invariant caused by the absence. +- Do not treat a permissive configuration or documented tradeoff as a problem by itself. Report it only when a supported setting bypasses required processing or breaks security, lifecycle, or consistency invariants. +- Do not treat a simple implementation as an architecture flaw without concrete coupling, duplicated responsibility, boundary leakage, unsafe change propagation, or realistic scaling impact. +- Do not report theoretical performance limits without a reachable hot path, resource-growth mechanism, and material consequence under realistic use or roadmap requirements. +- Do not report style preferences, naming preferences, speculative future features, or optional hardening as defects. +- Do not weaken or reinterpret a valid test to remove an implementation failure. Confirm intended behavior before deciding whether the test or implementation is wrong. +- Do not duplicate one root cause across review lenses. Assign one primary category and describe secondary impacts in its analysis. + +Place credible concerns that lack required evidence in `Unverified areas`. State what is unknown, why it matters, and the smallest validation needed to resolve it. diff --git a/AGENTS.md b/AGENTS.md index 10e217c..f95616f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,12 @@ 5. Verify: `go vet ./... && go test ./...` 6. Update `docs/roadmap.md`, `docs/decisions.md`, or `docs/architecture.md` if anything changed +## Repository Review + +- Explicitly invoke `$mygo-api-repo-review` for repository-wide or scoped architecture, design, security, resilience, data-consistency, performance, and implementation audits. +- Repository reviews are read-only unless the user separately requests fixes. +- Use `diff`, `main`, or `full` review mode and optionally limit any mode with a target path, Go package, or logical component. + ## Go Conventions - **Format**: `go fmt ./...` before every commit diff --git a/docs/development.md b/docs/development.md index 644ae61..48b1646 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,6 +26,22 @@ go vet ./... go fmt ./... ``` +## Repository Review + +Use the repository-scoped `$mygo-api-repo-review` skill for evidence-based architecture, design, security, resilience, data-consistency, performance, and implementation audits. Reviews are read-only unless fixes are requested separately. + +Select one review mode: + +- `diff` compares the current workspace, including relevant untracked files, with `HEAD`. +- `main` compares the current workspace with the merge base of `HEAD` and the local `main` branch without fetching remote refs. +- `full` reviews the current repository as a whole. + +Optionally limit any mode to a target path, Go package, or logical component. For example: + +```text +Use $mygo-api-repo-review in main mode with target internal/storage. +``` + ## Dependencies ```bash From a62a5bc0e2e45c3eb0da6ce88461c87057cdc364 Mon Sep 17 00:00:00 2001 From: Huxley Date: Tue, 14 Jul 2026 13:52:52 +0800 Subject: [PATCH 28/31] skill: refine repo-review skill instructions for clarity and calibration --- .agents/skills/mygo-api-repo-review/SKILL.md | 37 ++++++------------- .../references/report-template.md | 17 ++++++--- .../references/review-calibration.md | 36 +++++++++--------- 3 files changed, 41 insertions(+), 49 deletions(-) diff --git a/.agents/skills/mygo-api-repo-review/SKILL.md b/.agents/skills/mygo-api-repo-review/SKILL.md index d2b5edd..85763fb 100644 --- a/.agents/skills/mygo-api-repo-review/SKILL.md +++ b/.agents/skills/mygo-api-repo-review/SKILL.md @@ -5,7 +5,7 @@ description: Review the MyGO backend repository for material, evidence-backed ar # MyGO API Repository Review -Perform a read-only, evidence-first review of the MyGO backend. Find real defects and systemic risks without manufacturing findings from stylistic preferences, unsupported hypotheticals, or intentional design choices. +Perform a read-only, evidence-first review of the MyGO backend. Find material defects and systemic risks without turning unfinished product scope, stylistic preferences, or unsupported hypotheticals into findings. ## Resolve the review scope @@ -32,29 +32,20 @@ Keep the review read-only unless the user separately asks for fixes. 3. Do not fetch, pull, commit, stage, or discard changes. 4. Capture Git status again before reporting. If the workspace changed, identify the change and do not erase it. -## Establish context +## Review the implementation Read `AGENTS.md`, `docs/roadmap.md`, `docs/architecture.md`, `docs/decisions.md`, and `docs/development.md` before judging the implementation. Treat documented decisions as intent, not as proof that their implementation is correct. -Map the reviewed packages, dependency direction, public entry points, state owners, external resources, and important lifecycle operations. Use `rg` and targeted file reads; do not read `go.sum` in full. +Apply production-grade correctness, security, resilience, and architecture standards to behavior and components that already exist, including implemented paths within WIP features. Do not interpret that standard as requiring every capability of the finished product to exist now. -## Review objective +1. Map the reviewed packages, dependency direction, entry points, state owners, external resources, and lifecycle operations. Use `rg` and targeted reads; do not read `go.sum` in full. +2. Reconstruct behavior, boundaries, invariants, state transitions, and trust assumptions from code, tests, configuration, and documentation. +3. Trace important control, data, ownership, resource, concurrency, and failure flows without starting from a predetermined bug list. +4. Use the lenses below for minimum coverage, then challenge every candidate before reporting it. -Find material, evidence-backed flaws in the repository's architecture, design, and implementation. Prioritize problems that can meaningfully harm correctness, security, resilience, data integrity, scalability, extensibility, decoupling, customizability, or maintainability. Favor systemic causes and cross-component interactions over local code smells. +Treat the lenses as perspectives, not an exhaustive checklist, taxonomy, or finding quota: -Do not begin from a predetermined list of expected bugs. Reconstruct intended behavior, boundaries, invariants, state transitions, and trust assumptions from code, tests, configuration, architecture documentation, technical decisions, and the roadmap. - -## Analyze in phases - -1. Build a working system model before producing findings. -2. Perform an open-ended pass by tracing important control, data, state, ownership, resource, and failure flows across component boundaries. -3. Perform a coverage pass using the review lenses below. -4. Challenge each candidate by seeking evidence that the behavior is intentional, unreachable, contained, or already protected. -5. Classify and report findings only after discovery and falsification. - -Treat these lenses as minimum coverage, not as an exhaustive checklist, required taxonomy, or finding quota: - -- **Architecture and evolution:** Evaluate whether responsibilities, dependencies, boundaries, data flows, or resource use create material obstacles to supported behavior, realistic scaling, roadmap work, extensibility, decoupling, customization, or maintenance. +- **Architecture and evolution:** Evaluate whether responsibilities, dependencies, boundaries, data flows, or resource use materially obstruct supported behavior, scaling, committed evolution, extensibility, decoupling, customization, or maintenance. - **Security:** Evaluate whether trust, identity, authorization, ownership, confidentiality, integrity, availability, or exposure assumptions can be violated through a reachable execution path or component interaction. - **Resilience:** Evaluate whether plausible partial failures, dependency failures, cancellation, concurrency, malformed inputs, or interrupted lifecycle operations can cause disproportionate impact, cascading failure, loss of service, or an unrecoverable state. - **Data consistency:** Evaluate whether persistent state, external resources, ownership, and lifecycle transitions preserve required invariants across normal, concurrent, retried, interrupted, and partially failed execution paths. @@ -67,7 +58,7 @@ Keep small `diff` and narrowly targeted reviews in the main agent by default. Fo Give every reviewer the complete, non-exhaustive review objective and one primary emphasis such as architecture and performance, security, or resilience and data consistency. Make the emphasis a starting perspective rather than an exclusive boundary. Wait for all reviewers, then independently verify, deduplicate, and rank their candidates in the main agent. -## Verify deterministically +## Verify and report Run the repository-wide checks below unless the user explicitly limits command execution or the environment prevents them: @@ -76,10 +67,6 @@ Run the repository-wide checks below unless the user explicitly limits command e - `go test ./...` - a non-mutating `gofmt -l` check over tracked Go files -Record each check and its result. When a test fails, inspect the test first and follow the debugging principles in `AGENTS.md`. Do not convert a command failure into an architecture finding without validating its cause. Put checks that cannot run and their reasons under `Unverified areas`. +Record each check and its result. When a test fails, inspect the test first and follow the debugging principles in `AGENTS.md`. Do not convert a command failure into an architecture finding without validating its cause. -## Filter and report - -After discovering candidate findings, read [review-calibration.md](references/review-calibration.md) and apply its evidence and falsification rules. Before writing the final response, read [report-template.md](references/report-template.md) and follow it exactly. - -Report a candidate only when another developer can verify its location, causal path, and material impact. Place unresolved hypotheses under `Unverified areas` with the evidence needed to resolve them. If no candidate qualifies, state `Findings: None` rather than lowering the threshold. +After open-ended discovery, read [review-calibration.md](references/review-calibration.md) and apply its admission and falsification rules. Then read [report-template.md](references/report-template.md) and follow it exactly. If no candidate qualifies, state `Findings: None` rather than lowering the threshold. diff --git a/.agents/skills/mygo-api-repo-review/references/report-template.md b/.agents/skills/mygo-api-repo-review/references/report-template.md index 11da834..9cf747c 100644 --- a/.agents/skills/mygo-api-repo-review/references/report-template.md +++ b/.agents/skills/mygo-api-repo-review/references/report-template.md @@ -22,6 +22,8 @@ Use these severity levels independently of category: Sort findings by severity, then by impact within the same severity. Security is a category, not a severity; a security finding can have any severity. +Make each `Analysis` identify the existing behavior, boundary, or invariant under review, the evidence and reachable violation path, and the material impact. Do not use any report section as a backlog for absent WIP, planned, or future capabilities. + Use this structure: ```markdown @@ -34,19 +36,22 @@ Use this structure: - Findings - ID: - - Severity: Critical | High | Medium | Low - - Location: - - Analysis: - - Recommendation: + - Severity: Critical | High | Medium | Low + - Location: + - Analysis: + - Recommendation: + + - ID: + - - Architecture drift - - + - - Documentation drift - - Unverified areas - - + - ``` If there are no qualifying findings, write `Findings: None`. Write `None` for any other empty section. Do not omit verification failures or review limitations. diff --git a/.agents/skills/mygo-api-repo-review/references/review-calibration.md b/.agents/skills/mygo-api-repo-review/references/review-calibration.md index a048d4f..829a7cb 100644 --- a/.agents/skills/mygo-api-repo-review/references/review-calibration.md +++ b/.agents/skills/mygo-api-repo-review/references/review-calibration.md @@ -1,27 +1,27 @@ # Review Calibration -Use this reference only after the open-ended discovery pass. Apply it to challenge candidate findings and suppress false positives. +Use this reference only after open-ended discovery. Apply it to decide which candidates deserve a place in the report. -## Finding eligibility +## Admit a finding -Accept a candidate as a finding only when all of these are present: +Accept a candidate only when all of these are present: -- Concrete evidence in current code or documented design. -- A violated invariant or reachable, plausible failure scenario. -- A material consequence under supported behavior or realistic roadmap use. -- A precise source location that lets another developer verify the analysis. -- A recommendation that addresses the underlying cause. +- An existing implemented behavior, boundary, or intrinsic invariant, including code already exposed within a WIP feature. An absent part of a WIP, planned, or future capability is not itself a basis. +- Concrete evidence that current code violates that behavior or invariant through a reachable, plausible path. +- A material consequence under production-grade operation of the existing capability, without requiring the finished product's full feature set. +- A precise source location and a recommendation that primarily corrects existing behavior or structure rather than delivers a missing product capability. -Actively seek the strongest disconfirming evidence before accepting a candidate. Check whether another layer enforces the invariant, the path is unreachable, the behavior is explicitly unsupported, or the tradeoff is intentional and adequately contained. +Ask: **Does the repair mainly correct existing behavior, or design and deliver a capability that does not yet exist?** The amount of code required is not decisive; the repair's purpose is. -## Calibration rules +Seek the strongest disconfirming evidence. Check other layers, tests, supported scope, containment, and intentional tradeoffs before accepting a candidate. -- Do not report a missing abstraction, restriction, validation, limit, test, or defensive mechanism solely because it is absent. Demonstrate the failure path or violated invariant caused by the absence. -- Do not treat a permissive configuration or documented tradeoff as a problem by itself. Report it only when a supported setting bypasses required processing or breaks security, lifecycle, or consistency invariants. -- Do not treat a simple implementation as an architecture flaw without concrete coupling, duplicated responsibility, boundary leakage, unsafe change propagation, or realistic scaling impact. -- Do not report theoretical performance limits without a reachable hot path, resource-growth mechanism, and material consequence under realistic use or roadmap requirements. -- Do not report style preferences, naming preferences, speculative future features, or optional hardening as defects. -- Do not weaken or reinterpret a valid test to remove an implementation failure. Confirm intended behavior before deciding whether the test or implementation is wrong. -- Do not duplicate one root cause across review lenses. Assign one primary category and describe secondary impacts in its analysis. +## Apply the boundary -Place credible concerns that lack required evidence in `Unverified areas`. State what is unknown, why it matters, and the smallest validation needed to resolve it. +- A missing safeguard qualifies only when it is intrinsic to an existing interface or mechanism and its absence creates a demonstrated failure. Do not turn expected product policies or lifecycle subsystems into defects merely because a mature product will need them. +- For configuration, distinguish enforceable technical validity from operator judgment. Report values that violate an implemented mechanism's objective preconditions or are silently misapplied; do not claim the program can prove secret entropy or prevent every poor but technically valid policy choice. +- Report architecture or evolution risk only when current design causes concrete coupling, boundary leakage, unsafe change propagation, or material rework for a committed direction. A missing future subsystem is not an architecture flaw. +- Require a reachable hot path, resource-growth mechanism, and material impact for performance findings. Do not report style, naming, speculative scale, or optional preference as a defect. +- Keep one primary violated invariant and root cause per finding. Do not combine independent missing defenses to raise severity; assign one category and describe only supported secondary impacts. +- Preserve valid tests and intended behavior. Never weaken a test to dismiss an implementation failure. + +Use `Unverified areas` only for a near-qualifying current defect that a specific factual check can resolve. Omit undecided product policy, future capability, and general hardening ideas rather than using this section as a backlog. From b49bf648bec61017396dd57cea88ed62b50e7175 Mon Sep 17 00:00:00 2001 From: Huxley Date: Tue, 14 Jul 2026 16:50:13 +0800 Subject: [PATCH 29/31] feat(web): scaffold client-side rendered SPA foundation - feat: initialize Vite project with React 19, React Router 8, TanStack Query, Ant Design 6, and Tailwind CSS 4 - feat: add Oxlint with React/TypeScript rules, strict TypeScript config, and layered CSS import order - docs: add decision record for client-rendered Web foundation with React, Ant Design, and Tailwind CSS - docs: create web roadmap defining architecture boundaries and deferred dependencies - build: pin Node.js 24 in mise.toml --- docs/decisions.md | 18 + docs/web-roadmap.md | 49 + mise.toml | 1 + web/.gitignore | 24 + web/.oxlintrc.json | 8 + web/README.md | 18 + web/index.html | 13 + web/package-lock.json | 2761 ++++++++++++++++++++++++++++++++ web/package.json | 36 + web/src/App.tsx | 9 + web/src/app/providers.tsx | 15 + web/src/app/router.tsx | 15 + web/src/index.css | 13 + web/src/main.tsx | 13 + web/src/pages/HomePage.tsx | 19 + web/src/pages/NotFoundPage.tsx | 16 + web/tsconfig.app.json | 27 + web/tsconfig.json | 7 + web/tsconfig.node.json | 24 + web/vite.config.ts | 7 + 20 files changed, 3093 insertions(+) create mode 100644 docs/web-roadmap.md create mode 100644 web/.gitignore create mode 100644 web/.oxlintrc.json create mode 100644 web/README.md create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/app/providers.tsx create mode 100644 web/src/app/router.tsx create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/HomePage.tsx create mode 100644 web/src/pages/NotFoundPage.tsx create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/docs/decisions.md b/docs/decisions.md index 99ea246..726276c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -106,3 +106,21 @@ - REST remains a handler/API concern, and future protocols can reuse services without HTTP leakage. - Error responses keep the same top-level shape, with optional `log_id` instead of embedding log references in the message. - Admin and auth middleware behavior is testable through service contracts rather than database access. + +## 2026-07-14: Client-rendered Web Foundation + +**Context**: MyGO needs a browser client now and native clients later. The Web application must share the versioned REST API instead of introducing browser-only server logic. + +**Decisions**: + +| Area | Choice | Guidance | +|------|--------|----------| +| Rendering | Pure client-side rendered SPA | Vite emits static assets; do not introduce SSR, React Server Components, or a Node API server. | +| Application stack | React, strict TypeScript, React Router, and TanStack Query | Keep routing and remote-data state explicit and client-side. | +| UI system | Ant Design plus Tailwind CSS 4 | Ant Design owns reusable controls and theme tokens; Tailwind initially owns layout, spacing, and responsive utilities. | +| Dependency policy | Install capabilities when their feature starts | Keep API generation, transfer, virtualization, drag-and-drop, test, and preview libraries deferred in `docs/web-roadmap.md`. | + +**Consequences**: +- The Web and future native clients consume the same client-neutral API contracts. +- MyGO or a reverse proxy may host `web/dist` with an SPA fallback without changing the rendering model. +- MyGO domain components own file-browser behavior and must not depend on Ant Design request behavior for business logic. diff --git a/docs/web-roadmap.md b/docs/web-roadmap.md new file mode 100644 index 0000000..b3665a9 --- /dev/null +++ b/docs/web-roadmap.md @@ -0,0 +1,49 @@ +# Web Roadmap + +## Product Boundary + +- The Web client is a pure client-side rendered single-page application. +- Vite produces static assets only. The project does not use SSR, React Server Components, a Node API server, or a browser-specific business backend. +- The Web client consumes the same versioned REST API as future Android and other native clients. +- Production may serve `web/dist` from MyGO or a reverse proxy. +- Shared API contracts should remain client-neutral and eventually be described by OpenAPI. + +## Foundation + +The initial project contains only the framework and styling foundation: + +- Node.js 24 and npm +- Vite +- React +- TypeScript in strict mode +- TanStack Query provider +- Tailwind CSS 4 through its Vite plugin +- Ant Design provider and components +- Oxlint from the Vite template + +Ant Design owns reusable UI components and theme tokens. Tailwind CSS is initially limited to application layout, spacing, and responsive utilities. + +## Planned Structure + +```text +web/src/ +β”œβ”€β”€ app/ # Router, providers, and application composition +β”œβ”€β”€ api/ # API client and generated contracts +β”œβ”€β”€ components/ # Shared presentation components +β”œβ”€β”€ features/ # Auth, files, account, and admin features +β”œβ”€β”€ pages/ # Route entry points +β”œβ”€β”€ lib/ # Framework-independent utilities +└── test/ # Shared test setup and fixtures +``` + +## Deferred Dependencies + +Some dependencies are suggested for future implementation. Refer these only when the corresponding feature is implemented and propose better choices if any: + +- `@ant-design/icons`: Add Ant Design-consistent application and action icons when real screens require them. +- `openapi-typescript`: Generate TypeScript API types from the shared OpenAPI document. +- `openapi-fetch`: Provide a small type-safe Fetch client based on generated OpenAPI types. +- `openapi-react-query`: Connect generated OpenAPI operations to TanStack Query if handwritten query adapters become repetitive. +- `zustand`: Manage a cross-route upload queue, bulk selection, or other complex client-only state if React state is insufficient. +- `vitest`: Run unit and integration tests using the Vite toolchain. +- `pdfjs-dist`: Preview PDF files in the browser when document preview is implemented. diff --git a/mise.toml b/mise.toml index 966f88a..38b040d 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,3 @@ [tools] go = "1.26.2" +node = "24" diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/.oxlintrc.json b/web/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/web/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..7d065ce --- /dev/null +++ b/web/README.md @@ -0,0 +1,18 @@ +# MyGO Web + +MyGO's browser client is a pure client-side rendered application built with React, TypeScript, and Vite. + +## Development + +```bash +npm install +npm run dev +``` + +## Checks + +```bash +npm run check +``` + +The production build is emitted to `dist/` as static assets. See `docs/web-roadmap.md` at the repository root for architecture boundaries and planned dependencies. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..8c644db --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + MyGO + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..b98c40e --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2761 @@ +{ + "name": "mygo-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mygo-web", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "antd": "^6.5.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.2.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.2", + "typescript": "~6.0.2", + "vite": "^8.1.1" + }, + "engines": { + "node": ">=24 <25" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.3.2", + "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-6.3.2.tgz", + "integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.5.0", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", + "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", + "json2mq": "^0.2.0", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.74.0.tgz", + "integrity": "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-android-arm64/-/binding-android-arm64-1.74.0.tgz", + "integrity": "sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.74.0.tgz", + "integrity": "sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.74.0.tgz", + "integrity": "sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.74.0.tgz", + "integrity": "sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.74.0.tgz", + "integrity": "sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.74.0.tgz", + "integrity": "sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.74.0.tgz", + "integrity": "sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.74.0.tgz", + "integrity": "sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.74.0.tgz", + "integrity": "sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.74.0.tgz", + "integrity": "sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.74.0.tgz", + "integrity": "sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.74.0.tgz", + "integrity": "sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.74.0.tgz", + "integrity": "sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.74.0.tgz", + "integrity": "sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.74.0.tgz", + "integrity": "sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.74.0.tgz", + "integrity": "sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.74.0.tgz", + "integrity": "sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.74.0.tgz", + "integrity": "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-6.0.0.tgz", + "integrity": "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.17.0", + "resolved": "https://registry.npmmirror.com/@rc-component/cascader/-/cascader-1.17.0.tgz", + "integrity": "sha512-3cVNG0zrQF1PoXq262L3wGCU+/YLEC1mGSVHDl577dQmA0ZKkXFbY6nwyXo+beCcM7buo49t24jkr+QZdL7O8w==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.8.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/@rc-component/context/-/context-2.0.2.tgz", + "integrity": "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/@rc-component/dialog/-/dialog-1.10.0.tgz", + "integrity": "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@rc-component/dropdown/-/dropdown-1.0.3.tgz", + "integrity": "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.8.5", + "resolved": "https://registry.npmmirror.com/@rc-component/form/-/form-1.8.5.tgz", + "integrity": "sha512-d24EYtvUOBhxEtSd/EqIu9DaMuqrWF2IRIvAFCTM6NQ/GJIYNr8DvEpUSUlv2uPxEJ0ZPwYQ+wwlGIAaiHvdrw==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^6.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.9.0", + "resolved": "https://registry.npmmirror.com/@rc-component/image/-/image-1.9.0.tgz", + "integrity": "sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.10.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@rc-component/input/-/input-1.3.1.tgz", + "integrity": "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==", + "license": "MIT", + "dependencies": { + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmmirror.com/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/@rc-component/mentions/-/mentions-1.10.0.tgz", + "integrity": "sha512-CI1njYUVY0NjHtLhNoVmXlJyy568Sfep9Wsak6vmGjtT6uazx98djGYlCXz2xkHhEm73g91Y3MTvzUyE5avI7w==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.3.0", + "@rc-component/menu": "~1.4.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/@rc-component/menu/-/menu-1.4.1.tgz", + "integrity": "sha512-3GsVRoQ4cnF/AoIQ4P+Z1haBfgfBPQfLT1RJY3Nu4DzOnheTslfCiGSPj7bv/cLj5sW5pHqN25dDXGP3JELAlQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/motion": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/@rc-component/motion/-/motion-1.3.3.tgz", + "integrity": "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@rc-component/notification/-/notification-2.0.7.tgz", + "integrity": "sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/overflow/-/overflow-1.0.1.tgz", + "integrity": "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/pagination/-/pagination-1.4.0.tgz", + "integrity": "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.11.0", + "resolved": "https://registry.npmmirror.com/@rc-component/picker/-/picker-1.11.0.tgz", + "integrity": "sha512-6qXGKtoJvO8sUd17m5cyNEbEJub0zflCHnaZTBBmj63DPRZYc0WEHN8rp6hFSl+yMCJS/dJY5G+1fQ8bLCuD7A==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@rc-component/portal": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/@rc-component/portal/-/portal-2.2.1.tgz", + "integrity": "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@rc-component/progress/-/progress-1.0.2.tgz", + "integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-2.0.0.tgz", + "integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.8.2", + "resolved": "https://registry.npmmirror.com/@rc-component/select/-/select-1.8.2.tgz", + "integrity": "sha512-HQ9zuYqjfZTlcEMWlU1GAPBajd2OHIMVHyjZSGVTCVARwkfCgvXZMTEn0cduy3L+ejAKkaZluOQvxovZoaJaQw==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/slider/-/slider-1.1.1.tgz", + "integrity": "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/@rc-component/steps/-/steps-1.2.2.tgz", + "integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.10.4", + "resolved": "https://registry.npmmirror.com/@rc-component/table/-/table-1.10.4.tgz", + "integrity": "sha512-HwoTnrwc29zeoXkXGhWqzJh8FIibGUxi1jM4LtoSzmR9d5Vv5osUQpZxnXKBP8iOCvyD6BQzZm1nXJRcnrxpAg==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.11.0", + "resolved": "https://registry.npmmirror.com/@rc-component/tabs/-/tabs-1.11.0.tgz", + "integrity": "sha512-hA/drZYOVa/MMIb4M2fWf3yaTyTG4qVuIABmghvEhyfw2nBob5VTH69lMCDjSVKmgODjO6nWlCV+gVn3xBrj5Q==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.4.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/tooltip/-/tooltip-1.4.0.tgz", + "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.7.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/tour/-/tour-2.4.0.tgz", + "integrity": "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/@rc-component/tree/-/tree-1.3.2.tgz", + "integrity": "sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.11.0", + "resolved": "https://registry.npmmirror.com/@rc-component/tree-select/-/tree-select-1.11.0.tgz", + "integrity": "sha512-EhS0X0wtUhBfK4S5TlpSY3MR9ndPMGgujtt1PJW3Ej+ToAlnS/6ohYURtCoXBYGqazUwHmgQGVUDsfpVwhWPkg==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.8.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/@rc-component/trigger/-/trigger-3.10.0.tgz", + "integrity": "sha512-uC3QSG7Ax3qLOE5Q2jLqJCJc4iBtJEHzNTPhqGvlRvRcU8x8CT5moIavRVe24YSQKCp2/D1GSq7y76SCSheuVA==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.2.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/upload/-/upload-1.1.1.tgz", + "integrity": "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.12.0", + "resolved": "https://registry.npmmirror.com/@rc-component/util/-/util-1.12.0.tgz", + "integrity": "sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^19.2.7" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/@rc-component/virtual-list/-/virtual-list-1.3.2.tgz", + "integrity": "sha512-/smuvWBFdP/Is9QuNDKynD0+T3XTXWFyNXXNKJ4sno8CE3bTOK8sfgYmQJtYwLUNX+lv0Ytd+PMshgpmdReq5g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^8.0.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list/node_modules/@babel/runtime": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-8.0.0.tgz", + "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/antd": { + "version": "6.5.1", + "resolved": "https://registry.npmmirror.com/antd/-/antd-6.5.1.tgz", + "integrity": "sha512-VZVVF9zYI6S0NHqboVhCoY9Iiqj6dphW1NPB+sEaAf2HuIQ0haXWXj7ZvAXTRDzusktV6+cvvrSZEdRi4twATg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.3.2", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.29.2", + "@rc-component/cascader": "~1.17.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.10.0", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.3", + "@rc-component/form": "~1.8.5", + "@rc-component/image": "~1.9.0", + "@rc-component/input": "~1.3.1", + "@rc-component/input-number": "~1.6.2", + "@rc-component/mentions": "~1.10.0", + "@rc-component/menu": "~1.4.1", + "@rc-component/motion": "^1.3.3", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~2.0.7", + "@rc-component/pagination": "~1.4.0", + "@rc-component/picker": "~1.11.0", + "@rc-component/progress": "~1.0.2", + "@rc-component/qrcode": "~2.0.0", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.8.2", + "@rc-component/slider": "~1.1.1", + "@rc-component/steps": "~1.2.2", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.10.4", + "@rc-component/tabs": "~1.11.0", + "@rc-component/tooltip": "~1.4.0", + "@rc-component/tour": "~2.4.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/tree-select": "~1.11.0", + "@rc-component/trigger": "^3.10.0", + "@rc-component/upload": "~1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.74.0", + "resolved": "https://registry.npmmirror.com/oxlint/-/oxlint-1.74.0.tgz", + "integrity": "sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.74.0", + "@oxlint/binding-android-arm64": "1.74.0", + "@oxlint/binding-darwin-arm64": "1.74.0", + "@oxlint/binding-darwin-x64": "1.74.0", + "@oxlint/binding-freebsd-x64": "1.74.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.74.0", + "@oxlint/binding-linux-arm-musleabihf": "1.74.0", + "@oxlint/binding-linux-arm64-gnu": "1.74.0", + "@oxlint/binding-linux-arm64-musl": "1.74.0", + "@oxlint/binding-linux-ppc64-gnu": "1.74.0", + "@oxlint/binding-linux-riscv64-gnu": "1.74.0", + "@oxlint/binding-linux-riscv64-musl": "1.74.0", + "@oxlint/binding-linux-s390x-gnu": "1.74.0", + "@oxlint/binding-linux-x64-gnu": "1.74.0", + "@oxlint/binding-linux-x64-musl": "1.74.0", + "@oxlint/binding-openharmony-arm64": "1.74.0", + "@oxlint/binding-win32-arm64-msvc": "1.74.0", + "@oxlint/binding-win32-ia32-msvc": "1.74.0", + "@oxlint/binding-win32-x64-msvc": "1.74.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.24.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "license": "MIT" + }, + "node_modules/react-router": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/react-router/-/react-router-8.2.0.tgz", + "integrity": "sha512-uP1LgGNHyilL1u+ZkecQk74DJOKOb6q4NLNYLRJcD/fjoogftNb5/Rj/07o/QBFsY/5MbAKPm1peTCQuVPv9cQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmmirror.com/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..3563a94 --- /dev/null +++ b/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "mygo-web", + "private": true, + "version": "0.0.0", + "type": "module", + "engines": { + "node": ">=24 <25" + }, + "packageManager": "npm@11.9.0", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "typecheck": "tsc -b", + "check": "npm run lint && npm run build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "antd": "^6.5.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.2.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.2", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..44ac365 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,9 @@ +import { RouterProvider } from 'react-router' + +import { router } from './app/router.tsx' + +function App() { + return +} + +export default App diff --git a/web/src/app/providers.tsx b/web/src/app/providers.tsx new file mode 100644 index 0000000..8077714 --- /dev/null +++ b/web/src/app/providers.tsx @@ -0,0 +1,15 @@ +import type { PropsWithChildren } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { App as AntDesignApp, ConfigProvider } from 'antd' + +const queryClient = new QueryClient() + +export function AppProviders({ children }: PropsWithChildren) { + return ( + + + {children} + + + ) +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx new file mode 100644 index 0000000..ff4db0b --- /dev/null +++ b/web/src/app/router.tsx @@ -0,0 +1,15 @@ +import { createBrowserRouter } from 'react-router' + +import { HomePage } from '../pages/HomePage.tsx' +import { NotFoundPage } from '../pages/NotFoundPage.tsx' + +export const router = createBrowserRouter([ + { + path: '/', + element: , + }, + { + path: '*', + element: , + }, +]) diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..1ac8c8a --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,13 @@ +@layer theme, base, antd, components, utilities; +@import 'tailwindcss'; + +html, +body, +#root { + min-width: 320px; + min-height: 100%; +} + +body { + margin: 0; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..6406740 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' +import { AppProviders } from './app/providers.tsx' + +createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..d759bcc --- /dev/null +++ b/web/src/pages/HomePage.tsx @@ -0,0 +1,19 @@ +import { Card, Space, Tag, Typography } from 'antd' + +const { Paragraph, Title } = Typography + +export function HomePage() { + return ( +
+ + + MyGO Web + MyGO + + The client-side web application foundation is ready. + + + +
+ ) +} diff --git a/web/src/pages/NotFoundPage.tsx b/web/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..f926881 --- /dev/null +++ b/web/src/pages/NotFoundPage.tsx @@ -0,0 +1,16 @@ +import { Button, Result } from 'antd' +import { Link } from 'react-router' + +export function NotFoundPage() { + return ( + + Back home + + } + /> + ) +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..4fc48da --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + "strict": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..0ddc5bc --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + "strict": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..0616e59 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], +}) From 9f13c0a23a6394a4cc92ed5c83aae88f6e900977 Mon Sep 17 00:00:00 2001 From: Huxley Date: Tue, 14 Jul 2026 17:07:42 +0800 Subject: [PATCH 30/31] docs: reorganize documentation into server/ and web/ directories - docs: move server docs to docs/server/ and update all cross-references - docs: move web-roadmap.md to docs/web/roadmap.md and create docs/web/decisions.md - docs: update AGENTS.md with separate Server and Web sections --- AGENTS.md | 107 ++++++++++++++++-------- README.md | 4 +- docs/README.md | 19 ++++- docs/{ => server}/architecture.md | 0 docs/{ => server}/decisions.md | 18 ---- docs/{ => server}/development.md | 0 docs/{ => server}/roadmap.md | 0 docs/web/decisions.md | 19 +++++ docs/{web-roadmap.md => web/roadmap.md} | 0 web/README.md | 2 +- 10 files changed, 109 insertions(+), 60 deletions(-) rename docs/{ => server}/architecture.md (100%) rename docs/{ => server}/decisions.md (87%) rename docs/{ => server}/development.md (100%) rename docs/{ => server}/roadmap.md (100%) create mode 100644 docs/web/decisions.md rename docs/{web-roadmap.md => web/roadmap.md} (100%) diff --git a/AGENTS.md b/AGENTS.md index f95616f..901c008 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,28 +1,36 @@ -# AGENTS.md β€” MyGO Backend +# AGENTS.md β€” MyGO -## Project Essentials +## Project Layout -- Module: `github.com/dhao2001/mygo`, Go 1.26.2 -- WebDisk (cloud drive) backend; roadmap in `docs/roadmap.md` -- CLI framework: `github.com/spf13/cobra` -- Go version pinned in `mise.toml` +- Server: Go backend in the repository root, with documentation in `docs/server/` +- Web: React client in `web/`, with documentation in `docs/web/` +- Documentation index: `docs/README.md` ## Agent Workflow -1. Read the task -2. Read relevant `docs/` files for context +1. Read the task and identify the affected project +2. Read `docs/README.md` and the relevant project documentation for context 3. Explore existing code before writing new code -4. Implement following the conventions below -5. Verify: `go vet ./... && go test ./...` -6. Update `docs/roadmap.md`, `docs/decisions.md`, or `docs/architecture.md` if anything changed +4. Implement following the relevant project conventions below +5. Run the verification commands for every affected project +6. Update the relevant project roadmap, decisions, architecture, or development documentation if anything changed -## Repository Review +## Server β€” Go Backend + +### Project Essentials + +- Module: `github.com/dhao2001/mygo`, Go 1.26.2 +- WebDisk (cloud drive) backend; roadmap in `docs/server/roadmap.md` +- CLI framework: `github.com/spf13/cobra` +- Go version pinned in `mise.toml` + +### Repository Review - Explicitly invoke `$mygo-api-repo-review` for repository-wide or scoped architecture, design, security, resilience, data-consistency, performance, and implementation audits. - Repository reviews are read-only unless the user separately requests fixes. - Use `diff`, `main`, or `full` review mode and optionally limit any mode with a target path, Go package, or logical component. -## Go Conventions +### Go Conventions - **Format**: `go fmt ./...` before every commit - **Imports**: stdlib / third-party / internal, blank-line separated @@ -32,19 +40,63 @@ - **`init()`**: only in cobra cmd files for flag registration - **`cmd/`** is thin; business logic goes in `internal/` -## Documentation +### Documentation | File | Read Before | Update After | |------|-------------|--------------| -| `docs/architecture.md` | Adding new packages | Adding new packages | -| `docs/decisions.md` | Making technical decisions | Making technical decisions | -| `docs/roadmap.md` | Every task | Completing a feature | -| `docs/development.md` | Build/test/debug setup | Changing workflow | +| `docs/server/architecture.md` | Adding new packages | Adding new packages | +| `docs/server/decisions.md` | Making technical decisions | Making technical decisions | +| `docs/server/roadmap.md` | Every server task | Completing a server feature | +| `docs/server/development.md` | Build/test/debug setup | Changing server workflow | + +### Commands + +```bash +go build ./... # build all packages +go test ./... # all tests +go vet ./... # static analysis +go fmt ./... # format +go mod tidy # clean deps after add/remove +``` + +### Server DO / DON'T + +- DO put business logic in `internal/`, keep `cmd/` thin +- DO add all Go module dependencies **before** writing code that uses them +- DON'T read `go.sum` entirely into context β€” use `grep` or other tools to search specific patterns if needed +- DON'T skip `go vet ./...` before finishing server work +- DON'T add, remove, or change Go module dependencies after debugging has started β€” ask for explicit permission first + +## Web β€” React Client + +### Project Essentials + +- Source: `web/` +- Pure client-side rendered application; roadmap in `docs/web/roadmap.md` +- Stack: Node.js 24, Vite, React, TypeScript, React Router, TanStack Query, Tailwind CSS 4, and Ant Design +- Node.js version pinned in `mise.toml` + +### Documentation + +| File | Read Before | Update After | +|------|-------------|--------------| +| `docs/web/decisions.md` | Making Web technical decisions | Making Web technical decisions | +| `docs/web/roadmap.md` | Every Web task | Completing a Web feature or changing the Web plan | + +### Commands + +Run from `web/`: + +```bash +npm ci # install locked dependencies +npm run dev # start the development server +npm run check # lint, type-check, and build +``` ## Git Version Control - DON'T create a commit unless the user explicitly asks for one. -- Before any commit, verify the work with the required project checks. For code changes, run `go vet ./... && go test ./...`; for docs-only changes, run the most relevant non-mutating checks if available. +- Before any commit, verify the work with the required project checks. For code changes, run the checks for every affected project; for docs-only changes, run the most relevant non-mutating checks if available. - Create a commit only when all required checks pass and the current implementation area has no unresolved issues. - If a known failing check or unresolved issue belongs to another module and is outside the current task, report it clearly before asking for commit approval. - Before running `git commit`, write the complete commit message first, show it to the user, and ask for explicit approval. DON'T commit until the user approves that exact message. @@ -78,25 +130,10 @@ feat(middleware): add AdminRequired authorization middleware - test: admin passes, non-admin forbidden, soft-deleted admin rejected, missing user ID. ``` -## Commands +## Shared DO / DON'T -```bash -go build ./... # build all packages -go test ./... # all tests -go vet ./... # static analysis -go fmt ./... # format -go mod tidy # clean deps after add/remove -``` - -## DO / DON'T - -- DO put business logic in `internal/`, keep `cmd/` thin - DO write all code, comments, and documentation in English -- DO add all Go module dependencies **before** writing code that uses them -- DON'T read `go.sum` entirely into context β€” use `grep` or other tools to search specific patterns if needed -- DON'T skip `go vet ./...` before finishing work - DON'T commit without following the Git Version Control rules above -- DON'T add, remove, or change Go module dependencies after debugging has started β€” ask for explicit permission first ## Debugging Principles diff --git a/README.md b/README.md index f064a81..ab514f6 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,10 @@ MyGO is a WebDisk (cloud drive) server, written in Go. ## Frontend -The frontend uses React + TypeScript and communicates with the backend via HTTP API. Frontend source is maintained in a separate repository. +The frontend uses React + TypeScript and communicates with the backend via HTTP API. Frontend source is maintained in `web/`. --- ## Development -See `docs/development.md` for build and test workflows. See `AGENTS.md` for behavioral conventions. +See `docs/README.md` for the server and Web documentation index. See `AGENTS.md` for behavioral conventions. diff --git a/docs/README.md b/docs/README.md index 6c6e586..54851e4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,19 @@ # Docs +Documentation is organized by project. Use `server/` for the Go backend and `web/` for the browser client. + +## Server + | File | Content | |------|---------| -| `architecture.md` | Module layout, package boundaries | -| `decisions.md` | Technical decisions (ADR) | -| `roadmap.md` | Feature progress and status | -| `development.md` | Build, test, debug workflow | +| `server/architecture.md` | Module layout, package boundaries | +| `server/decisions.md` | Technical decisions (ADR) | +| `server/roadmap.md` | Feature progress and status | +| `server/development.md` | Build, test, debug workflow | + +## Web + +| File | Content | +|------|---------| +| `web/decisions.md` | Technical decisions (ADR) | +| `web/roadmap.md` | Product boundary, foundation, planned structure, and deferred dependencies | diff --git a/docs/architecture.md b/docs/server/architecture.md similarity index 100% rename from docs/architecture.md rename to docs/server/architecture.md diff --git a/docs/decisions.md b/docs/server/decisions.md similarity index 87% rename from docs/decisions.md rename to docs/server/decisions.md index 726276c..99ea246 100644 --- a/docs/decisions.md +++ b/docs/server/decisions.md @@ -106,21 +106,3 @@ - REST remains a handler/API concern, and future protocols can reuse services without HTTP leakage. - Error responses keep the same top-level shape, with optional `log_id` instead of embedding log references in the message. - Admin and auth middleware behavior is testable through service contracts rather than database access. - -## 2026-07-14: Client-rendered Web Foundation - -**Context**: MyGO needs a browser client now and native clients later. The Web application must share the versioned REST API instead of introducing browser-only server logic. - -**Decisions**: - -| Area | Choice | Guidance | -|------|--------|----------| -| Rendering | Pure client-side rendered SPA | Vite emits static assets; do not introduce SSR, React Server Components, or a Node API server. | -| Application stack | React, strict TypeScript, React Router, and TanStack Query | Keep routing and remote-data state explicit and client-side. | -| UI system | Ant Design plus Tailwind CSS 4 | Ant Design owns reusable controls and theme tokens; Tailwind initially owns layout, spacing, and responsive utilities. | -| Dependency policy | Install capabilities when their feature starts | Keep API generation, transfer, virtualization, drag-and-drop, test, and preview libraries deferred in `docs/web-roadmap.md`. | - -**Consequences**: -- The Web and future native clients consume the same client-neutral API contracts. -- MyGO or a reverse proxy may host `web/dist` with an SPA fallback without changing the rendering model. -- MyGO domain components own file-browser behavior and must not depend on Ant Design request behavior for business logic. diff --git a/docs/development.md b/docs/server/development.md similarity index 100% rename from docs/development.md rename to docs/server/development.md diff --git a/docs/roadmap.md b/docs/server/roadmap.md similarity index 100% rename from docs/roadmap.md rename to docs/server/roadmap.md diff --git a/docs/web/decisions.md b/docs/web/decisions.md new file mode 100644 index 0000000..1be9098 --- /dev/null +++ b/docs/web/decisions.md @@ -0,0 +1,19 @@ +# Technical Decisions + +## 2026-07-14: Client-rendered Web Foundation + +**Context**: MyGO needs a browser client now and native clients later. The Web application must share the versioned REST API instead of introducing browser-only server logic. + +**Decisions**: + +| Area | Choice | Guidance | +|------|--------|----------| +| Rendering | Pure client-side rendered SPA | Vite emits static assets; do not introduce SSR, React Server Components, or a Node API server. | +| Application stack | React, strict TypeScript, React Router, and TanStack Query | Keep routing and remote-data state explicit and client-side. | +| UI system | Ant Design plus Tailwind CSS 4 | Ant Design owns reusable controls and theme tokens; Tailwind initially owns layout, spacing, and responsive utilities. | +| Dependency policy | Install capabilities when their feature starts | Keep API generation, transfer, virtualization, drag-and-drop, test, and preview libraries deferred in `docs/web/roadmap.md`. | + +**Consequences**: +- The Web and future native clients consume the same client-neutral API contracts. +- MyGO or a reverse proxy may host `web/dist` with an SPA fallback without changing the rendering model. +- MyGO domain components own file-browser behavior and must not depend on Ant Design request behavior for business logic. diff --git a/docs/web-roadmap.md b/docs/web/roadmap.md similarity index 100% rename from docs/web-roadmap.md rename to docs/web/roadmap.md diff --git a/web/README.md b/web/README.md index 7d065ce..1514b1e 100644 --- a/web/README.md +++ b/web/README.md @@ -15,4 +15,4 @@ npm run dev npm run check ``` -The production build is emitted to `dist/` as static assets. See `docs/web-roadmap.md` at the repository root for architecture boundaries and planned dependencies. +The production build is emitted to `dist/` as static assets. See `docs/web/roadmap.md` at the repository root for architecture boundaries and planned dependencies. From ceaeafd96cdc895fd82a392a5ea325f2897dd54c Mon Sep 17 00:00:00 2001 From: Huxley Date: Tue, 14 Jul 2026 17:42:56 +0800 Subject: [PATCH 31/31] docs: document worktree branch convention in AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 901c008..93f291c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,7 @@ npm run check # lint, type-check, and build ## Git Version Control +- When using a worktree, create a new branch and place its worktree in `.worktree/`, naming safely (for example, `git worktree add -b feat/partly-upload .worktree/feat-partly-upload`). - DON'T create a commit unless the user explicitly asks for one. - Before any commit, verify the work with the required project checks. For code changes, run the checks for every affected project; for docs-only changes, run the most relevant non-mutating checks if available. - Create a commit only when all required checks pass and the current implementation area has no unresolved issues.