/* Copyright (C) 2025 hexlocation (hex@iwakura.rip) & grngxd (grng@iwakura.rip) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package routes import ( "bytes" "encoding/base64" "io" "strconv" "strings" "time" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "github.com/galdor/go-thumbhash" "github.com/gin-gonic/gin" "github.com/gofrs/uuid" "github.com/golang-jwt/jwt/v5" "github.com/h2non/filetype" "github.com/minio/minio-go/v7" "stereo.cat/backend/internal/auth" "stereo.cat/backend/internal/auth/session" "stereo.cat/backend/internal/types" ) func intoReader(buf []byte) io.Reader { return io.NopCloser(bytes.NewBuffer(buf)) } func RegisterFileRoutes(cfg *types.StereoConfig, api *gin.RouterGroup) { api.POST("/upload", session.SessionMiddleware(cfg.JWTSecret), func(c *gin.Context) { claims := c.MustGet("claims").(jwt.MapClaims) user := claims["user"].(auth.User) uid := user.ID if uid == "" { types.ErrorInvalidParams.Throw(c, nil) return } file, err := c.FormFile("file") if err != nil { types.ErrorInvalidParams.Throw(c, err) return } if file.Size <= 0 { types.ErrorInvalidParams.Throw(c, nil) return } fileReader, err := file.Open() if err != nil { types.ErrorReaderOpen.Throw(c, err) return } buf, err := io.ReadAll(fileReader) if err != nil { types.ErrorReaderOpen.Throw(c, err) return } if !filetype.IsImage(buf) { types.ErrorInvalidFile.Throw(c, nil) return } fileType, err := filetype.Match(buf) if err != nil { types.ErrorInvalidFile.Throw(c, err) return } mime := fileType.MIME.Value fileMeta := types.File{ Owner: uid, Name: file.Filename, CreatedAt: time.Now(), Size: int64(len(buf)), Mime: mime, } if filetype.IsImage(buf) { img, _, err := image.Decode(bytes.NewReader(buf)) if err != nil { types.ErrorInvalidFile.Throw(c, err) return } hashBytes := thumbhash.EncodeImage(img) // encode the byte[] into a string hash := base64.StdEncoding.EncodeToString(hashBytes) fileMeta.Hash = hash fileMeta.Width = img.Bounds().Dx() fileMeta.Height = img.Bounds().Dy() } if err := cfg.Database.Create(&fileMeta).Error; err != nil { types.ErrorDatabase.Throw(c, err) return } _, err = cfg.MinioClient.PutObject(cfg.Context, cfg.Bucket, fileMeta.ID.String(), intoReader(buf), fileMeta.Size, minio.PutObjectOptions{ContentType: fileMeta.Mime}) if err != nil { types.ErrorS3.Throw(c, err) return } c.JSON(200, gin.H{"message": "file uploaded successfully", "id": fileMeta.ID.String()}) }) api.DELETE("/:id", session.SessionMiddleware(cfg.JWTSecret), func(c *gin.Context) { claims := c.MustGet("claims").(jwt.MapClaims) user := claims["user"].(auth.User) fileID, err := uuid.FromString(strings.TrimSpace(c.Param("id"))) if err != nil { types.ErrorInvalidFile.Throw(c, err) return } var file *types.File err = cfg.Database.First(&file, fileID).Error if err != nil { types.ErrorFileNotFound.Throw(c, err) return } if file.Owner != user.ID { types.ErrorUnauthorized.Throw(c, nil) return } if err := cfg.MinioClient.RemoveObject(cfg.Context, cfg.Bucket, fileID.String(), minio.RemoveObjectOptions{}); err != nil { types.ErrorS3.Throw(c, err) return } if err := cfg.Database.Delete(&file).Error; err != nil { types.ErrorDatabase.Throw(c, err) return } c.JSON(200, gin.H{"message": "file deleted successfully"}) }) api.GET("/:id", func(c *gin.Context) { fileID := c.Param("id") fileID = strings.TrimSpace(fileID) var file *types.File id, err := uuid.FromString(fileID) if err != nil { types.ErrorInvalidFile.Throw(c, err) return } cfg.Database.First(&file, id) if file == nil { types.ErrorFileNotFound.Throw(c, nil) return } object, err := cfg.MinioClient.GetObject(cfg.Context, cfg.Bucket, fileID, minio.GetObjectOptions{}) if err != nil { types.ErrorS3.Throw(c, err) return } if _, err := object.Stat(); err != nil { types.ErrorFileNotFound.Throw(c, err) return } c.DataFromReader(200, file.Size, file.Mime, object, nil) }) api.GET("/:id/meta", func(c *gin.Context) { fileID := c.Param("id") fileID = strings.TrimSpace(fileID) var file *types.File id, err := uuid.FromString(fileID) if err != nil { types.ErrorInvalidFile.Throw(c, err) return } if err := cfg.Database.First(&file, id).Error; err != nil { types.ErrorFileNotFound.Throw(c, err) return } if file == nil { types.ErrorFileNotFound.Throw(c, nil) return } c.JSON(200, file) }) api.GET("/list", session.SessionMiddleware(cfg.JWTSecret), func(c *gin.Context) { claims := c.MustGet("claims").(jwt.MapClaims) user := claims["user"].(auth.User) var files []types.File if c.Query("page") == "" || c.Query("size") == "" { if err := cfg.Database.Where("owner = ?", user.ID).Order("created_at DESC").Find(&files).Error; err != nil { types.ErrorDatabase.Throw(c, err) return } c.JSON(200, files) return } page := c.Query("page") size := c.Query("size") pageNum, err := strconv.Atoi(page) if err != nil || pageNum < 0 { types.ErrorInvalidParams.Throw(c, err) return } sizeNum, err := strconv.Atoi(size) if err != nil || sizeNum <= 0 { types.ErrorInvalidParams.Throw(c, err) return } offset := (pageNum - 1) * sizeNum if offset < 0 { offset = 0 } if err := cfg.Database.Where("owner = ?", user.ID).Order("created_at DESC").Offset(offset).Limit(sizeNum).Find(&files).Error; err != nil { types.ErrorDatabase.Throw(c, err) return } c.JSON(200, files) }) }