90ac0b81d1
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>
152 lines
4.6 KiB
Go
152 lines
4.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/flatrender/render-svc/internal/db"
|
|
"github.com/flatrender/render-svc/internal/middleware"
|
|
"github.com/flatrender/render-svc/internal/models"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/minio/minio-go/v7"
|
|
)
|
|
|
|
type ExportHandler struct {
|
|
store *db.Store
|
|
minio *minio.Client
|
|
bucket string
|
|
}
|
|
|
|
func NewExportHandler(store *db.Store, mc *minio.Client, bucket string) *ExportHandler {
|
|
return &ExportHandler{store: store, minio: mc, bucket: bucket}
|
|
}
|
|
|
|
// GET /v1/exports
|
|
func (h *ExportHandler) List(c *gin.Context) {
|
|
userID := middleware.GetUserID(c)
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
exports, total, err := h.store.ListExports(c.Request.Context(), userID, page, 20)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
|
return
|
|
}
|
|
if exports == nil {
|
|
exports = []*models.Export{}
|
|
}
|
|
c.JSON(http.StatusOK, models.PagedResponse[*models.Export]{
|
|
Data: exports,
|
|
Meta: models.PaginationMeta{
|
|
Page: page,
|
|
PageSize: 20,
|
|
Total: total,
|
|
HasMore: int64(page*20) < total,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GET /v1/exports/:export_id
|
|
func (h *ExportHandler) Get(c *gin.Context) {
|
|
userID := middleware.GetUserID(c)
|
|
exportID, err := uuid.Parse(c.Param("export_id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid export_id"})
|
|
return
|
|
}
|
|
exp, err := h.store.GetExportByID(c.Request.Context(), exportID, userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: err.Error()})
|
|
return
|
|
}
|
|
files, _ := h.store.ListExportFiles(c.Request.Context(), exportID)
|
|
if files == nil {
|
|
files = []*models.ExportFile{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": exp.ID,
|
|
"saved_project_id": exp.SavedProjectID,
|
|
"path": exp.Path,
|
|
"image": exp.Image,
|
|
"size_bytes": exp.SizeBytes,
|
|
"duration_sec": exp.DurationSec,
|
|
"width": exp.Width,
|
|
"height": exp.Height,
|
|
"file_extension": exp.FileExtension,
|
|
"file_type": exp.FileType,
|
|
"render_quality": exp.RenderQuality,
|
|
"create_type": exp.CreateType,
|
|
"produce_date": exp.ProduceDate,
|
|
"auto_delete_date": exp.AutoDeleteDate,
|
|
"files": files,
|
|
})
|
|
}
|
|
|
|
// DELETE /v1/exports/:export_id
|
|
func (h *ExportHandler) Delete(c *gin.Context) {
|
|
userID := middleware.GetUserID(c)
|
|
exportID, err := uuid.Parse(c.Param("export_id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid export_id"})
|
|
return
|
|
}
|
|
if err := h.store.SoftDeleteExport(c.Request.Context(), exportID, userID); err != nil {
|
|
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: err.Error()})
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// POST /v1/exports/:export_id/extend
|
|
func (h *ExportHandler) Extend(c *gin.Context) {
|
|
userID := middleware.GetUserID(c)
|
|
exportID, err := uuid.Parse(c.Param("export_id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid export_id"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Days int `json:"days" binding:"required,min=1,max=365"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
|
|
return
|
|
}
|
|
newDate, err := h.store.ExtendExportDeleteDate(c.Request.Context(), exportID, userID, body.Days)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"new_auto_delete_date": newDate})
|
|
}
|
|
|
|
// GET /v1/exports/:export_id/download-url
|
|
func (h *ExportHandler) DownloadURL(c *gin.Context) {
|
|
userID := middleware.GetUserID(c)
|
|
exportID, err := uuid.Parse(c.Param("export_id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid export_id"})
|
|
return
|
|
}
|
|
exp, err := h.store.GetExportByID(c.Request.Context(), exportID, userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: err.Error()})
|
|
return
|
|
}
|
|
// Generate presigned URL (15 min TTL)
|
|
expiry := 15 * time.Minute
|
|
url, err := h.minio.PresignedGetObject(context.Background(), h.bucket, exp.Path, expiry, nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: "could not generate download URL"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"url": url.String(),
|
|
"expires_at": time.Now().Add(expiry),
|
|
})
|
|
}
|