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,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 }
|
||||
Reference in New Issue
Block a user