From e2af482cc97ba4c40e37d83f3252872b1353de7c Mon Sep 17 00:00:00 2001 From: Huxley Date: Wed, 24 Jun 2026 14:08:57 +0800 Subject: [PATCH] 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 +}