From 44d83ff92713b6f057ada2ea4f1045072f466398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Baldeweg?= <56736413+abaldeweg@users.noreply.github.com> Date: Sat, 15 Feb 2025 18:32:31 +0000 Subject: [PATCH] Add cover image upload and retrieval functionality --- gateway/core/models/publicBook.go | 7 + gateway/cover/cover.go | 148 ++--------------- gateway/cover/cover_save.go | 156 ++++++++++++++++++ .../{cover_test.go => cover_save_test.go} | 0 gateway/cover/cover_show.go | 24 +++ gateway/uploads/none.jpg | Bin 0 -> 5173 bytes 6 files changed, 197 insertions(+), 138 deletions(-) create mode 100644 gateway/cover/cover_save.go rename gateway/cover/{cover_test.go => cover_save_test.go} (100%) create mode 100644 gateway/cover/cover_show.go create mode 100644 gateway/uploads/none.jpg diff --git a/gateway/core/models/publicBook.go b/gateway/core/models/publicBook.go index d73bf48..4d0d9c1 100644 --- a/gateway/core/models/publicBook.go +++ b/gateway/core/models/publicBook.go @@ -1,6 +1,7 @@ package models import ( + "github.com/abaldeweg/warehouse-server/gateway/cover" "github.com/google/uuid" "gorm.io/gorm" ) @@ -36,6 +37,9 @@ type PublicBook struct { Removed bool `json:"-" gorm:"default:false"` Reserved bool `json:"-" gorm:"default:false"` Recommendation bool `json:"-" gorm:"default:false"` + CoverS string `json:"cover_s" gorm:"default:null"` + CoverM string `json:"cover_m" gorm:"default:null"` + CoverL string `json:"cover_l" gorm:"default:null"` } // TableName overrides the default table name for PublicBook model. @@ -60,5 +64,8 @@ func (book *PublicBook) AfterFind(tx *gorm.DB) (err error) { book.Cond = book.Condition.Name book.FormatName = book.Format.Name book.BranchCart = book.Branch.Cart + book.CoverS = cover.ShowCover("s", book.ID) + book.CoverM = cover.ShowCover("m", book.ID) + book.CoverL = cover.ShowCover("l", book.ID) return } diff --git a/gateway/cover/cover.go b/gateway/cover/cover.go index bdd8129..b67c784 100644 --- a/gateway/cover/cover.go +++ b/gateway/cover/cover.go @@ -1,156 +1,28 @@ package cover import ( - "bytes" "fmt" - "image" - "image/jpeg" - "image/png" - "mime/multipart" - "net/http" "os" "path/filepath" - - "github.com/disintegration/imaging" - "github.com/gin-gonic/gin" - "golang.org/x/image/webp" ) -const uploadsDir = "uploads" - -// SaveCover saves the uploaded cover image in different sizes. -func SaveCover(c *gin.Context, imageUUID string) { - imageData, err := c.FormFile("cover") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Image upload required"}) - return - } - - if err := saveResizedImages(c, imageData, imageUUID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"}) - return - } - - c.Status(http.StatusOK) -} - -func saveResizedImages(c *gin.Context, imageData *multipart.FileHeader, imageUUID string) error { - imagePath, err := saveUploadedImage(c, imageData, imageUUID) - if err != nil { - return fmt.Errorf("failed to save uploaded image: %w", err) - } - defer os.Remove(imagePath) - - sizes := []struct { - width int - suffix string - }{ - {400, "l"}, - {200, "m"}, - {100, "s"}, - } - - for _, size := range sizes { - resizedImagePath := filepath.Join(uploadsDir, fmt.Sprintf("%s-%s%s", imageUUID, size.suffix, ".jpg")) - - if err := resizeAndSaveImage(imagePath, resizedImagePath, size.width); err != nil { - return fmt.Errorf("failed to resize image: %w", err) - } - } +const ( + Quality = 75 +) - return nil +var Sizes = map[string]int{ + "l": 400, + "m": 200, + "s": 100, } -func saveUploadedImage(c *gin.Context, imageData *multipart.FileHeader, imageUUID string) (string, error) { - imageFilename := fmt.Sprintf("%s%s", imageUUID, filepath.Ext(imageData.Filename)) +func getPath() (string, error) { currentDir, _ := os.Getwd() - uploadsDirPath := filepath.Join(currentDir, uploadsDir) + uploadsDirPath := filepath.Join(currentDir, "uploads") if err := os.MkdirAll(uploadsDirPath, 0755); err != nil { return "", fmt.Errorf("failed to create uploads directory") } - imagePath := filepath.Join(uploadsDirPath, imageFilename) - if err := c.SaveUploadedFile(imageData, imagePath); err != nil { - return "", fmt.Errorf("failed to save image") - } - - return imagePath, nil -} - -func resizeAndSaveImage(imagePath string, resizedImagePath string, width int) error { - file, err := os.Open(imagePath) - if err != nil { - return fmt.Errorf("failed to open image: %w", err) - } - defer file.Close() - - var img image.Image - - fileHeader := make([]byte, 512) - if _, err := file.Read(fileHeader); err != nil { - return fmt.Errorf("failed to read file header: %w", err) - } - - mimeType := http.DetectContentType(fileHeader) - _, err = file.Seek(0, 0) - if err != nil { - return fmt.Errorf("failed to reset file pointer: %w", err) - } - - switch mimeType { - case "image/jpeg": - img, err = jpeg.Decode(file) - case "image/png": - img, err = png.Decode(file) - case "image/webp": - img, err = convertWebp(file) - default: - return fmt.Errorf("unsupported image format") - } - - if err != nil { - return fmt.Errorf("failed to decode image: %w", err) - } - - originalBounds := img.Bounds() - aspectRatio := float64(originalBounds.Dx()) / float64(originalBounds.Dy()) - height := int(float64(width) / aspectRatio) - - resizedImage := imaging.Resize(img, width, height, imaging.Lanczos) - - outFile, err := os.Create(resizedImagePath) - if err != nil { - return fmt.Errorf("failed to create resized image file: %w", err) - } - defer outFile.Close() - - err = jpeg.Encode(outFile, resizedImage, nil) - if err != nil { - return fmt.Errorf("failed to encode resized image: %w", err) - } - - return nil -} - -func convertWebp(file *os.File) (image.Image, error) { - var err error - var img image.Image - img, err = webp.Decode(file) - if err != nil { - return nil, err - } - - buf := new(bytes.Buffer) - err = jpeg.Encode(buf, img, nil) - if err != nil { - return nil, err - } - - img, err = jpeg.Decode(buf) - if err != nil { - return nil, err - } - - return img, nil + return uploadsDirPath, nil } diff --git a/gateway/cover/cover_save.go b/gateway/cover/cover_save.go new file mode 100644 index 0000000..bdd8129 --- /dev/null +++ b/gateway/cover/cover_save.go @@ -0,0 +1,156 @@ +package cover + +import ( + "bytes" + "fmt" + "image" + "image/jpeg" + "image/png" + "mime/multipart" + "net/http" + "os" + "path/filepath" + + "github.com/disintegration/imaging" + "github.com/gin-gonic/gin" + "golang.org/x/image/webp" +) + +const uploadsDir = "uploads" + +// SaveCover saves the uploaded cover image in different sizes. +func SaveCover(c *gin.Context, imageUUID string) { + imageData, err := c.FormFile("cover") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Image upload required"}) + return + } + + if err := saveResizedImages(c, imageData, imageUUID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"}) + return + } + + c.Status(http.StatusOK) +} + +func saveResizedImages(c *gin.Context, imageData *multipart.FileHeader, imageUUID string) error { + imagePath, err := saveUploadedImage(c, imageData, imageUUID) + if err != nil { + return fmt.Errorf("failed to save uploaded image: %w", err) + } + defer os.Remove(imagePath) + + sizes := []struct { + width int + suffix string + }{ + {400, "l"}, + {200, "m"}, + {100, "s"}, + } + + for _, size := range sizes { + resizedImagePath := filepath.Join(uploadsDir, fmt.Sprintf("%s-%s%s", imageUUID, size.suffix, ".jpg")) + + if err := resizeAndSaveImage(imagePath, resizedImagePath, size.width); err != nil { + return fmt.Errorf("failed to resize image: %w", err) + } + } + + return nil +} + +func saveUploadedImage(c *gin.Context, imageData *multipart.FileHeader, imageUUID string) (string, error) { + imageFilename := fmt.Sprintf("%s%s", imageUUID, filepath.Ext(imageData.Filename)) + currentDir, _ := os.Getwd() + uploadsDirPath := filepath.Join(currentDir, uploadsDir) + + if err := os.MkdirAll(uploadsDirPath, 0755); err != nil { + return "", fmt.Errorf("failed to create uploads directory") + } + + imagePath := filepath.Join(uploadsDirPath, imageFilename) + if err := c.SaveUploadedFile(imageData, imagePath); err != nil { + return "", fmt.Errorf("failed to save image") + } + + return imagePath, nil +} + +func resizeAndSaveImage(imagePath string, resizedImagePath string, width int) error { + file, err := os.Open(imagePath) + if err != nil { + return fmt.Errorf("failed to open image: %w", err) + } + defer file.Close() + + var img image.Image + + fileHeader := make([]byte, 512) + if _, err := file.Read(fileHeader); err != nil { + return fmt.Errorf("failed to read file header: %w", err) + } + + mimeType := http.DetectContentType(fileHeader) + _, err = file.Seek(0, 0) + if err != nil { + return fmt.Errorf("failed to reset file pointer: %w", err) + } + + switch mimeType { + case "image/jpeg": + img, err = jpeg.Decode(file) + case "image/png": + img, err = png.Decode(file) + case "image/webp": + img, err = convertWebp(file) + default: + return fmt.Errorf("unsupported image format") + } + + if err != nil { + return fmt.Errorf("failed to decode image: %w", err) + } + + originalBounds := img.Bounds() + aspectRatio := float64(originalBounds.Dx()) / float64(originalBounds.Dy()) + height := int(float64(width) / aspectRatio) + + resizedImage := imaging.Resize(img, width, height, imaging.Lanczos) + + outFile, err := os.Create(resizedImagePath) + if err != nil { + return fmt.Errorf("failed to create resized image file: %w", err) + } + defer outFile.Close() + + err = jpeg.Encode(outFile, resizedImage, nil) + if err != nil { + return fmt.Errorf("failed to encode resized image: %w", err) + } + + return nil +} + +func convertWebp(file *os.File) (image.Image, error) { + var err error + var img image.Image + img, err = webp.Decode(file) + if err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + err = jpeg.Encode(buf, img, nil) + if err != nil { + return nil, err + } + + img, err = jpeg.Decode(buf) + if err != nil { + return nil, err + } + + return img, nil +} diff --git a/gateway/cover/cover_test.go b/gateway/cover/cover_save_test.go similarity index 100% rename from gateway/cover/cover_test.go rename to gateway/cover/cover_save_test.go diff --git a/gateway/cover/cover_show.go b/gateway/cover/cover_show.go new file mode 100644 index 0000000..78ff0c2 --- /dev/null +++ b/gateway/cover/cover_show.go @@ -0,0 +1,24 @@ +package cover + +import ( + "encoding/base64" + "os" + "path/filepath" + + "github.com/google/uuid" +) + +func ShowCover(size string, bookID uuid.UUID) string { + path, _ := getPath() + filename := filepath.Join(path, bookID.String()+"-"+size+".jpg") + + if _, err := os.Stat(filename); err != nil { + filename = filepath.Join(path, "none.jpg") + } + + data, err := os.ReadFile(filename) + if err != nil { + return "" + } + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data) +} diff --git a/gateway/uploads/none.jpg b/gateway/uploads/none.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c3ba5e9024b206eeee77fb923affa1c982f590f0 GIT binary patch literal 5173 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