diff --git a/backend/app/api/main.go b/backend/app/api/main.go index 59bff0e3..960f4ab9 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -123,7 +123,9 @@ func run(cfg *config.Config) error { } // Construct and validate the full storage path storageDir := filepath.Join(absBase, cfg.Storage.PrefixPath) - if !strings.HasPrefix(storageDir, absBase+string(os.PathSeparator)) && storageDir != absBase { + // Set windows paths to use forward slashes required by go-cloud + storageDir = strings.ReplaceAll(storageDir, "\\", "/") + if !strings.HasPrefix(storageDir, absBase+"/") && storageDir != absBase { log.Fatal(). Str("path", storageDir). Msg("invalid storage path: you tried to use a prefix that is not a subdirectory of the base path") diff --git a/backend/go.mod b/backend/go.mod index 7cfcccd1..a48e33d3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,6 +8,7 @@ require ( entgo.io/ent v0.14.4 github.com/ardanlabs/conf/v3 v3.8.0 github.com/containrrr/shoutrrr v0.8.0 + github.com/evanoberholster/imagemeta v0.3.1 github.com/gen2brain/avif v0.4.4 github.com/gen2brain/heic v0.4.5 github.com/gen2brain/jpegxl v0.4.5 @@ -139,7 +140,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/klauspost/cpuid/v2 v2.0.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -152,6 +153,7 @@ require ( github.com/nats-io/nkeys v0.4.10 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/philhofer/fwd v1.1.2 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect @@ -163,6 +165,7 @@ require ( github.com/sethvargo/go-retry v0.3.0 // indirect github.com/swaggo/files/v2 v2.0.0 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/tinylib/msgp v1.1.8 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 29ecd521..366dda50 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -170,6 +170,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJP github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/evanoberholster/imagemeta v0.3.1 h1:E4GUjXcvlVMjP9joN25+bBNf3Al3MTTfMqCrDOCW+LE= +github.com/evanoberholster/imagemeta v0.3.1/go.mod h1:V0vtDJmjTqvwAYO8r+u33NRVIMXQb0qSqEfImoKEiXM= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -323,8 +325,8 @@ github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKu github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/cpuid/v2 v2.0.12 h1:p9dKCg8i4gmOxtv35DvrYoWqYzQrvEVdjQ762Y0OqZE= -github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -350,8 +352,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -375,12 +375,12 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olahol/melody v1.2.1 h1:xdwRkzHxf+B0w4TKbGpUSSkV516ZucQZJIWLztOWICQ= github.com/olahol/melody v1.2.1/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= +github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= +github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -417,10 +417,6 @@ github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQU github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -440,6 +436,8 @@ github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= +github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= @@ -515,6 +513,7 @@ golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTk golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= @@ -530,6 +529,7 @@ golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= @@ -558,9 +558,11 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -571,6 +573,7 @@ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= @@ -578,6 +581,7 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= @@ -593,6 +597,7 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= diff --git a/backend/internal/data/repo/repo_item_attachments.go b/backend/internal/data/repo/repo_item_attachments.go index 67b5b700..d99cf854 100644 --- a/backend/internal/data/repo/repo_item_attachments.go +++ b/backend/internal/data/repo/repo_item_attachments.go @@ -5,16 +5,16 @@ import ( "context" "crypto/md5" "fmt" + "github.com/evanoberholster/imagemeta" + "github.com/gen2brain/avif" + "github.com/gen2brain/heic" + "github.com/gen2brain/jpegxl" + "github.com/gen2brain/webp" "github.com/rs/zerolog/log" "github.com/sysadminsmedia/homebox/backend/internal/data/ent/group" "github.com/sysadminsmedia/homebox/backend/internal/sys/config" "github.com/sysadminsmedia/homebox/backend/pkgs/utils" "github.com/zeebo/blake3" - - "github.com/gen2brain/avif" - "github.com/gen2brain/heic" - "github.com/gen2brain/jpegxl" - "github.com/gen2brain/webp" "golang.org/x/image/draw" "image" "io" @@ -502,10 +502,13 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen contentType := http.DetectContentType(contentBytes[:min(512, len(contentBytes))]) if contentType == "application/octet-stream" { - if strings.HasSuffix(title, ".heic") || strings.HasSuffix(title, ".heif") { + switch { + case strings.HasSuffix(title, ".heic") || strings.HasSuffix(title, ".heif"): contentType = "image/heic" - } else if strings.HasSuffix(title, ".avif") { + case strings.HasSuffix(title, ".avif"): contentType = "image/avif" + case strings.HasSuffix(title, ".jxl"): + contentType = "image/jxl" } } @@ -522,10 +525,18 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen } return err } - dst := image.NewRGBA(image.Rect(0, 0, r.thumbnail.Width, r.thumbnail.Height)) - draw.ApproxBiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil) - buf := new(bytes.Buffer) - err = webp.Encode(buf, dst, webp.Options{Quality: 80, Lossless: false}) + log.Debug().Msg("reading original file orientation") + imageMeta, err := imagemeta.Decode(bytes.NewReader(contentBytes)) + if err != nil { + log.Err(err).Msg("failed to decode original file content") + err := tx.Rollback() + if err != nil { + return err + } + return err + } + orientation := uint16(imageMeta.Orientation) + thumbnailPath, err := r.processThumbnailFromImage(ctx, groupId, img, title, orientation) if err != nil { err := tx.Rollback() if err != nil { @@ -533,22 +544,7 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen } return err } - contentBytes := buf.Bytes() - log.Debug().Msg("uploading thumbnail file") - thumbnailFile, err := r.UploadFile(ctx, tx.Group.GetX(ctx, groupId), ItemCreateAttachment{ - Title: fmt.Sprintf("%s-thumb", title), - Content: bytes.NewReader(contentBytes), - }) - if err != nil { - log.Err(err).Msg("failed to upload thumbnail file") - err := tx.Rollback() - if err != nil { - return err - } - return err - } - log.Debug().Msg("setting thumbnail file path in attachment") - att.SetPath(thumbnailFile) + att.SetPath(thumbnailPath) case contentType == "image/webp": log.Debug().Msg("creating thumbnail for webp file") img, err := webp.Decode(bytes.NewReader(contentBytes)) @@ -560,10 +556,18 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen } return err } - dst := image.NewRGBA(image.Rect(0, 0, r.thumbnail.Width, r.thumbnail.Height)) - draw.ApproxBiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil) - buf := new(bytes.Buffer) - err = webp.Encode(buf, dst, webp.Options{Quality: 80, Lossless: false}) + log.Debug().Msg("reading original file orientation") + imageMeta, err := imagemeta.Decode(bytes.NewReader(contentBytes)) + if err != nil { + log.Err(err).Msg("failed to decode original file content") + err := tx.Rollback() + if err != nil { + return err + } + return err + } + orientation := uint16(imageMeta.Orientation) + thumbnailPath, err := r.processThumbnailFromImage(ctx, groupId, img, title, orientation) if err != nil { err := tx.Rollback() if err != nil { @@ -571,22 +575,7 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen } return err } - contentBytes := buf.Bytes() - log.Debug().Msg("uploading thumbnail file") - thumbnailFile, err := r.UploadFile(ctx, tx.Group.GetX(ctx, groupId), ItemCreateAttachment{ - Title: fmt.Sprintf("%s-thumb", title), - Content: bytes.NewReader(contentBytes), - }) - if err != nil { - log.Err(err).Msg("failed to upload thumbnail file") - err := tx.Rollback() - if err != nil { - return err - } - return err - } - log.Debug().Msg("setting thumbnail file path in attachment") - att.SetPath(thumbnailFile) + att.SetPath(thumbnailPath) case contentType == "image/avif": log.Debug().Msg("creating thumbnail for avif file") img, err := avif.Decode(bytes.NewReader(contentBytes)) @@ -598,10 +587,7 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen } return err } - dst := image.NewRGBA(image.Rect(0, 0, r.thumbnail.Width, r.thumbnail.Height)) - draw.ApproxBiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil) - buf := new(bytes.Buffer) - err = webp.Encode(buf, dst, webp.Options{Quality: 80, Lossless: false}) + thumbnailPath, err := r.processThumbnailFromImage(ctx, groupId, img, title, uint16(1)) if err != nil { err := tx.Rollback() if err != nil { @@ -609,22 +595,7 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen } return err } - contentBytes := buf.Bytes() - log.Debug().Msg("uploading thumbnail file") - thumbnailFile, err := r.UploadFile(ctx, tx.Group.GetX(ctx, groupId), ItemCreateAttachment{ - Title: fmt.Sprintf("%s-thumb", title), - Content: bytes.NewReader(contentBytes), - }) - if err != nil { - log.Err(err).Msg("failed to upload thumbnail file") - err := tx.Rollback() - if err != nil { - return err - } - return err - } - log.Debug().Msg("setting thumbnail file path in attachment") - att.SetPath(thumbnailFile) + att.SetPath(thumbnailPath) case contentType == "image/heic" || contentType == "image/heif": log.Debug().Msg("creating thumbnail for heic file") img, err := heic.Decode(bytes.NewReader(contentBytes)) @@ -636,10 +607,18 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen } return err } - dst := image.NewRGBA(image.Rect(0, 0, r.thumbnail.Width, r.thumbnail.Height)) - draw.ApproxBiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil) - buf := new(bytes.Buffer) - err = webp.Encode(buf, dst, webp.Options{Quality: 80, Lossless: false}) + log.Debug().Msg("reading original file orientation") + imageMeta, err := imagemeta.Decode(bytes.NewReader(contentBytes)) + if err != nil { + log.Err(err).Msg("failed to decode original file content") + err := tx.Rollback() + if err != nil { + return err + } + return err + } + orientation := uint16(imageMeta.Orientation) + thumbnailPath, err := r.processThumbnailFromImage(ctx, groupId, img, title, orientation) if err != nil { err := tx.Rollback() if err != nil { @@ -647,22 +626,7 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen } return err } - contentBytes := buf.Bytes() - log.Debug().Msg("uploading thumbnail file") - thumbnailFile, err := r.UploadFile(ctx, tx.Group.GetX(ctx, groupId), ItemCreateAttachment{ - Title: fmt.Sprintf("%s-thumb", title), - Content: bytes.NewReader(contentBytes), - }) - if err != nil { - log.Err(err).Msg("failed to upload thumbnail file") - err := tx.Rollback() - if err != nil { - return err - } - return err - } - log.Debug().Msg("setting thumbnail file path in attachment") - att.SetPath(thumbnailFile) + att.SetPath(thumbnailPath) case contentType == "image/jxl": log.Debug().Msg("creating thumbnail for jpegxl file") img, err := jpegxl.Decode(bytes.NewReader(contentBytes)) @@ -674,10 +638,7 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen } return err } - dst := image.NewRGBA(image.Rect(0, 0, r.thumbnail.Width, r.thumbnail.Height)) - draw.ApproxBiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil) - buf := new(bytes.Buffer) - err = webp.Encode(buf, dst, webp.Options{Quality: 80, Lossless: false}) + thumbnailPath, err := r.processThumbnailFromImage(ctx, groupId, img, title, uint16(1)) if err != nil { err := tx.Rollback() if err != nil { @@ -685,22 +646,7 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen } return err } - contentBytes := buf.Bytes() - log.Debug().Msg("uploading thumbnail file") - thumbnailFile, err := r.UploadFile(ctx, tx.Group.GetX(ctx, groupId), ItemCreateAttachment{ - Title: fmt.Sprintf("%s-thumb", title), - Content: bytes.NewReader(contentBytes), - }) - if err != nil { - log.Err(err).Msg("failed to upload thumbnail file") - err := tx.Rollback() - if err != nil { - return err - } - return err - } - log.Debug().Msg("setting thumbnail file path in attachment") - att.SetPath(thumbnailFile) + att.SetPath(thumbnailPath) default: return fmt.Errorf("file type %s is not supported for thumbnail creation or document thumnails disabled", title) } @@ -831,3 +777,73 @@ func isImageFile(mimetype string) bool { // Check file extension for image types return strings.Contains(mimetype, "image/jpeg") || strings.Contains(mimetype, "image/png") || strings.Contains(mimetype, "image/gif") } + +// calculateThumbnailDimensions calculates new dimensions that preserve aspect ratio +// while fitting within the configured maximum width and height +func calculateThumbnailDimensions(origWidth, origHeight, maxWidth, maxHeight int) (int, int) { + if origWidth <= maxWidth && origHeight <= maxHeight { + return origWidth, origHeight + } + + // Calculate scaling factors for both dimensions + scaleX := float64(maxWidth) / float64(origWidth) + scaleY := float64(maxHeight) / float64(origHeight) + + // Use the smaller scaling factor to ensure both dimensions fit + scale := scaleX + if scaleY < scaleX { + scale = scaleY + } + + newWidth := int(float64(origWidth) * scale) + newHeight := int(float64(origHeight) * scale) + + // Ensure we don't get zero dimensions + if newWidth < 1 { + newWidth = 1 + } + if newHeight < 1 { + newHeight = 1 + } + + return newWidth, newHeight +} + +// processThumbnailFromImage handles the common thumbnail processing logic after image decoding +// Returns the thumbnail file path or an error +func (r *AttachmentRepo) processThumbnailFromImage(ctx context.Context, groupId uuid.UUID, img image.Image, title string, orientation uint16) (string, error) { + bounds := img.Bounds() + // Apply EXIF orientation if needed + if orientation > 1 { + img = utils.ApplyOrientation(img, orientation) + bounds = img.Bounds() + } + newWidth, newHeight := calculateThumbnailDimensions(bounds.Dx(), bounds.Dy(), r.thumbnail.Width, r.thumbnail.Height) + dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) + draw.ApproxBiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil) + + buf := new(bytes.Buffer) + err := webp.Encode(buf, dst, webp.Options{Quality: 80, Lossless: false}) + if err != nil { + return "", err + } + contentBytes := buf.Bytes() + log.Debug().Msg("uploading thumbnail file") + + // Get the group for uploading the thumbnail + group, err := r.db.Group.Get(ctx, groupId) + if err != nil { + return "", err + } + + thumbnailFile, err := r.UploadFile(ctx, group, ItemCreateAttachment{ + Title: fmt.Sprintf("%s-thumb", title), + Content: bytes.NewReader(contentBytes), + }) + if err != nil { + log.Err(err).Msg("failed to upload thumbnail file") + return "", err + } + + return thumbnailFile, nil +} diff --git a/backend/internal/data/repo/repo_item_attachments_test.go b/backend/internal/data/repo/repo_item_attachments_test.go index 4841f9b3..b86fbdea 100644 --- a/backend/internal/data/repo/repo_item_attachments_test.go +++ b/backend/internal/data/repo/repo_item_attachments_test.go @@ -18,7 +18,7 @@ func TestAttachmentRepo_Create(t *testing.T) { ids := []uuid.UUID{item.ID} t.Cleanup(func() { for _, id := range ids { - _ = tRepos.Attachments.Delete(context.Background(), id) + _ = tRepos.Attachments.Delete(context.Background(), tGroup.ID, item.ID, id) } }) @@ -69,7 +69,7 @@ func TestAttachmentRepo_Create(t *testing.T) { assert.Equal(t, tt.want.Type, got.Type) - withItems, err := tRepos.Attachments.Get(tt.args.ctx, got.ID) + withItems, err := tRepos.Attachments.Get(tt.args.ctx, tGroup.ID, got.ID) require.NoError(t, err) assert.Equal(t, tt.args.itemID, withItems.Edges.Item.ID) @@ -86,17 +86,17 @@ func useAttachments(t *testing.T, n int) []*ent.Attachment { ids := make([]uuid.UUID, 0, n) t.Cleanup(func() { for _, id := range ids { - _ = tRepos.Attachments.Delete(context.Background(), id) + _ = tRepos.Attachments.Delete(context.Background(), tGroup.ID, item.ID, id) } }) attachments := make([]*ent.Attachment, n) for i := 0; i < n; i++ { - attachment, err := tRepos.Attachments.Create(context.Background(), item.ID, ItemCreateAttachment{Title: "Test", Content: strings.NewReader("Test String")}, attachment.TypePhoto, true) + attach, err := tRepos.Attachments.Create(context.Background(), item.ID, ItemCreateAttachment{Title: "Test", Content: strings.NewReader("Test String")}, attachment.TypePhoto, true) require.NoError(t, err) - attachments[i] = attachment + attachments[i] = attach - ids = append(ids, attachment.ID) + ids = append(ids, attach.ID) } return attachments @@ -107,13 +107,13 @@ func TestAttachmentRepo_Update(t *testing.T) { for _, typ := range []attachment.Type{"photo", "manual", "warranty", "attachment"} { t.Run(string(typ), func(t *testing.T) { - _, err := tRepos.Attachments.Update(context.Background(), entity.ID, &ItemAttachmentUpdate{ + _, err := tRepos.Attachments.Update(context.Background(), tGroup.ID, entity.ID, &ItemAttachmentUpdate{ Type: string(typ), }) require.NoError(t, err) - updated, err := tRepos.Attachments.Get(context.Background(), entity.ID) + updated, err := tRepos.Attachments.Get(context.Background(), tGroup.ID, entity.ID) require.NoError(t, err) assert.Equal(t, typ, updated.Type) }) @@ -122,11 +122,12 @@ func TestAttachmentRepo_Update(t *testing.T) { func TestAttachmentRepo_Delete(t *testing.T) { entity := useAttachments(t, 1)[0] + item := useItems(t, 1)[0] - err := tRepos.Attachments.Delete(context.Background(), entity.ID) + err := tRepos.Attachments.Delete(context.Background(), tGroup.ID, item.ID, entity.ID) require.NoError(t, err) - _, err = tRepos.Attachments.Get(context.Background(), entity.ID) + _, err = tRepos.Attachments.Get(context.Background(), tGroup.ID, entity.ID) require.Error(t, err) } @@ -135,13 +136,13 @@ func TestAttachmentRepo_EnsureSinglePrimaryAttachment(t *testing.T) { attachments := useAttachments(t, 2) setAndVerifyPrimary := func(primaryAttachmentID, nonPrimaryAttachmentID uuid.UUID) { - primaryAttachment, err := tRepos.Attachments.Update(ctx, primaryAttachmentID, &ItemAttachmentUpdate{ + primaryAttachment, err := tRepos.Attachments.Update(ctx, tGroup.ID, primaryAttachmentID, &ItemAttachmentUpdate{ Type: attachment.TypePhoto.String(), Primary: true, }) require.NoError(t, err) - nonPrimaryAttachment, err := tRepos.Attachments.Get(ctx, nonPrimaryAttachmentID) + nonPrimaryAttachment, err := tRepos.Attachments.Get(ctx, tGroup.ID, nonPrimaryAttachmentID) require.NoError(t, err) assert.True(t, primaryAttachment.Primary) diff --git a/backend/pkgs/utils/image.go b/backend/pkgs/utils/image.go new file mode 100644 index 00000000..7a527a5d --- /dev/null +++ b/backend/pkgs/utils/image.go @@ -0,0 +1,86 @@ +package utils + +import "image" + +// flipHorizontal will flip the image horizontally. There is a limit of 10000 pixels in either dimension to prevent excessive memory usage. +func flipHorizontal(img image.Image) image.Image { + b := img.Bounds() + if b.Dx() > 10000 || b.Dy() > 10000 { + return img + } + dst := image.NewRGBA(b) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + dst.Set(b.Max.X-1-(x-b.Min.X), y, img.At(x, y)) + } + } + return dst +} + +// flipVertical will flip the image vertically. There is a limit of 10000 pixels in either dimension to prevent excessive memory usage. +func flipVertical(img image.Image) image.Image { + b := img.Bounds() + if b.Dx() > 10000 || b.Dy() > 10000 { + return img + } + dst := image.NewRGBA(b) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + dst.Set(x, b.Max.Y-1-(y-b.Min.Y), img.At(x, y)) + } + } + return dst +} + +// rotate90 will rotate the image 90 degrees clockwise. There is a limit of 10000 pixels in either dimension to prevent excessive memory usage. +func rotate90(img image.Image) image.Image { + b := img.Bounds() + if b.Dx() > 10000 || b.Dy() > 10000 { + return img + } + dst := image.NewRGBA(image.Rect(0, 0, b.Dy(), b.Dx())) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + dst.Set(b.Max.Y-1-y, x, img.At(x, y)) + } + } + return dst +} + +func rotate180(img image.Image) image.Image { + return rotate90(rotate90(img)) +} + +func rotate270(img image.Image) image.Image { + return rotate90(rotate180(img)) +} + +// Applies EXIF orientation using only stdlib +func ApplyOrientation(img image.Image, orientation uint16) image.Image { + if img == nil { + return nil + } + if orientation < 1 || orientation > 8 { + return img // No orientation or invalid orientation + } + switch orientation { + case 1: + return img // No rotation needed + case 2: + return flipHorizontal(img) + case 3: + return rotate180(img) + case 4: + return flipVertical(img) + case 5: + return rotate90(flipHorizontal(img)) + case 6: + return rotate90(img) + case 7: + return rotate270(flipHorizontal(img)) + case 8: + return rotate270(img) + default: + return img + } +}