mirror of
https://github.com/Sosokker/go-chi-oapi-codegen-todolist.git
synced 2025-12-19 14:04:07 +01:00
186 lines
6.3 KiB
Go
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
|
|
}
|