feat: V2 microservices stack — backend services, gateway, JWT auth
Add full V2 architecture: identity, content, studio (.NET 10) and file, render, notification, gateway (Go) services with vendored deps, plus DB migrations, event/API contracts, and an init-db script. Wire the Next.js frontend to the gateway: server-side JWT auth routes (login/register/refresh/logout/me), gateway fetch helper, and session/ cookie/jwt helpers under src/lib. Containerize the stack via docker-compose.v2.yml and per-service Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via next/font/local to avoid Google Fonts (geo-blocked). Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/flatrender/file-svc/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(connStr string) (*Store, error) {
|
||||
cfg, err := pgxpool.ParseConfig(connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db config: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 20
|
||||
cfg.MinConns = 2
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db connect: %w", err)
|
||||
}
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() { s.pool.Close() }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
// ── Folders ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) GetFolders(ctx context.Context, userID uuid.UUID, parentID *uuid.UUID) ([]models.UserFolder, error) {
|
||||
var rows pgx.Rows
|
||||
var err error
|
||||
if parentID == nil {
|
||||
rows, err = s.pool.Query(ctx,
|
||||
`SELECT id, tenant_id, user_id, name, folder_type, parent_folder_id,
|
||||
file_count, total_size_bytes, sort, is_shared, share_token, created_at, updated_at
|
||||
FROM file_mgr.user_folders
|
||||
WHERE user_id = $1 AND parent_folder_id IS NULL AND deleted_at IS NULL
|
||||
ORDER BY sort, name`, userID)
|
||||
} else {
|
||||
rows, err = s.pool.Query(ctx,
|
||||
`SELECT id, tenant_id, user_id, name, folder_type, parent_folder_id,
|
||||
file_count, total_size_bytes, sort, is_shared, share_token, created_at, updated_at
|
||||
FROM file_mgr.user_folders
|
||||
WHERE user_id = $1 AND parent_folder_id = $2 AND deleted_at IS NULL
|
||||
ORDER BY sort, name`, userID, parentID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var folders []models.UserFolder
|
||||
for rows.Next() {
|
||||
var f models.UserFolder
|
||||
if err := rows.Scan(&f.ID, &f.TenantID, &f.UserID, &f.Name, &f.FolderType, &f.ParentFolderID,
|
||||
&f.FileCount, &f.TotalSizeBytes, &f.Sort, &f.IsShared, &f.ShareToken, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
folders = append(folders, f)
|
||||
}
|
||||
return folders, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateFolder(ctx context.Context, tenantID, userID uuid.UUID, req models.CreateFolderRequest) (*models.UserFolder, error) {
|
||||
var f models.UserFolder
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO file_mgr.user_folders (tenant_id, user_id, name, folder_type, parent_folder_id)
|
||||
VALUES ($1, $2, $3, 'User', $4)
|
||||
RETURNING id, tenant_id, user_id, name, folder_type, parent_folder_id,
|
||||
file_count, total_size_bytes, sort, is_shared, share_token, created_at, updated_at`,
|
||||
tenantID, userID, req.Name, req.ParentFolderID,
|
||||
).Scan(&f.ID, &f.TenantID, &f.UserID, &f.Name, &f.FolderType, &f.ParentFolderID,
|
||||
&f.FileCount, &f.TotalSizeBytes, &f.Sort, &f.IsShared, &f.ShareToken, &f.CreatedAt, &f.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFolder(ctx context.Context, id, userID uuid.UUID) error {
|
||||
ct, err := s.pool.Exec(ctx,
|
||||
`UPDATE file_mgr.user_folders SET deleted_at = NOW() WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`,
|
||||
id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Files ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListFiles(ctx context.Context, userID uuid.UUID, req models.FileListRequest) ([]models.UserFile, int64, error) {
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
|
||||
baseQ := `FROM file_mgr.user_files WHERE user_id = $1 AND deleted_at IS NULL`
|
||||
args := []any{userID}
|
||||
argN := 2
|
||||
|
||||
if req.FolderID != nil {
|
||||
baseQ += fmt.Sprintf(" AND user_folder_id = $%d", argN)
|
||||
args = append(args, req.FolderID)
|
||||
argN++
|
||||
}
|
||||
if req.FileType != nil {
|
||||
baseQ += fmt.Sprintf(" AND file_type = $%d", argN)
|
||||
args = append(args, req.FileType)
|
||||
argN++
|
||||
}
|
||||
if req.Search != nil {
|
||||
baseQ += fmt.Sprintf(" AND name ILIKE $%d", argN)
|
||||
args = append(args, "%"+*req.Search+"%")
|
||||
argN++
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, "SELECT COUNT(*) "+baseQ, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
args = append(args, req.PageSize, offset)
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, tenant_id, user_id, user_folder_id, name, original_filename, file_extension, mime_type,
|
||||
file_type, minio_bucket, minio_key, cdn_url, file_address, size_bytes, md5_hash,
|
||||
thumbnail_url, upload_status, upload_progress, source, last_used_at, use_count,
|
||||
is_public, created_at, updated_at
|
||||
`+baseQ+fmt.Sprintf(` ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, argN, argN+1),
|
||||
args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var files []models.UserFile
|
||||
for rows.Next() {
|
||||
var f models.UserFile
|
||||
if err := rows.Scan(&f.ID, &f.TenantID, &f.UserID, &f.UserFolderID, &f.Name, &f.OriginalFilename,
|
||||
&f.FileExtension, &f.MimeType, &f.FileType, &f.MinioBucket, &f.MinioKey, &f.CdnURL,
|
||||
&f.FileAddress, &f.SizeBytes, &f.Md5Hash, &f.ThumbnailURL, &f.UploadStatus, &f.UploadProgress,
|
||||
&f.Source, &f.LastUsedAt, &f.UseCount, &f.IsPublic, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
return files, total, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetFile(ctx context.Context, id, userID uuid.UUID) (*models.UserFile, error) {
|
||||
var f models.UserFile
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, tenant_id, user_id, user_folder_id, name, original_filename, file_extension, mime_type,
|
||||
file_type, minio_bucket, minio_key, cdn_url, file_address, size_bytes, md5_hash,
|
||||
sha256_hash, duration_sec, width, height, fps, bitrate_kbps, codec, has_audio, has_video,
|
||||
thumbnail_url, waveform_data, upload_status, upload_progress, source, export_id, parent_file_id,
|
||||
last_used_at, use_count, is_public, share_token, metadata, created_at, updated_at
|
||||
FROM file_mgr.user_files
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`, id, userID,
|
||||
).Scan(&f.ID, &f.TenantID, &f.UserID, &f.UserFolderID, &f.Name, &f.OriginalFilename,
|
||||
&f.FileExtension, &f.MimeType, &f.FileType, &f.MinioBucket, &f.MinioKey, &f.CdnURL,
|
||||
&f.FileAddress, &f.SizeBytes, &f.Md5Hash, &f.Sha256Hash, &f.DurationSec, &f.Width, &f.Height,
|
||||
&f.Fps, &f.BitrateKbps, &f.Codec, &f.HasAudio, &f.HasVideo, &f.ThumbnailURL, &f.WaveformData,
|
||||
&f.UploadStatus, &f.UploadProgress, &f.Source, &f.ExportID, &f.ParentFileID,
|
||||
&f.LastUsedAt, &f.UseCount, &f.IsPublic, &f.ShareToken, &f.Metadata, &f.CreatedAt, &f.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateFileRecord(ctx context.Context, f *models.UserFile) error {
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO file_mgr.user_files
|
||||
(tenant_id, user_id, user_folder_id, name, original_filename, file_extension, mime_type,
|
||||
file_type, minio_bucket, minio_key, file_address, size_bytes, upload_status, source)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'Pending',$13)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
f.TenantID, f.UserID, f.UserFolderID, f.Name, f.OriginalFilename, f.FileExtension,
|
||||
f.MimeType, f.FileType, f.MinioBucket, f.MinioKey, f.FileAddress, f.SizeBytes, f.Source,
|
||||
).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) MarkFileReady(ctx context.Context, id uuid.UUID, cdnURL *string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE file_mgr.user_files
|
||||
SET upload_status = 'Ready', upload_progress = 100, cdn_url = $2, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
id, cdnURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFile(ctx context.Context, id, userID uuid.UUID) (*models.UserFile, error) {
|
||||
var f models.UserFile
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`UPDATE file_mgr.user_files SET deleted_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
||||
RETURNING id, minio_bucket, minio_key, size_bytes, user_folder_id`,
|
||||
id, userID,
|
||||
).Scan(&f.ID, &f.MinioBucket, &f.MinioKey, &f.SizeBytes, &f.UserFolderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// ── Storage Quota ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) GetQuota(ctx context.Context, userID uuid.UUID) (*models.StorageQuota, error) {
|
||||
var q models.StorageQuota
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT user_id, tenant_id, plan_quota_bytes, bonus_quota_bytes, used_bytes,
|
||||
video_count, image_count, audio_count, video_bytes, image_bytes, audio_bytes,
|
||||
last_90pct_notified_at, last_100pct_notified_at, updated_at
|
||||
FROM file_mgr.storage_quotas WHERE user_id = $1`, userID,
|
||||
).Scan(&q.UserID, &q.TenantID, &q.PlanQuotaBytes, &q.BonusQuotaBytes, &q.UsedBytes,
|
||||
&q.VideoCount, &q.ImageCount, &q.AudioCount, &q.VideoBytes, &q.ImageBytes, &q.AudioBytes,
|
||||
&q.Last90PctNotifiedAt, &q.Last100PctNotifiedAt, &q.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
func (s *Store) EnsureQuota(ctx context.Context, userID, tenantID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO file_mgr.storage_quotas (user_id, tenant_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO NOTHING`, userID, tenantID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AddUsedBytes(ctx context.Context, userID uuid.UUID, delta int64) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE file_mgr.storage_quotas SET used_bytes = used_bytes + $2, updated_at = NOW()
|
||||
WHERE user_id = $1`, userID, delta)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Upload Sessions ───────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) CreateUploadSession(ctx context.Context, sess *models.UploadSession) error {
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO file_mgr.upload_sessions
|
||||
(tenant_id, user_id, minio_bucket, minio_key, minio_upload_id, filename, mime_type,
|
||||
total_size_bytes, chunk_size_bytes, target_folder_id, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'Uploading')
|
||||
RETURNING id, expires_at, created_at, updated_at`,
|
||||
sess.TenantID, sess.UserID, sess.MinioBucket, sess.MinioKey, sess.MinioUploadID,
|
||||
sess.Filename, sess.MimeType, sess.TotalSizeBytes, sess.ChunkSizeBytes, sess.TargetFolderID,
|
||||
).Scan(&sess.ID, &sess.ExpiresAt, &sess.CreatedAt, &sess.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) GetUploadSession(ctx context.Context, id uuid.UUID) (*models.UploadSession, error) {
|
||||
var sess models.UploadSession
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, tenant_id, user_id, minio_bucket, minio_key, minio_upload_id, filename, mime_type,
|
||||
total_size_bytes, chunks_received, bytes_received, chunk_size_bytes,
|
||||
target_folder_id, target_file_id, status, error_message, expires_at, completed_at, created_at, updated_at
|
||||
FROM file_mgr.upload_sessions WHERE id = $1`, id,
|
||||
).Scan(&sess.ID, &sess.TenantID, &sess.UserID, &sess.MinioBucket, &sess.MinioKey, &sess.MinioUploadID,
|
||||
&sess.Filename, &sess.MimeType, &sess.TotalSizeBytes, &sess.ChunksReceived, &sess.BytesReceived,
|
||||
&sess.ChunkSizeBytes, &sess.TargetFolderID, &sess.TargetFileID, &sess.Status, &sess.ErrorMessage,
|
||||
&sess.ExpiresAt, &sess.CompletedAt, &sess.CreatedAt, &sess.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
func (s *Store) CompleteUploadSession(ctx context.Context, id, fileID uuid.UUID) error {
|
||||
now := time.Now()
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE file_mgr.upload_sessions
|
||||
SET status = 'Ready', target_file_id = $2, completed_at = $3, updated_at = $3
|
||||
WHERE id = $1`, id, fileID, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── MinIO Buckets ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) GetBucketByPurpose(ctx context.Context, purpose string) (*models.MinioBucket, error) {
|
||||
var b models.MinioBucket
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, name, region, endpoint, purpose, is_public, cdn_base_url, is_active, created_at
|
||||
FROM file_mgr.minio_buckets WHERE purpose = $1 AND is_active = TRUE LIMIT 1`, purpose,
|
||||
).Scan(&b.ID, &b.Name, &b.Region, &b.Endpoint, &b.Purpose, &b.IsPublic, &b.CdnBaseURL, &b.IsActive, &b.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TotalPages(total int64, pageSize int) int {
|
||||
if pageSize == 0 {
|
||||
return 0
|
||||
}
|
||||
return int(math.Ceil(float64(total) / float64(pageSize)))
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/flatrender/file-svc/internal/db"
|
||||
"github.com/flatrender/file-svc/internal/middleware"
|
||||
"github.com/flatrender/file-svc/internal/models"
|
||||
"github.com/flatrender/file-svc/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type FileHandler struct {
|
||||
store *db.Store
|
||||
minio *storage.MinioClient
|
||||
bucket string // default upload bucket name
|
||||
}
|
||||
|
||||
func NewFileHandler(store *db.Store, minio *storage.MinioClient, bucket string) *FileHandler {
|
||||
return &FileHandler{store: store, minio: minio, bucket: bucket}
|
||||
}
|
||||
|
||||
// GET /v1/files
|
||||
func (h *FileHandler) ListFiles(c *gin.Context) {
|
||||
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
|
||||
|
||||
var req models.FileListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize < 1 || req.PageSize > 100 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
|
||||
files, total, err := h.store.ListFiles(c.Request.Context(), userID, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
if files == nil {
|
||||
files = []models.UserFile{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.PagedResponse[models.UserFile]{
|
||||
Items: files,
|
||||
Meta: models.PaginationMeta{Page: req.Page, PageSize: req.PageSize, Total: total, TotalPages: db.TotalPages(total, req.PageSize)},
|
||||
})
|
||||
}
|
||||
|
||||
// GET /v1/files/:id
|
||||
func (h *FileHandler) GetFile(c *gin.Context) {
|
||||
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.store.GetFile(c.Request.Context(), id, userID)
|
||||
if err == pgx.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "file not found"}})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, file)
|
||||
}
|
||||
|
||||
// POST /v1/files/presigned-upload
|
||||
func (h *FileHandler) PresignedUpload(c *gin.Context) {
|
||||
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
|
||||
tenantID := c.MustGet(middleware.KeyTenantID).(uuid.UUID)
|
||||
|
||||
var req models.PresignedUploadRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.EnsureQuota(c.Request.Context(), userID, tenantID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(req.Filename))
|
||||
key := fmt.Sprintf("uploads/%s/%s%s", userID, uuid.New(), ext)
|
||||
|
||||
uploadURL, err := h.minio.PresignedPutURL(c.Request.Context(), h.bucket, key, 15*time.Minute)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "storage_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
fileKind := guessFileKind(ext, req.MimeType)
|
||||
file := &models.UserFile{
|
||||
TenantID: tenantID,
|
||||
UserID: userID,
|
||||
UserFolderID: req.TargetFolderID,
|
||||
Name: req.Filename,
|
||||
OriginalFilename: &req.Filename,
|
||||
FileExtension: &ext,
|
||||
MimeType: req.MimeType,
|
||||
FileType: fileKind,
|
||||
MinioBucket: h.bucket,
|
||||
MinioKey: key,
|
||||
FileAddress: fmt.Sprintf("minio://%s/%s", h.bucket, key),
|
||||
SizeBytes: req.SizeBytes,
|
||||
UploadStatus: models.UploadStatusPending,
|
||||
Source: strPtr("upload"),
|
||||
}
|
||||
|
||||
if err := h.store.CreateFileRecord(c.Request.Context(), file); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.PresignedUploadResponse{
|
||||
UploadURL: uploadURL,
|
||||
FileID: file.ID,
|
||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /v1/files/:id/confirm
|
||||
func (h *FileHandler) ConfirmUpload(c *gin.Context) {
|
||||
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.store.GetFile(c.Request.Context(), id, userID)
|
||||
if err == pgx.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "file not found"}})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.MarkFileReady(c.Request.Context(), id, nil); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.AddUsedBytes(c.Request.Context(), userID, file.SizeBytes); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ready"})
|
||||
}
|
||||
|
||||
// DELETE /v1/files/:id
|
||||
func (h *FileHandler) DeleteFile(c *gin.Context) {
|
||||
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.store.DeleteFile(c.Request.Context(), id, userID)
|
||||
if err == pgx.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "file not found"}})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
_ = h.minio.DeleteObject(c.Request.Context(), file.MinioBucket, file.MinioKey)
|
||||
_ = h.store.AddUsedBytes(c.Request.Context(), userID, -file.SizeBytes)
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /v1/files/:id/download
|
||||
func (h *FileHandler) GetDownloadURL(c *gin.Context) {
|
||||
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.store.GetFile(c.Request.Context(), id, userID)
|
||||
if err == pgx.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "file not found"}})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
dlURL, err := h.minio.PresignedGetURL(c.Request.Context(), file.MinioBucket, file.MinioKey, time.Hour)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "storage_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"url": dlURL, "expires_in": 3600})
|
||||
}
|
||||
|
||||
// GET /v1/quota
|
||||
func (h *FileHandler) GetQuota(c *gin.Context) {
|
||||
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
|
||||
tenantID := c.MustGet(middleware.KeyTenantID).(uuid.UUID)
|
||||
|
||||
if err := h.store.EnsureQuota(c.Request.Context(), userID, tenantID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
quota, err := h.store.GetQuota(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, quota)
|
||||
}
|
||||
|
||||
// ── Folders ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /v1/folders
|
||||
func (h *FileHandler) ListFolders(c *gin.Context) {
|
||||
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
|
||||
|
||||
var parentID *uuid.UUID
|
||||
if p := c.Query("parent_id"); p != "" {
|
||||
id, err := uuid.Parse(p)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid parent_id"}})
|
||||
return
|
||||
}
|
||||
parentID = &id
|
||||
}
|
||||
|
||||
folders, err := h.store.GetFolders(c.Request.Context(), userID, parentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
if folders == nil {
|
||||
folders = []models.UserFolder{}
|
||||
}
|
||||
c.JSON(http.StatusOK, folders)
|
||||
}
|
||||
|
||||
// POST /v1/folders
|
||||
func (h *FileHandler) CreateFolder(c *gin.Context) {
|
||||
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
|
||||
tenantID := c.MustGet(middleware.KeyTenantID).(uuid.UUID)
|
||||
|
||||
var req models.CreateFolderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
folder, err := h.store.CreateFolder(c.Request.Context(), tenantID, userID, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, folder)
|
||||
}
|
||||
|
||||
// DELETE /v1/folders/:id
|
||||
func (h *FileHandler) DeleteFolder(c *gin.Context) {
|
||||
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteFolder(c.Request.Context(), id, userID); err == pgx.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "folder not found"}})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func guessFileKind(ext string, mime *string) models.FileKind {
|
||||
if mime != nil {
|
||||
switch {
|
||||
case strings.HasPrefix(*mime, "video/"):
|
||||
return models.FileKindVideo
|
||||
case strings.HasPrefix(*mime, "image/"):
|
||||
return models.FileKindImage
|
||||
case strings.HasPrefix(*mime, "audio/"):
|
||||
return models.FileKindAudio
|
||||
}
|
||||
}
|
||||
switch ext {
|
||||
case ".mp4", ".mov", ".avi", ".webm", ".mkv":
|
||||
return models.FileKindVideo
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg":
|
||||
return models.FileKindImage
|
||||
case ".mp3", ".wav", ".ogg", ".aac", ".flac":
|
||||
return models.FileKindAudio
|
||||
}
|
||||
return models.FileKindOther
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,79 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/flatrender/file-svc/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
KeyUserID = "user_id"
|
||||
KeyTenantID = "tenant_id"
|
||||
KeyIsAdmin = "is_admin"
|
||||
)
|
||||
|
||||
func Auth(jwtSecret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, models.ErrorResponse{
|
||||
Error: models.APIError{Code: "unauthorized", Message: "missing bearer token"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(header, "Bearer ")
|
||||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, models.ErrorResponse{
|
||||
Error: models.APIError{Code: "unauthorized", Message: "invalid token"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, models.ErrorResponse{
|
||||
Error: models.APIError{Code: "unauthorized", Message: "invalid claims"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(claims["sub"].(string))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, models.ErrorResponse{
|
||||
Error: models.APIError{Code: "unauthorized", Message: "invalid sub claim"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, _ := uuid.Parse(claims["tenant_id"].(string))
|
||||
isAdmin, _ := claims["is_admin"].(bool)
|
||||
|
||||
c.Set(KeyUserID, userID)
|
||||
c.Set(KeyTenantID, tenantID)
|
||||
c.Set(KeyIsAdmin, isAdmin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if isAdmin, _ := c.Get(KeyIsAdmin); isAdmin != true {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, models.ErrorResponse{
|
||||
Error: models.APIError{Code: "forbidden", Message: "admin only"},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ── Enums ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type FileKind string
|
||||
|
||||
const (
|
||||
FileKindVideo FileKind = "Video"
|
||||
FileKindImage FileKind = "Image"
|
||||
FileKindAudio FileKind = "Audio"
|
||||
FileKindVoiceover FileKind = "Voiceover"
|
||||
FileKindDocument FileKind = "Document"
|
||||
FileKindOther FileKind = "Other"
|
||||
)
|
||||
|
||||
type FolderKind string
|
||||
|
||||
const (
|
||||
FolderKindSystem FolderKind = "System"
|
||||
FolderKindUser FolderKind = "User"
|
||||
FolderKindShared FolderKind = "Shared"
|
||||
FolderKindTenant FolderKind = "Tenant"
|
||||
)
|
||||
|
||||
type UploadStatus string
|
||||
|
||||
const (
|
||||
UploadStatusPending UploadStatus = "Pending"
|
||||
UploadStatusUploading UploadStatus = "Uploading"
|
||||
UploadStatusProcessing UploadStatus = "Processing"
|
||||
UploadStatusReady UploadStatus = "Ready"
|
||||
UploadStatusFailed UploadStatus = "Failed"
|
||||
UploadStatusQuarantined UploadStatus = "Quarantined"
|
||||
)
|
||||
|
||||
type CleanupEntityType string
|
||||
|
||||
const (
|
||||
CleanupEntityExport CleanupEntityType = "Export"
|
||||
CleanupEntityTempRenderFolder CleanupEntityType = "TempRenderFolder"
|
||||
CleanupEntityOrphanedFile CleanupEntityType = "OrphanedFile"
|
||||
CleanupEntityUnusedUpload CleanupEntityType = "UnusedUpload"
|
||||
CleanupEntitySnapshotExpired CleanupEntityType = "SnapshotExpired"
|
||||
)
|
||||
|
||||
type CleanupStatus string
|
||||
|
||||
const (
|
||||
CleanupStatusScheduled CleanupStatus = "Scheduled"
|
||||
CleanupStatusNotified CleanupStatus = "Notified"
|
||||
CleanupStatusProcessing CleanupStatus = "Processing"
|
||||
CleanupStatusDone CleanupStatus = "Done"
|
||||
CleanupStatusSkipped CleanupStatus = "Skipped"
|
||||
CleanupStatusFailed CleanupStatus = "Failed"
|
||||
)
|
||||
|
||||
// ── Domain ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type UserFolder struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
FolderType FolderKind `json:"folder_type"`
|
||||
ParentFolderID *uuid.UUID `json:"parent_folder_id,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalSizeBytes int64 `json:"total_size_bytes"`
|
||||
Sort int `json:"sort"`
|
||||
IsShared bool `json:"is_shared"`
|
||||
ShareToken *string `json:"share_token,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
type UserFile struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
UserFolderID *uuid.UUID `json:"user_folder_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
OriginalFilename *string `json:"original_filename,omitempty"`
|
||||
FileExtension *string `json:"file_extension,omitempty"`
|
||||
MimeType *string `json:"mime_type,omitempty"`
|
||||
FileType FileKind `json:"file_type"`
|
||||
MinioBucket string `json:"minio_bucket"`
|
||||
MinioKey string `json:"minio_key"`
|
||||
CdnURL *string `json:"cdn_url,omitempty"`
|
||||
FileAddress string `json:"file_address"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Md5Hash *string `json:"md5_hash,omitempty"`
|
||||
Sha256Hash *string `json:"sha256_hash,omitempty"`
|
||||
DurationSec *float64 `json:"duration_sec,omitempty"`
|
||||
Width *int `json:"width,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
Fps *float64 `json:"fps,omitempty"`
|
||||
BitrateKbps *int `json:"bitrate_kbps,omitempty"`
|
||||
Codec *string `json:"codec,omitempty"`
|
||||
HasAudio *bool `json:"has_audio,omitempty"`
|
||||
HasVideo *bool `json:"has_video,omitempty"`
|
||||
ThumbnailURL *string `json:"thumbnail_url,omitempty"`
|
||||
WaveformData *string `json:"waveform_data,omitempty"`
|
||||
UploadStatus UploadStatus `json:"upload_status"`
|
||||
UploadProgress int `json:"upload_progress"`
|
||||
Source *string `json:"source,omitempty"`
|
||||
ExportID *uuid.UUID `json:"export_id,omitempty"`
|
||||
ParentFileID *uuid.UUID `json:"parent_file_id,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
UseCount int `json:"use_count"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
ShareToken *string `json:"share_token,omitempty"`
|
||||
Metadata string `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
type StorageQuota struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
PlanQuotaBytes int64 `json:"plan_quota_bytes"`
|
||||
BonusQuotaBytes int64 `json:"bonus_quota_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
VideoCount int `json:"video_count"`
|
||||
ImageCount int `json:"image_count"`
|
||||
AudioCount int `json:"audio_count"`
|
||||
VideoBytes int64 `json:"video_bytes"`
|
||||
ImageBytes int64 `json:"image_bytes"`
|
||||
AudioBytes int64 `json:"audio_bytes"`
|
||||
Last90PctNotifiedAt *time.Time `json:"last_90pct_notified_at,omitempty"`
|
||||
Last100PctNotifiedAt *time.Time `json:"last_100pct_notified_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UploadSession struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
MinioBucket string `json:"minio_bucket"`
|
||||
MinioKey string `json:"minio_key"`
|
||||
MinioUploadID string `json:"minio_upload_id"`
|
||||
Filename string `json:"filename"`
|
||||
MimeType *string `json:"mime_type,omitempty"`
|
||||
TotalSizeBytes int64 `json:"total_size_bytes"`
|
||||
ChunksReceived int `json:"chunks_received"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
ChunkSizeBytes int `json:"chunk_size_bytes"`
|
||||
TargetFolderID *uuid.UUID `json:"target_folder_id,omitempty"`
|
||||
TargetFileID *uuid.UUID `json:"target_file_id,omitempty"`
|
||||
Status UploadStatus `json:"status"`
|
||||
ErrorMessage *string `json:"error_message,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type MinioBucket struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Region string `json:"region"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Purpose string `json:"purpose"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
CdnBaseURL *string `json:"cdn_base_url,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ── Request / Response ────────────────────────────────────────────────────────
|
||||
|
||||
type CreateFolderRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ParentFolderID *uuid.UUID `json:"parent_folder_id"`
|
||||
}
|
||||
|
||||
type MoveFolderRequest struct {
|
||||
ParentFolderID *uuid.UUID `json:"parent_folder_id"`
|
||||
}
|
||||
|
||||
type RenameFolderRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
type InitiateUploadRequest struct {
|
||||
Filename string `json:"filename" binding:"required"`
|
||||
MimeType *string `json:"mime_type"`
|
||||
TotalSizeBytes int64 `json:"total_size_bytes" binding:"required,min=1"`
|
||||
ChunkSizeBytes int `json:"chunk_size_bytes"`
|
||||
TargetFolderID *uuid.UUID `json:"target_folder_id"`
|
||||
}
|
||||
|
||||
type PresignedUploadRequest struct {
|
||||
Filename string `json:"filename" binding:"required"`
|
||||
MimeType *string `json:"mime_type"`
|
||||
SizeBytes int64 `json:"size_bytes" binding:"required,min=1"`
|
||||
TargetFolderID *uuid.UUID `json:"target_folder_id"`
|
||||
}
|
||||
|
||||
type PresignedUploadResponse struct {
|
||||
UploadURL string `json:"upload_url"`
|
||||
FileID uuid.UUID `json:"file_id"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type FileListRequest struct {
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=20"`
|
||||
FolderID *uuid.UUID `form:"folder_id"`
|
||||
FileType *FileKind `form:"file_type"`
|
||||
Search *string `form:"search"`
|
||||
}
|
||||
|
||||
type PagedResponse[T any] struct {
|
||||
Items []T `json:"items"`
|
||||
Meta PaginationMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type PaginationMeta struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error APIError `json:"error"`
|
||||
}
|
||||
|
||||
// ── JWT Claims ────────────────────────────────────────────────────────────────
|
||||
|
||||
type Claims struct {
|
||||
Sub string `json:"sub"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type MinioClient struct {
|
||||
client *minio.Client
|
||||
endpoint string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Endpoint string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
UseSSL bool
|
||||
}
|
||||
|
||||
func NewMinioClient(cfg Config) (*MinioClient, error) {
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKeyID, cfg.SecretAccessKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minio client: %w", err)
|
||||
}
|
||||
return &MinioClient{client: client, endpoint: cfg.Endpoint}, nil
|
||||
}
|
||||
|
||||
func (m *MinioClient) PresignedPutURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) {
|
||||
u, err := m.client.PresignedPutObject(ctx, bucket, key, expiry)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (m *MinioClient) PresignedGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) {
|
||||
reqParams := url.Values{}
|
||||
u, err := m.client.PresignedGetObject(ctx, bucket, key, expiry, reqParams)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (m *MinioClient) GenerateKey(folder string) string {
|
||||
return fmt.Sprintf("%s/%s", folder, uuid.New().String())
|
||||
}
|
||||
|
||||
func (m *MinioClient) DeleteObject(ctx context.Context, bucket, key string) error {
|
||||
return m.client.RemoveObject(ctx, bucket, key, minio.RemoveObjectOptions{})
|
||||
}
|
||||
|
||||
func (m *MinioClient) GetObject(ctx context.Context, bucket, key string) (io.ReadCloser, error) {
|
||||
obj, err := m.client.GetObject(ctx, bucket, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
func (m *MinioClient) InitiateMultipartUpload(ctx context.Context, bucket, key string) (string, error) {
|
||||
// MinIO SDK doesn't directly expose multipart — use presigned PUT per chunk instead.
|
||||
// Return a generated upload ID for session tracking.
|
||||
return uuid.New().String(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user