diff --git a/frontend/src/components/resource_card.tsx b/frontend/src/components/resource_card.tsx index 5b31c1e..88810c9 100644 --- a/frontend/src/components/resource_card.tsx +++ b/frontend/src/components/resource_card.tsx @@ -24,7 +24,7 @@ export default function ResourceCard({ resource }: { resource: Resource }) { {resource.image != null && (
cover> { diff --git a/go.mod b/go.mod index 3b3768e..9616ecf 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,10 @@ require ( gorm.io/driver/mysql v1.5.7 ) -require github.com/go-sql-driver/mysql v1.7.0 // indirect +require ( + github.com/disintegration/imaging v1.6.2 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect +) require ( github.com/dustin/go-humanize v1.0.1 // indirect @@ -47,8 +50,8 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.61.0 // indirect github.com/x448/float16 v0.8.4 // indirect - golang.org/x/image v0.27.0 + golang.org/x/image v0.28.0 golang.org/x/net v0.39.0 // indirect golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/text v0.26.0 // indirect ) diff --git a/go.sum b/go.sum index c38de86..a577aba 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= @@ -78,8 +80,11 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= +golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= @@ -90,6 +95,8 @@ golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/api/image.go b/server/api/image.go index dfee14b..bbc74ca 100644 --- a/server/api/image.go +++ b/server/api/image.go @@ -71,10 +71,30 @@ func handleDeleteImage(c fiber.Ctx) error { }) } +func handleGetResampledImage(c fiber.Ctx) error { + idStr := c.Params("id") + if idStr == "" { + return model.NewRequestError("Image ID is required") + } + id, err := strconv.Atoi(idStr) + if err != nil { + return model.NewRequestError("Invalid image ID") + } + image, err := service.GetResampledImage(uint(id)) + if err != nil { + return err + } + contentType := http.DetectContentType(image) + c.Set("Content-Type", contentType) + c.Set("Cache-Control", "public, max-age=31536000") + return c.Send(image) +} + func AddImageRoutes(api fiber.Router) { image := api.Group("/image") { image.Put("/", handleUploadImage) + image.Get("/resampled/:id", handleGetResampledImage) image.Get("/:id", handleGetImage) image.Delete("/:id", handleDeleteImage) } diff --git a/server/service/image.go b/server/service/image.go index 15caad3..965bab8 100644 --- a/server/service/image.go +++ b/server/service/image.go @@ -3,12 +3,14 @@ package service import ( "bytes" "errors" + "github.com/disintegration/imaging" "image" "net/http" "nysoure/server/dao" "nysoure/server/model" "nysoure/server/utils" "os" + "strconv" "time" "github.com/gofiber/fiber/v3/log" @@ -24,6 +26,10 @@ import ( "github.com/chai2010/webp" ) +const ( + resampledMaxPixels = 1280 * 720 +) + func init() { // Start a goroutine to delete unused images every hour go func() { @@ -152,11 +158,83 @@ func deleteImage(id uint) error { } imageDir := utils.GetStoragePath() + "/images/" - _ = os.Remove(imageDir + i.FileName) + resampledDir := utils.GetStoragePath() + "/resampled/" + _ = os.Remove(resampledDir + strconv.Itoa(int(i.ID)) + ".webp") + if err := dao.DeleteImage(id); err != nil { return err } return nil } + +func GetResampledImage(id uint) ([]byte, error) { + i, err := dao.GetImageByID(id) + if err != nil { + return nil, err + } + + data, err := getOrCreateResampledImage(i) + if err != nil { + log.Error("Error getting or creating resampled image:", err) + return nil, model.NewInternalServerError("Error processing image") + } + + return data, nil +} + +func getOrCreateResampledImage(i model.Image) ([]byte, error) { + baseDir := utils.GetStoragePath() + "/resampled/" + if _, err := os.Stat(baseDir); os.IsNotExist(err) { + if err := os.MkdirAll(baseDir, 0755); err != nil { + return nil, err + } + } + + resampledFilepath := baseDir + strconv.Itoa(int(i.ID)) + ".webp" + if _, err := os.Stat(resampledFilepath); err != nil { + if !os.IsNotExist(err) { + return nil, err + } + } else { + return os.ReadFile(resampledFilepath) + } + + originalFilepath := utils.GetStoragePath() + "/images/" + i.FileName + if _, err := os.Stat(originalFilepath); os.IsNotExist(err) { + return nil, model.NewNotFoundError("Original image not found") + } + imgData, err := os.ReadFile(originalFilepath) + if err != nil { + return nil, errors.New("failed to read original image file") + } + if i.Width*i.Height <= resampledMaxPixels { + return imgData, nil + } + + log.Info("Resampling image", "id", i.ID, "original size", i.Width, "x", i.Height) + img, _, err := image.Decode(bytes.NewReader(imgData)) + if err != nil { + return nil, errors.New("failed to decode original image data") + } + pixels := img.Bounds().Dx() * img.Bounds().Dy() + if pixels <= resampledMaxPixels { + return imgData, nil // No need to resample if the image is small enough + } + + scale := float64(resampledMaxPixels) / float64(pixels) + dstWidth := int(float64(img.Bounds().Dx()) * scale) + dstHeight := int(float64(img.Bounds().Dy()) * scale) + dstImg := imaging.Resize(img, dstWidth, dstHeight, imaging.Lanczos) + + buf := new(bytes.Buffer) + if err := webp.Encode(buf, dstImg, &webp.Options{Quality: 80}); err != nil { + return nil, errors.New("failed to encode resampled image data to webp format") + } + if err := os.WriteFile(resampledFilepath, buf.Bytes(), 0644); err != nil { + return nil, errors.New("failed to save resampled image file") + } + + return buf.Bytes(), nil +}