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:
soroush.asadi
2026-05-29 23:29:31 +03:30
parent 53ea78a00d
commit 90ac0b81d1
7636 changed files with 3707504 additions and 240 deletions
+314
View File
@@ -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)))
}
+334
View File
@@ -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 }
+79
View File
@@ -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()
}
}
+247
View File
@@ -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"`
}
+75
View File
@@ -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
}