| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480 |
- package handlers
- import (
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "io"
- "math"
- "net/http"
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "time"
- "github.com/celestia-trace/backend/config"
- "github.com/celestia-trace/backend/models"
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
- )
- const maxAssetUploadBytes int64 = 2 * 1024 * 1024 * 1024 // 2 GiB
- type MediaHandler struct {
- DB *gorm.DB
- Cfg *config.Config
- }
- // UploadAsset stores a real session attachment. clientId makes retries
- // idempotent; the SHA-256 digest allows clients to verify the stored content.
- func (h *MediaHandler) UploadAsset(c *gin.Context) {
- userID := c.GetString("userID")
- sessionID := c.Param("id")
- var session models.CelestiaSession
- if err := h.DB.Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).First(&session).Error; err != nil {
- respondError(c, http.StatusNotFound, 404, "record not found")
- return
- }
- c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxAssetUploadBytes)
- clientID := strings.TrimSpace(c.PostForm("clientId"))
- kind := strings.ToUpper(strings.TrimSpace(c.PostForm("kind")))
- if clientID == "" || (kind != "AUDIO" && kind != "PHOTO") {
- respondError(c, http.StatusBadRequest, 400, "clientId and a valid kind are required")
- return
- }
- var existing models.MediaAsset
- if err := h.DB.Where("session_id = ? AND client_id = ?", sessionID, clientID).First(&existing).Error; err == nil {
- respondSuccess(c, existing)
- return
- }
- header, err := c.FormFile("file")
- if err != nil {
- respondError(c, http.StatusBadRequest, 400, "file is required")
- return
- }
- // Storage quota check against incoming file size
- var usedBytes int64
- h.DB.Model(&models.MediaAsset{}).Where("user_id = ?", userID).Select("COALESCE(SUM(size_bytes), 0)").Scan(&usedBytes)
- var setting models.SyncSetting
- if err := h.DB.First(&setting, "user_id = ?", userID).Error; err != nil {
- setting.CloudStorageMaxMB = 5120 // default 5 GB
- }
- maxBytes := setting.CloudStorageMaxMB * 1024 * 1024
- remainingBytes := maxBytes - usedBytes
- if remainingBytes < header.Size {
- c.JSON(http.StatusRequestEntityTooLarge, gin.H{
- "code": 413, "message": "storage quota exceeded",
- "data": gin.H{
- "totalBytes": maxBytes,
- "usedBytes": usedBytes,
- "remainingBytes": remainingBytes,
- "requiredBytes": header.Size,
- },
- })
- return
- }
- input, err := header.Open()
- if err != nil {
- respondError(c, http.StatusBadRequest, 400, "failed to read uploaded file")
- return
- }
- defer input.Close()
- asset := models.MediaAsset{
- SessionID: sessionID,
- UserID: userID,
- ClientID: clientID,
- Kind: kind,
- FileName: filepath.Base(header.Filename),
- MIMEType: header.Header.Get("Content-Type"),
- SizeBytes: 0,
- SHA256: "pending",
- StoragePath: "pending",
- }
- if err := h.DB.Create(&asset).Error; err != nil {
- if isDuplicateError(err) && h.DB.Where("session_id = ? AND client_id = ?", sessionID, clientID).First(&existing).Error == nil {
- respondSuccess(c, existing)
- return
- }
- respondError(c, http.StatusInternalServerError, 500, "failed to create asset")
- return
- }
- directory := filepath.Join(h.Cfg.StoragePath, userID, sessionID)
- if err := os.MkdirAll(directory, 0o750); err != nil {
- h.DB.Delete(&asset)
- respondError(c, http.StatusInternalServerError, 500, "failed to prepare asset storage")
- return
- }
- extension := strings.ToLower(filepath.Ext(header.Filename))
- if len(extension) > 12 {
- extension = ""
- }
- storagePath := filepath.Join(directory, asset.ID+extension)
- output, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
- if err != nil {
- h.DB.Delete(&asset)
- respondError(c, http.StatusInternalServerError, 500, "failed to create asset file")
- return
- }
- hasher := sha256.New()
- size, copyErr := io.Copy(io.MultiWriter(output, hasher), input)
- closeErr := output.Close()
- if copyErr != nil || closeErr != nil {
- os.Remove(storagePath)
- h.DB.Delete(&asset)
- respondError(c, http.StatusInternalServerError, 500, "failed to store uploaded file")
- return
- }
- asset.SizeBytes = size
- asset.SHA256 = hex.EncodeToString(hasher.Sum(nil))
- asset.StoragePath = storagePath
- if err := h.DB.Save(&asset).Error; err != nil {
- os.Remove(storagePath)
- h.DB.Delete(&asset)
- respondError(c, http.StatusInternalServerError, 500, "failed to finalize uploaded file")
- return
- }
- // Increment session revision so other devices detect the new asset
- h.DB.Model(&session).Updates(map[string]interface{}{
- "revision": gorm.Expr("revision + 1"),
- "updated_at": gorm.Expr("NOW()"),
- })
- // Reload to get the updated revision
- h.DB.First(&session, "id = ?", session.ID)
- c.JSON(http.StatusOK, gin.H{
- "code": 0, "message": "success",
- "data": gin.H{
- "asset": asset,
- "sessionRevision": session.Revision,
- },
- })
- }
- func (h *MediaHandler) DownloadAsset(c *gin.Context) {
- userID := c.GetString("userID")
- var asset models.MediaAsset
- if err := h.DB.Where("id = ? AND session_id = ? AND user_id = ?", c.Param("assetId"), c.Param("id"), userID).First(&asset).Error; err != nil {
- respondError(c, http.StatusNotFound, 404, "asset not found")
- return
- }
- if _, err := os.Stat(asset.StoragePath); err != nil {
- respondError(c, http.StatusNotFound, 404, "asset file is unavailable")
- return
- }
- if asset.MIMEType != "" {
- c.Header("Content-Type", asset.MIMEType)
- }
- c.FileAttachment(asset.StoragePath, asset.FileName)
- }
- // InitChunkedUpload creates a new chunked upload session for large files.
- func (h *MediaHandler) InitChunkedUpload(c *gin.Context) {
- userID := c.GetString("userID")
- sessionID := c.Param("id")
- var session models.CelestiaSession
- if err := h.DB.Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).First(&session).Error; err != nil {
- respondError(c, http.StatusNotFound, 404, "record not found")
- return
- }
- var req struct {
- ClientID string `json:"clientId" binding:"required"`
- Kind string `json:"kind" binding:"required"`
- FileName string `json:"fileName" binding:"required"`
- MIMEType string `json:"mimeType"`
- FileSize int64 `json:"fileSize" binding:"required,gt=0"`
- ChunkSize int `json:"chunkSize" binding:"required,gt=0"`
- }
- if err := c.ShouldBindJSON(&req); err != nil {
- respondError(c, http.StatusBadRequest, 400, "invalid request: "+err.Error())
- return
- }
- req.Kind = strings.ToUpper(strings.TrimSpace(req.Kind))
- if req.Kind != "AUDIO" && req.Kind != "PHOTO" {
- respondError(c, http.StatusBadRequest, 400, "kind must be AUDIO or PHOTO")
- return
- }
- // Storage quota pre-check against requested FileSize
- var usedBytes int64
- h.DB.Model(&models.MediaAsset{}).Where("user_id = ?", userID).Select("COALESCE(SUM(size_bytes), 0)").Scan(&usedBytes)
- var setting models.SyncSetting
- if err := h.DB.First(&setting, "user_id = ?", userID).Error; err != nil {
- setting.CloudStorageMaxMB = 5120
- }
- maxBytes := setting.CloudStorageMaxMB * 1024 * 1024
- remainingBytes := maxBytes - usedBytes
- if remainingBytes < req.FileSize {
- c.JSON(http.StatusRequestEntityTooLarge, gin.H{
- "code": 413, "message": "storage quota exceeded",
- "data": gin.H{
- "totalBytes": maxBytes,
- "usedBytes": usedBytes,
- "remainingBytes": remainingBytes,
- "requiredBytes": req.FileSize,
- },
- })
- return
- }
- totalChunks := int(math.Ceil(float64(req.FileSize) / float64(req.ChunkSize)))
- storageDir := filepath.Join(h.Cfg.StoragePath, userID, sessionID, "chunks")
- if err := os.MkdirAll(storageDir, 0o750); err != nil {
- respondError(c, http.StatusInternalServerError, 500, "failed to prepare chunk storage")
- return
- }
- upload := models.ChunkedUpload{
- SessionID: sessionID,
- UserID: userID,
- ClientID: strings.TrimSpace(req.ClientID),
- Kind: req.Kind,
- FileName: filepath.Base(req.FileName),
- MIMEType: req.MIMEType,
- TotalSize: req.FileSize,
- ChunkSize: req.ChunkSize,
- TotalChunks: totalChunks,
- UploadedChunks: 0,
- StorageDir: storageDir,
- Status: "pending",
- ExpiresAt: time.Now().Add(24 * time.Hour),
- }
- if err := h.DB.Create(&upload).Error; err != nil {
- respondError(c, http.StatusInternalServerError, 500, "failed to create chunked upload")
- return
- }
- c.JSON(http.StatusOK, gin.H{
- "code": 0, "message": "success",
- "data": gin.H{
- "uploadId": upload.ID,
- "totalChunks": totalChunks,
- "chunkSize": req.ChunkSize,
- "expiresAt": upload.ExpiresAt,
- },
- })
- }
- // UploadChunk receives a single chunk of a chunked upload.
- func (h *MediaHandler) UploadChunk(c *gin.Context) {
- userID := c.GetString("userID")
- sessionID := c.Param("id")
- uploadID := strings.TrimSpace(c.PostForm("uploadId"))
- chunkIndexStr := strings.TrimSpace(c.PostForm("chunkIndex"))
- if uploadID == "" || chunkIndexStr == "" {
- respondError(c, http.StatusBadRequest, 400, "uploadId and chunkIndex are required")
- return
- }
- chunkIndex, err := strconv.Atoi(chunkIndexStr)
- if err != nil || chunkIndex < 0 {
- respondError(c, http.StatusBadRequest, 400, "chunkIndex must be a non-negative integer")
- return
- }
- var upload models.ChunkedUpload
- if err := h.DB.Where("id = ? AND session_id = ? AND user_id = ? AND status = 'pending'", uploadID, sessionID, userID).First(&upload).Error; err != nil {
- respondError(c, http.StatusNotFound, 404, "chunked upload not found")
- return
- }
- if time.Now().After(upload.ExpiresAt) {
- respondError(c, http.StatusGone, 410, "chunked upload has expired")
- return
- }
- if chunkIndex >= upload.TotalChunks {
- respondError(c, http.StatusBadRequest, 400, "chunkIndex out of range")
- return
- }
- header, err := c.FormFile("file")
- if err != nil {
- respondError(c, http.StatusBadRequest, 400, "file is required")
- return
- }
- input, err := header.Open()
- if err != nil {
- respondError(c, http.StatusBadRequest, 400, "failed to read uploaded chunk")
- return
- }
- defer input.Close()
- chunkPath := filepath.Join(upload.StorageDir, fmt.Sprintf("chunk_%04d", chunkIndex))
- output, err := os.OpenFile(chunkPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
- if err != nil {
- respondError(c, http.StatusInternalServerError, 500, "failed to create chunk file")
- return
- }
- if _, err := io.Copy(output, input); err != nil {
- output.Close()
- os.Remove(chunkPath)
- respondError(c, http.StatusInternalServerError, 500, "failed to write chunk")
- return
- }
- output.Close()
- h.DB.Model(&upload).Update("uploaded_chunks", gorm.Expr("uploaded_chunks + 1"))
- c.JSON(http.StatusOK, gin.H{
- "code": 0, "message": "success",
- "data": gin.H{
- "uploadId": upload.ID,
- "chunkIndex": chunkIndex,
- "uploadedChunks": upload.UploadedChunks + 1,
- "totalChunks": upload.TotalChunks,
- },
- })
- }
- // CompleteChunkedUpload merges all chunks into a single file and creates the
- // MediaAsset record.
- func (h *MediaHandler) CompleteChunkedUpload(c *gin.Context) {
- userID := c.GetString("userID")
- sessionID := c.Param("id")
- var req struct {
- UploadID string `json:"uploadId" binding:"required"`
- TotalChunks int `json:"totalChunks" binding:"required"`
- }
- if err := c.ShouldBindJSON(&req); err != nil {
- respondError(c, http.StatusBadRequest, 400, "invalid request: "+err.Error())
- return
- }
- var upload models.ChunkedUpload
- if err := h.DB.Where("id = ? AND session_id = ? AND user_id = ? AND status = 'pending'", req.UploadID, sessionID, userID).First(&upload).Error; err != nil {
- respondError(c, http.StatusNotFound, 404, "chunked upload not found")
- return
- }
- if time.Now().After(upload.ExpiresAt) {
- respondError(c, http.StatusGone, 410, "chunked upload has expired")
- return
- }
- if req.TotalChunks != upload.TotalChunks {
- respondError(c, http.StatusBadRequest, 400, "totalChunks mismatch")
- return
- }
- // Verify all chunks exist
- for i := 0; i < upload.TotalChunks; i++ {
- chunkPath := filepath.Join(upload.StorageDir, fmt.Sprintf("chunk_%04d", i))
- if _, err := os.Stat(chunkPath); err != nil {
- respondError(c, http.StatusBadRequest, 400, fmt.Sprintf("chunk %d is missing", i))
- return
- }
- }
- // Merge chunks into final file
- directory := filepath.Join(h.Cfg.StoragePath, userID, sessionID)
- if err := os.MkdirAll(directory, 0o750); err != nil {
- respondError(c, http.StatusInternalServerError, 500, "failed to prepare asset storage")
- return
- }
- extension := strings.ToLower(filepath.Ext(upload.FileName))
- if len(extension) > 12 {
- extension = ""
- }
- // Create MediaAsset first to get the ID for the filename
- asset := models.MediaAsset{
- SessionID: sessionID,
- UserID: userID,
- ClientID: upload.ClientID,
- Kind: upload.Kind,
- FileName: upload.FileName,
- MIMEType: upload.MIMEType,
- SizeBytes: 0,
- SHA256: "pending",
- StoragePath: "pending",
- }
- if err := h.DB.Create(&asset).Error; err != nil {
- if isDuplicateError(err) {
- var existing models.MediaAsset
- if h.DB.Where("session_id = ? AND client_id = ?", sessionID, upload.ClientID).First(&existing).Error == nil {
- respondSuccess(c, existing)
- return
- }
- }
- respondError(c, http.StatusInternalServerError, 500, "failed to create asset")
- return
- }
- storagePath := filepath.Join(directory, asset.ID+extension)
- output, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
- if err != nil {
- h.DB.Delete(&asset)
- respondError(c, http.StatusInternalServerError, 500, "failed to create asset file")
- return
- }
- hasher := sha256.New()
- var totalSize int64
- for i := 0; i < upload.TotalChunks; i++ {
- chunkPath := filepath.Join(upload.StorageDir, fmt.Sprintf("chunk_%04d", i))
- chunkFile, err := os.Open(chunkPath)
- if err != nil {
- output.Close()
- os.Remove(storagePath)
- h.DB.Delete(&asset)
- respondError(c, http.StatusInternalServerError, 500, "failed to read chunk")
- return
- }
- n, copyErr := io.Copy(io.MultiWriter(output, hasher), chunkFile)
- chunkFile.Close()
- if copyErr != nil {
- output.Close()
- os.Remove(storagePath)
- h.DB.Delete(&asset)
- respondError(c, http.StatusInternalServerError, 500, "failed to merge chunk")
- return
- }
- totalSize += n
- }
- if err := output.Close(); err != nil {
- os.Remove(storagePath)
- h.DB.Delete(&asset)
- respondError(c, http.StatusInternalServerError, 500, "failed to finalize merged file")
- return
- }
- asset.SizeBytes = totalSize
- asset.SHA256 = hex.EncodeToString(hasher.Sum(nil))
- asset.StoragePath = storagePath
- if err := h.DB.Save(&asset).Error; err != nil {
- os.Remove(storagePath)
- h.DB.Delete(&asset)
- respondError(c, http.StatusInternalServerError, 500, "failed to finalize asset")
- return
- }
- // Clean up chunk files
- for i := 0; i < upload.TotalChunks; i++ {
- os.Remove(filepath.Join(upload.StorageDir, fmt.Sprintf("chunk_%04d", i)))
- }
- os.Remove(upload.StorageDir)
- // Mark upload as completed
- h.DB.Model(&upload).Update("status", "completed")
- // Increment session revision
- var session models.CelestiaSession
- h.DB.First(&session, "id = ?", sessionID)
- h.DB.Model(&session).Updates(map[string]interface{}{
- "revision": gorm.Expr("revision + 1"),
- "updated_at": gorm.Expr("NOW()"),
- })
- h.DB.First(&session, "id = ?", session.ID)
- c.JSON(http.StatusOK, gin.H{
- "code": 0, "message": "success",
- "data": gin.H{
- "asset": asset,
- "sessionRevision": session.Revision,
- },
- })
- }
|