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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ type PostgresConfig struct {
|
||||
type StorageConfig struct {
|
||||
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"))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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] != ".."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user