media_handler.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. package handlers
  2. import (
  3. "crypto/sha256"
  4. "encoding/hex"
  5. "fmt"
  6. "io"
  7. "math"
  8. "net/http"
  9. "os"
  10. "path/filepath"
  11. "strconv"
  12. "strings"
  13. "time"
  14. "github.com/celestia-trace/backend/config"
  15. "github.com/celestia-trace/backend/models"
  16. "github.com/gin-gonic/gin"
  17. "gorm.io/gorm"
  18. )
  19. const maxAssetUploadBytes int64 = 2 * 1024 * 1024 * 1024 // 2 GiB
  20. type MediaHandler struct {
  21. DB *gorm.DB
  22. Cfg *config.Config
  23. }
  24. // UploadAsset stores a real session attachment. clientId makes retries
  25. // idempotent; the SHA-256 digest allows clients to verify the stored content.
  26. func (h *MediaHandler) UploadAsset(c *gin.Context) {
  27. userID := c.GetString("userID")
  28. sessionID := c.Param("id")
  29. var session models.CelestiaSession
  30. if err := h.DB.Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).First(&session).Error; err != nil {
  31. respondError(c, http.StatusNotFound, 404, "record not found")
  32. return
  33. }
  34. c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxAssetUploadBytes)
  35. clientID := strings.TrimSpace(c.PostForm("clientId"))
  36. kind := strings.ToUpper(strings.TrimSpace(c.PostForm("kind")))
  37. if clientID == "" || (kind != "AUDIO" && kind != "PHOTO") {
  38. respondError(c, http.StatusBadRequest, 400, "clientId and a valid kind are required")
  39. return
  40. }
  41. var existing models.MediaAsset
  42. if err := h.DB.Where("session_id = ? AND client_id = ?", sessionID, clientID).First(&existing).Error; err == nil {
  43. respondSuccess(c, existing)
  44. return
  45. }
  46. header, err := c.FormFile("file")
  47. if err != nil {
  48. respondError(c, http.StatusBadRequest, 400, "file is required")
  49. return
  50. }
  51. // Storage quota check against incoming file size
  52. var usedBytes int64
  53. h.DB.Model(&models.MediaAsset{}).Where("user_id = ?", userID).Select("COALESCE(SUM(size_bytes), 0)").Scan(&usedBytes)
  54. var setting models.SyncSetting
  55. if err := h.DB.First(&setting, "user_id = ?", userID).Error; err != nil {
  56. setting.CloudStorageMaxMB = 5120 // default 5 GB
  57. }
  58. maxBytes := setting.CloudStorageMaxMB * 1024 * 1024
  59. remainingBytes := maxBytes - usedBytes
  60. if remainingBytes < header.Size {
  61. c.JSON(http.StatusRequestEntityTooLarge, gin.H{
  62. "code": 413, "message": "storage quota exceeded",
  63. "data": gin.H{
  64. "totalBytes": maxBytes,
  65. "usedBytes": usedBytes,
  66. "remainingBytes": remainingBytes,
  67. "requiredBytes": header.Size,
  68. },
  69. })
  70. return
  71. }
  72. input, err := header.Open()
  73. if err != nil {
  74. respondError(c, http.StatusBadRequest, 400, "failed to read uploaded file")
  75. return
  76. }
  77. defer input.Close()
  78. asset := models.MediaAsset{
  79. SessionID: sessionID,
  80. UserID: userID,
  81. ClientID: clientID,
  82. Kind: kind,
  83. FileName: filepath.Base(header.Filename),
  84. MIMEType: header.Header.Get("Content-Type"),
  85. SizeBytes: 0,
  86. SHA256: "pending",
  87. StoragePath: "pending",
  88. }
  89. if err := h.DB.Create(&asset).Error; err != nil {
  90. if isDuplicateError(err) && h.DB.Where("session_id = ? AND client_id = ?", sessionID, clientID).First(&existing).Error == nil {
  91. respondSuccess(c, existing)
  92. return
  93. }
  94. respondError(c, http.StatusInternalServerError, 500, "failed to create asset")
  95. return
  96. }
  97. directory := filepath.Join(h.Cfg.StoragePath, userID, sessionID)
  98. if err := os.MkdirAll(directory, 0o750); err != nil {
  99. h.DB.Delete(&asset)
  100. respondError(c, http.StatusInternalServerError, 500, "failed to prepare asset storage")
  101. return
  102. }
  103. extension := strings.ToLower(filepath.Ext(header.Filename))
  104. if len(extension) > 12 {
  105. extension = ""
  106. }
  107. storagePath := filepath.Join(directory, asset.ID+extension)
  108. output, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
  109. if err != nil {
  110. h.DB.Delete(&asset)
  111. respondError(c, http.StatusInternalServerError, 500, "failed to create asset file")
  112. return
  113. }
  114. hasher := sha256.New()
  115. size, copyErr := io.Copy(io.MultiWriter(output, hasher), input)
  116. closeErr := output.Close()
  117. if copyErr != nil || closeErr != nil {
  118. os.Remove(storagePath)
  119. h.DB.Delete(&asset)
  120. respondError(c, http.StatusInternalServerError, 500, "failed to store uploaded file")
  121. return
  122. }
  123. asset.SizeBytes = size
  124. asset.SHA256 = hex.EncodeToString(hasher.Sum(nil))
  125. asset.StoragePath = storagePath
  126. if err := h.DB.Save(&asset).Error; err != nil {
  127. os.Remove(storagePath)
  128. h.DB.Delete(&asset)
  129. respondError(c, http.StatusInternalServerError, 500, "failed to finalize uploaded file")
  130. return
  131. }
  132. // Increment session revision so other devices detect the new asset
  133. h.DB.Model(&session).Updates(map[string]interface{}{
  134. "revision": gorm.Expr("revision + 1"),
  135. "updated_at": gorm.Expr("NOW()"),
  136. })
  137. // Reload to get the updated revision
  138. h.DB.First(&session, "id = ?", session.ID)
  139. c.JSON(http.StatusOK, gin.H{
  140. "code": 0, "message": "success",
  141. "data": gin.H{
  142. "asset": asset,
  143. "sessionRevision": session.Revision,
  144. },
  145. })
  146. }
  147. func (h *MediaHandler) DownloadAsset(c *gin.Context) {
  148. userID := c.GetString("userID")
  149. var asset models.MediaAsset
  150. if err := h.DB.Where("id = ? AND session_id = ? AND user_id = ?", c.Param("assetId"), c.Param("id"), userID).First(&asset).Error; err != nil {
  151. respondError(c, http.StatusNotFound, 404, "asset not found")
  152. return
  153. }
  154. if _, err := os.Stat(asset.StoragePath); err != nil {
  155. respondError(c, http.StatusNotFound, 404, "asset file is unavailable")
  156. return
  157. }
  158. if asset.MIMEType != "" {
  159. c.Header("Content-Type", asset.MIMEType)
  160. }
  161. c.FileAttachment(asset.StoragePath, asset.FileName)
  162. }
  163. // InitChunkedUpload creates a new chunked upload session for large files.
  164. func (h *MediaHandler) InitChunkedUpload(c *gin.Context) {
  165. userID := c.GetString("userID")
  166. sessionID := c.Param("id")
  167. var session models.CelestiaSession
  168. if err := h.DB.Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).First(&session).Error; err != nil {
  169. respondError(c, http.StatusNotFound, 404, "record not found")
  170. return
  171. }
  172. var req struct {
  173. ClientID string `json:"clientId" binding:"required"`
  174. Kind string `json:"kind" binding:"required"`
  175. FileName string `json:"fileName" binding:"required"`
  176. MIMEType string `json:"mimeType"`
  177. FileSize int64 `json:"fileSize" binding:"required,gt=0"`
  178. ChunkSize int `json:"chunkSize" binding:"required,gt=0"`
  179. }
  180. if err := c.ShouldBindJSON(&req); err != nil {
  181. respondError(c, http.StatusBadRequest, 400, "invalid request: "+err.Error())
  182. return
  183. }
  184. req.Kind = strings.ToUpper(strings.TrimSpace(req.Kind))
  185. if req.Kind != "AUDIO" && req.Kind != "PHOTO" {
  186. respondError(c, http.StatusBadRequest, 400, "kind must be AUDIO or PHOTO")
  187. return
  188. }
  189. // Storage quota pre-check against requested FileSize
  190. var usedBytes int64
  191. h.DB.Model(&models.MediaAsset{}).Where("user_id = ?", userID).Select("COALESCE(SUM(size_bytes), 0)").Scan(&usedBytes)
  192. var setting models.SyncSetting
  193. if err := h.DB.First(&setting, "user_id = ?", userID).Error; err != nil {
  194. setting.CloudStorageMaxMB = 5120
  195. }
  196. maxBytes := setting.CloudStorageMaxMB * 1024 * 1024
  197. remainingBytes := maxBytes - usedBytes
  198. if remainingBytes < req.FileSize {
  199. c.JSON(http.StatusRequestEntityTooLarge, gin.H{
  200. "code": 413, "message": "storage quota exceeded",
  201. "data": gin.H{
  202. "totalBytes": maxBytes,
  203. "usedBytes": usedBytes,
  204. "remainingBytes": remainingBytes,
  205. "requiredBytes": req.FileSize,
  206. },
  207. })
  208. return
  209. }
  210. totalChunks := int(math.Ceil(float64(req.FileSize) / float64(req.ChunkSize)))
  211. storageDir := filepath.Join(h.Cfg.StoragePath, userID, sessionID, "chunks")
  212. if err := os.MkdirAll(storageDir, 0o750); err != nil {
  213. respondError(c, http.StatusInternalServerError, 500, "failed to prepare chunk storage")
  214. return
  215. }
  216. upload := models.ChunkedUpload{
  217. SessionID: sessionID,
  218. UserID: userID,
  219. ClientID: strings.TrimSpace(req.ClientID),
  220. Kind: req.Kind,
  221. FileName: filepath.Base(req.FileName),
  222. MIMEType: req.MIMEType,
  223. TotalSize: req.FileSize,
  224. ChunkSize: req.ChunkSize,
  225. TotalChunks: totalChunks,
  226. UploadedChunks: 0,
  227. StorageDir: storageDir,
  228. Status: "pending",
  229. ExpiresAt: time.Now().Add(24 * time.Hour),
  230. }
  231. if err := h.DB.Create(&upload).Error; err != nil {
  232. respondError(c, http.StatusInternalServerError, 500, "failed to create chunked upload")
  233. return
  234. }
  235. c.JSON(http.StatusOK, gin.H{
  236. "code": 0, "message": "success",
  237. "data": gin.H{
  238. "uploadId": upload.ID,
  239. "totalChunks": totalChunks,
  240. "chunkSize": req.ChunkSize,
  241. "expiresAt": upload.ExpiresAt,
  242. },
  243. })
  244. }
  245. // UploadChunk receives a single chunk of a chunked upload.
  246. func (h *MediaHandler) UploadChunk(c *gin.Context) {
  247. userID := c.GetString("userID")
  248. sessionID := c.Param("id")
  249. uploadID := strings.TrimSpace(c.PostForm("uploadId"))
  250. chunkIndexStr := strings.TrimSpace(c.PostForm("chunkIndex"))
  251. if uploadID == "" || chunkIndexStr == "" {
  252. respondError(c, http.StatusBadRequest, 400, "uploadId and chunkIndex are required")
  253. return
  254. }
  255. chunkIndex, err := strconv.Atoi(chunkIndexStr)
  256. if err != nil || chunkIndex < 0 {
  257. respondError(c, http.StatusBadRequest, 400, "chunkIndex must be a non-negative integer")
  258. return
  259. }
  260. var upload models.ChunkedUpload
  261. if err := h.DB.Where("id = ? AND session_id = ? AND user_id = ? AND status = 'pending'", uploadID, sessionID, userID).First(&upload).Error; err != nil {
  262. respondError(c, http.StatusNotFound, 404, "chunked upload not found")
  263. return
  264. }
  265. if time.Now().After(upload.ExpiresAt) {
  266. respondError(c, http.StatusGone, 410, "chunked upload has expired")
  267. return
  268. }
  269. if chunkIndex >= upload.TotalChunks {
  270. respondError(c, http.StatusBadRequest, 400, "chunkIndex out of range")
  271. return
  272. }
  273. header, err := c.FormFile("file")
  274. if err != nil {
  275. respondError(c, http.StatusBadRequest, 400, "file is required")
  276. return
  277. }
  278. input, err := header.Open()
  279. if err != nil {
  280. respondError(c, http.StatusBadRequest, 400, "failed to read uploaded chunk")
  281. return
  282. }
  283. defer input.Close()
  284. chunkPath := filepath.Join(upload.StorageDir, fmt.Sprintf("chunk_%04d", chunkIndex))
  285. output, err := os.OpenFile(chunkPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
  286. if err != nil {
  287. respondError(c, http.StatusInternalServerError, 500, "failed to create chunk file")
  288. return
  289. }
  290. if _, err := io.Copy(output, input); err != nil {
  291. output.Close()
  292. os.Remove(chunkPath)
  293. respondError(c, http.StatusInternalServerError, 500, "failed to write chunk")
  294. return
  295. }
  296. output.Close()
  297. h.DB.Model(&upload).Update("uploaded_chunks", gorm.Expr("uploaded_chunks + 1"))
  298. c.JSON(http.StatusOK, gin.H{
  299. "code": 0, "message": "success",
  300. "data": gin.H{
  301. "uploadId": upload.ID,
  302. "chunkIndex": chunkIndex,
  303. "uploadedChunks": upload.UploadedChunks + 1,
  304. "totalChunks": upload.TotalChunks,
  305. },
  306. })
  307. }
  308. // CompleteChunkedUpload merges all chunks into a single file and creates the
  309. // MediaAsset record.
  310. func (h *MediaHandler) CompleteChunkedUpload(c *gin.Context) {
  311. userID := c.GetString("userID")
  312. sessionID := c.Param("id")
  313. var req struct {
  314. UploadID string `json:"uploadId" binding:"required"`
  315. TotalChunks int `json:"totalChunks" binding:"required"`
  316. }
  317. if err := c.ShouldBindJSON(&req); err != nil {
  318. respondError(c, http.StatusBadRequest, 400, "invalid request: "+err.Error())
  319. return
  320. }
  321. var upload models.ChunkedUpload
  322. if err := h.DB.Where("id = ? AND session_id = ? AND user_id = ? AND status = 'pending'", req.UploadID, sessionID, userID).First(&upload).Error; err != nil {
  323. respondError(c, http.StatusNotFound, 404, "chunked upload not found")
  324. return
  325. }
  326. if time.Now().After(upload.ExpiresAt) {
  327. respondError(c, http.StatusGone, 410, "chunked upload has expired")
  328. return
  329. }
  330. if req.TotalChunks != upload.TotalChunks {
  331. respondError(c, http.StatusBadRequest, 400, "totalChunks mismatch")
  332. return
  333. }
  334. // Verify all chunks exist
  335. for i := 0; i < upload.TotalChunks; i++ {
  336. chunkPath := filepath.Join(upload.StorageDir, fmt.Sprintf("chunk_%04d", i))
  337. if _, err := os.Stat(chunkPath); err != nil {
  338. respondError(c, http.StatusBadRequest, 400, fmt.Sprintf("chunk %d is missing", i))
  339. return
  340. }
  341. }
  342. // Merge chunks into final file
  343. directory := filepath.Join(h.Cfg.StoragePath, userID, sessionID)
  344. if err := os.MkdirAll(directory, 0o750); err != nil {
  345. respondError(c, http.StatusInternalServerError, 500, "failed to prepare asset storage")
  346. return
  347. }
  348. extension := strings.ToLower(filepath.Ext(upload.FileName))
  349. if len(extension) > 12 {
  350. extension = ""
  351. }
  352. // Create MediaAsset first to get the ID for the filename
  353. asset := models.MediaAsset{
  354. SessionID: sessionID,
  355. UserID: userID,
  356. ClientID: upload.ClientID,
  357. Kind: upload.Kind,
  358. FileName: upload.FileName,
  359. MIMEType: upload.MIMEType,
  360. SizeBytes: 0,
  361. SHA256: "pending",
  362. StoragePath: "pending",
  363. }
  364. if err := h.DB.Create(&asset).Error; err != nil {
  365. if isDuplicateError(err) {
  366. var existing models.MediaAsset
  367. if h.DB.Where("session_id = ? AND client_id = ?", sessionID, upload.ClientID).First(&existing).Error == nil {
  368. respondSuccess(c, existing)
  369. return
  370. }
  371. }
  372. respondError(c, http.StatusInternalServerError, 500, "failed to create asset")
  373. return
  374. }
  375. storagePath := filepath.Join(directory, asset.ID+extension)
  376. output, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
  377. if err != nil {
  378. h.DB.Delete(&asset)
  379. respondError(c, http.StatusInternalServerError, 500, "failed to create asset file")
  380. return
  381. }
  382. hasher := sha256.New()
  383. var totalSize int64
  384. for i := 0; i < upload.TotalChunks; i++ {
  385. chunkPath := filepath.Join(upload.StorageDir, fmt.Sprintf("chunk_%04d", i))
  386. chunkFile, err := os.Open(chunkPath)
  387. if err != nil {
  388. output.Close()
  389. os.Remove(storagePath)
  390. h.DB.Delete(&asset)
  391. respondError(c, http.StatusInternalServerError, 500, "failed to read chunk")
  392. return
  393. }
  394. n, copyErr := io.Copy(io.MultiWriter(output, hasher), chunkFile)
  395. chunkFile.Close()
  396. if copyErr != nil {
  397. output.Close()
  398. os.Remove(storagePath)
  399. h.DB.Delete(&asset)
  400. respondError(c, http.StatusInternalServerError, 500, "failed to merge chunk")
  401. return
  402. }
  403. totalSize += n
  404. }
  405. if err := output.Close(); err != nil {
  406. os.Remove(storagePath)
  407. h.DB.Delete(&asset)
  408. respondError(c, http.StatusInternalServerError, 500, "failed to finalize merged file")
  409. return
  410. }
  411. asset.SizeBytes = totalSize
  412. asset.SHA256 = hex.EncodeToString(hasher.Sum(nil))
  413. asset.StoragePath = storagePath
  414. if err := h.DB.Save(&asset).Error; err != nil {
  415. os.Remove(storagePath)
  416. h.DB.Delete(&asset)
  417. respondError(c, http.StatusInternalServerError, 500, "failed to finalize asset")
  418. return
  419. }
  420. // Clean up chunk files
  421. for i := 0; i < upload.TotalChunks; i++ {
  422. os.Remove(filepath.Join(upload.StorageDir, fmt.Sprintf("chunk_%04d", i)))
  423. }
  424. os.Remove(upload.StorageDir)
  425. // Mark upload as completed
  426. h.DB.Model(&upload).Update("status", "completed")
  427. // Increment session revision
  428. var session models.CelestiaSession
  429. h.DB.First(&session, "id = ?", sessionID)
  430. h.DB.Model(&session).Updates(map[string]interface{}{
  431. "revision": gorm.Expr("revision + 1"),
  432. "updated_at": gorm.Expr("NOW()"),
  433. })
  434. h.DB.First(&session, "id = ?", session.ID)
  435. c.JSON(http.StatusOK, gin.H{
  436. "code": 0, "message": "success",
  437. "data": gin.H{
  438. "asset": asset,
  439. "sessionRevision": session.Revision,
  440. },
  441. })
  442. }