go-chi-oapi-codegen-todolist/backend/internal/service/local_storage.go
2025-04-20 15:58:52 +07:00

186 lines
6.3 KiB
Go

package service
import (
"context"
"fmt"
"io"
"log/slog"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/Sosokker/todolist-backend/internal/config"
"github.com/google/uuid"
)
type localStorageService struct {
basePath string
logger *slog.Logger
}
// NewLocalStorageService creates a service for storing files on the local disk.
func NewLocalStorageService(cfg config.LocalStorageConfig, logger *slog.Logger) (FileStorageService, error) {
if cfg.Path == "" {
return nil, fmt.Errorf("local storage path cannot be empty")
}
// Ensure the base directory exists
err := os.MkdirAll(cfg.Path, 0750) // Use appropriate permissions
if err != nil {
return nil, fmt.Errorf("failed to create local storage directory '%s': %w", cfg.Path, err)
}
logger.Info("Local file storage initialized", "path", cfg.Path)
return &localStorageService{
basePath: cfg.Path,
logger: logger.With("service", "localstorage"),
}, nil
}
// GenerateUniqueObjectName creates a unique path/filename for storage.
// Example: user_uuid/todo_uuid/file_uuid.ext
func (s *localStorageService) GenerateUniqueObjectName(originalFilename string) string {
ext := filepath.Ext(originalFilename)
fileName := uuid.NewString() + ext
return fileName
}
func (s *localStorageService) Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (string, string, error) {
// Create a unique filename
uniqueFilename := s.GenerateUniqueObjectName(originalFilename)
// Create user/todo specific subdirectory structure
subDir := filepath.Join(userID.String(), todoID.String())
fullDir := filepath.Join(s.basePath, subDir)
if err := os.MkdirAll(fullDir, 0750); err != nil {
s.logger.ErrorContext(ctx, "Failed to create subdirectory for upload", "error", err, "path", fullDir)
return "", "", fmt.Errorf("could not create storage directory: %w", err)
}
// Define the full path for the file
filePath := filepath.Join(fullDir, uniqueFilename)
storageID := filepath.Join(subDir, uniqueFilename) // Relative path used as ID
// Create the destination file
dst, err := os.Create(filePath)
if err != nil {
s.logger.ErrorContext(ctx, "Failed to create destination file", "error", err, "path", filePath)
return "", "", fmt.Errorf("could not create file: %w", err)
}
defer dst.Close()
// Copy the content from the reader to the destination file
written, err := io.Copy(dst, reader)
if err != nil {
// Attempt to clean up partially written file
os.Remove(filePath)
s.logger.ErrorContext(ctx, "Failed to copy file content", "error", err, "path", filePath)
return "", "", fmt.Errorf("could not write file content: %w", err)
}
if written != size {
// Attempt to clean up file if size mismatch (could indicate truncated upload)
os.Remove(filePath)
s.logger.WarnContext(ctx, "File size mismatch during upload", "expected", size, "written", written, "path", filePath)
return "", "", fmt.Errorf("file size mismatch during upload")
}
// Detect content type
contentType := s.detectContentType(filePath, originalFilename)
s.logger.InfoContext(ctx, "File uploaded successfully", "storageId", storageID, "originalName", originalFilename, "size", size, "contentType", contentType)
// Return the relative path as the storage identifier
return storageID, contentType, nil
}
func (s *localStorageService) Delete(ctx context.Context, storageID string) error {
// Prevent directory traversal attacks
cleanStorageID := filepath.Clean(storageID)
if strings.Contains(cleanStorageID, "..") {
s.logger.WarnContext(ctx, "Attempted directory traversal in delete", "storageId", storageID)
return fmt.Errorf("invalid storage ID")
}
fullPath := filepath.Join(s.basePath, cleanStorageID)
err := os.Remove(fullPath)
if err != nil {
if os.IsNotExist(err) {
s.logger.WarnContext(ctx, "Attempted to delete non-existent file", "storageId", storageID)
// Consider returning nil here if deleting non-existent is okay
return nil
}
s.logger.ErrorContext(ctx, "Failed to delete file", "error", err, "storageId", storageID)
return fmt.Errorf("could not delete file: %w", err)
}
s.logger.InfoContext(ctx, "File deleted successfully", "storageId", storageID)
dir := filepath.Dir(fullPath)
if isEmpty, _ := IsDirEmpty(dir); isEmpty {
os.Remove(dir)
}
dir = filepath.Dir(dir) // Go up one more level
if isEmpty, _ := IsDirEmpty(dir); isEmpty {
os.Remove(dir)
}
return nil
}
// GetURL for local storage might just return a path or require a separate file server.
// This implementation returns a placeholder indicating it's not a direct URL.
func (s *localStorageService) GetURL(ctx context.Context, storageID string) (string, error) {
// Local storage doesn't inherently provide a web URL.
// You would typically need a separate static file server pointing to `basePath`.
// For now, return the storageID itself or a placeholder path.
// Example: If you have a file server at /static/uploads mapped to basePath:
// return "/static/uploads/" + filepath.ToSlash(storageID), nil
return fmt.Sprintf("local://%s", storageID), nil // Placeholder indicating local storage
}
// detectContentType tries to determine the MIME type of the file.
func (s *localStorageService) detectContentType(filePath string, originalFilename string) string {
// First, try based on file extension
ext := filepath.Ext(originalFilename)
mimeType := mime.TypeByExtension(ext)
if mimeType != "" {
return mimeType
}
// If extension didn't work, try reading the first 512 bytes
file, err := os.Open(filePath)
if err != nil {
s.logger.Warn("Could not open file for content type detection", "error", err, "path", filePath)
return "application/octet-stream" // Default fallback
}
defer file.Close()
buffer := make([]byte, 512)
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
s.logger.Warn("Could not read file for content type detection", "error", err, "path", filePath)
return "application/octet-stream"
}
// http.DetectContentType works best with the file beginning
mimeType = http.DetectContentType(buffer[:n])
return mimeType
}
func IsDirEmpty(name string) (bool, error) {
f, err := os.Open(name)
if err != nil {
return false, err
}
defer f.Close()
// Read just one entry. If EOF, directory is empty.
_, err = f.Readdirnames(1)
if err == io.EOF {
return true, nil
}
return false, err // Either not empty or error during read
}