first
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
type mediaResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
AltText string `json:"alt_text"`
|
||||
}
|
||||
|
||||
func toResponse(m *Media) mediaResponse {
|
||||
return mediaResponse{
|
||||
ID: m.ID,
|
||||
Filename: m.Filename,
|
||||
URL: m.URL,
|
||||
MimeType: m.MimeType,
|
||||
SizeBytes: m.SizeBytes,
|
||||
AltText: m.AltText,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
list, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list media"})
|
||||
return
|
||||
}
|
||||
resp := make([]mediaResponse, 0, len(list))
|
||||
for _, m := range list {
|
||||
resp = append(resp, toResponse(m))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"media": resp})
|
||||
}
|
||||
|
||||
func (h *Handler) Upload(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
altText := c.PostForm("alt_text")
|
||||
|
||||
m, err := h.service.Upload(c.Request.Context(), fileHeader.Filename, file, fileHeader.Size, contentType, altText)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrFileTooLarge):
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds the maximum allowed size"})
|
||||
case errors.Is(err, ErrUnsupportedType):
|
||||
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "unsupported file type"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upload file"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toResponse(m))
|
||||
}
|
||||
|
||||
type updateRequest struct {
|
||||
AltText string `json:"alt_text"`
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req updateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
m, err := h.service.UpdateAltText(c.Request.Context(), id, req.AltText)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update media"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(m))
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.service.Delete(c.Request.Context(), id); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete media"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package media manages uploaded files (images/videos) and abstracts the
|
||||
// storage backend behind a Storage interface so the admin can switch
|
||||
// between local disk and any S3-compatible bucket via configuration only.
|
||||
package media
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Media struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
Filename string `gorm:"not null"`
|
||||
StorageKey string `gorm:"not null"`
|
||||
URL string `gorm:"not null"`
|
||||
MimeType string `gorm:"not null"`
|
||||
SizeBytes int64 `gorm:"not null"`
|
||||
AltText string `gorm:"not null;default:''"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Media) TableName() string { return "media" }
|
||||
@@ -0,0 +1,66 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("media not found")
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, m *Media) error
|
||||
FindByID(ctx context.Context, id uuid.UUID) (*Media, error)
|
||||
List(ctx context.Context) ([]*Media, error)
|
||||
Update(ctx context.Context, m *Media) error
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
}
|
||||
|
||||
type gormRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &gormRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *gormRepository) Create(ctx context.Context, m *Media) error {
|
||||
return r.db.WithContext(ctx).Create(m).Error
|
||||
}
|
||||
|
||||
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Media, error) {
|
||||
var m Media
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&m).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) List(ctx context.Context) ([]*Media, error) {
|
||||
var list []*Media
|
||||
if err := r.db.WithContext(ctx).Order("created_at desc").Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) Update(ctx context.Context, m *Media) error {
|
||||
return r.db.WithContext(ctx).Save(m).Error
|
||||
}
|
||||
|
||||
func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
res := r.db.WithContext(ctx).Delete(&Media{}, "id = ?", id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package media
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
|
||||
group := rg.Group("/admin/media", requireAdmin)
|
||||
group.GET("", h.List)
|
||||
group.POST("", h.Upload)
|
||||
group.PUT("/:id", h.Update)
|
||||
group.DELETE("/:id", h.Delete)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFileTooLarge = errors.New("file exceeds the maximum allowed size")
|
||||
ErrUnsupportedType = errors.New("unsupported file type")
|
||||
)
|
||||
|
||||
// allowedTypes maps accepted MIME types to a safe file extension. Anything
|
||||
// not in this list is rejected -- uploads are never trusted to declare
|
||||
// their own extension.
|
||||
var allowedTypes = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
storage Storage
|
||||
maxSizeByte int64
|
||||
}
|
||||
|
||||
func NewService(repo Repository, storage Storage, maxSizeBytes int64) *Service {
|
||||
return &Service{repo: repo, storage: storage, maxSizeByte: maxSizeBytes}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]*Media, error) {
|
||||
return s.repo.List(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Media, error) {
|
||||
return s.repo.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Upload(ctx context.Context, filename string, reader io.Reader, size int64, contentType, altText string) (*Media, error) {
|
||||
if size > s.maxSizeByte {
|
||||
return nil, ErrFileTooLarge
|
||||
}
|
||||
ext, ok := allowedTypes[contentType]
|
||||
if !ok {
|
||||
return nil, ErrUnsupportedType
|
||||
}
|
||||
|
||||
key := uuid.NewString() + ext
|
||||
url, err := s.storage.Save(ctx, key, reader, size, contentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("save file: %w", err)
|
||||
}
|
||||
|
||||
m := &Media{
|
||||
ID: uuid.New(),
|
||||
Filename: filename,
|
||||
StorageKey: key,
|
||||
URL: url,
|
||||
MimeType: contentType,
|
||||
SizeBytes: size,
|
||||
AltText: altText,
|
||||
}
|
||||
if err := s.repo.Create(ctx, m); err != nil {
|
||||
_ = s.storage.Delete(ctx, key)
|
||||
return nil, fmt.Errorf("save metadata: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAltText(ctx context.Context, id uuid.UUID, altText string) (*Media, error) {
|
||||
m, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.AltText = altText
|
||||
if err := s.repo.Update(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
m, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.storage.Delete(ctx, m.StorageKey); err != nil {
|
||||
return fmt.Errorf("delete stored file: %w", err)
|
||||
}
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Storage is the pluggable backend that actually persists uploaded bytes.
|
||||
// The service/handler layers never know whether files end up on local disk
|
||||
// or in an S3 bucket -- only main.go decides which implementation to wire
|
||||
// in, based on MEDIA_STORAGE_DRIVER.
|
||||
type Storage interface {
|
||||
// Save persists the content under the given key and returns the
|
||||
// publicly reachable URL for it.
|
||||
Save(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (url string, err error)
|
||||
// Delete removes the object identified by key. Deleting a
|
||||
// already-removed key must not be treated as an error.
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LocalStorage saves uploads under a directory on disk and serves them via
|
||||
// a static file route (mounted by main.go at /uploads when this driver is
|
||||
// active).
|
||||
type LocalStorage struct {
|
||||
dir string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewLocalStorage(dir, baseURL string) (*LocalStorage, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create upload dir: %w", err)
|
||||
}
|
||||
return &LocalStorage{dir: dir, baseURL: strings.TrimRight(baseURL, "/")}, nil
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Save(_ context.Context, key string, reader io.Reader, _ int64, _ string) (string, error) {
|
||||
// key is always a freshly generated UUID-based name (see service.go),
|
||||
// never derived from user input, so path traversal is not reachable here.
|
||||
dest := filepath.Join(s.dir, filepath.Base(key))
|
||||
|
||||
f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(f, reader); err != nil {
|
||||
return "", fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
|
||||
return s.baseURL + "/" + filepath.Base(key), nil
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Delete(_ context.Context, key string) error {
|
||||
err := os.Remove(filepath.Join(s.dir, filepath.Base(key)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("delete file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/smithy-go"
|
||||
)
|
||||
|
||||
// S3Config holds the settings needed to talk to any S3-compatible bucket
|
||||
// (AWS S3 itself, MinIO, Cloudflare R2, ...).
|
||||
type S3Config struct {
|
||||
Bucket string
|
||||
Region string
|
||||
Endpoint string // leave empty for real AWS S3
|
||||
AccessKeyID string
|
||||
SecretKey string
|
||||
UsePathStyle bool
|
||||
PublicBaseURL string // e.g. https://cdn.example.com or https://bucket.s3.region.amazonaws.com
|
||||
}
|
||||
|
||||
type S3Storage struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewS3Storage(ctx context.Context, cfg S3Config) (*S3Storage, error) {
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
|
||||
awsconfig.WithRegion(cfg.Region),
|
||||
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretKey, "")),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load aws config: %w", err)
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||
if cfg.Endpoint != "" {
|
||||
o.BaseEndpoint = &cfg.Endpoint
|
||||
}
|
||||
o.UsePathStyle = cfg.UsePathStyle
|
||||
})
|
||||
|
||||
baseURL := cfg.PublicBaseURL
|
||||
if baseURL == "" {
|
||||
if cfg.Endpoint != "" {
|
||||
baseURL = strings.TrimRight(cfg.Endpoint, "/") + "/" + cfg.Bucket
|
||||
} else {
|
||||
baseURL = fmt.Sprintf("https://%s.s3.%s.amazonaws.com", cfg.Bucket, cfg.Region)
|
||||
}
|
||||
}
|
||||
|
||||
return &S3Storage{client: client, bucket: cfg.Bucket, baseURL: strings.TrimRight(baseURL, "/")}, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Save(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (string, error) {
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Key: &key,
|
||||
Body: reader,
|
||||
ContentType: &contentType,
|
||||
ContentLength: &size,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("s3 put object: %w", err)
|
||||
}
|
||||
return s.baseURL + "/" + key, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Delete(ctx context.Context, key string) error {
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Key: &key,
|
||||
})
|
||||
if err != nil {
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NoSuchKey" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("s3 delete object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user