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
This commit is contained in:
2026-06-24 14:08:57 +08:00
parent eaa31efd64
commit e2af482cc9
17 changed files with 1815 additions and 15 deletions
-6
View File
@@ -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()
+222
View File
@@ -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
}
}
+439
View File
@@ -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)
}
}