diff --git a/.golangci.yml b/.golangci.yml index 2b10938..d5d12bb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -23,13 +23,12 @@ formatters: - goimports # checks if the code and import statements are formatted according to the 'goimports' command - golines # checks if code is formatted, and fixes long lines - swaggo # formats swaggo comments - ## you may want to enable #- gci # checks if code and import statements are formatted, with additional rules #- gofmt # checks if the code is formatted according to 'gofmt' command #- gofumpt # enforces a stricter format than 'gofmt', while being backwards compatible - # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml settings: golines: # Target maximum line length. @@ -118,7 +117,6 @@ linters: - wastedassign # finds wasted assignment statements - whitespace # detects leading and trailing whitespace - wrapcheck # checks that errors returned from external packages are wrapped - ## you may want to enable #- arangolint # opinionated best practices for arangodb client #- decorder # checks declaration order and count of types, constants, variables and functions @@ -155,7 +153,7 @@ linters: #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines #- wsl_v5 # [too strict and mostly code is not more readable] add or remove empty lines - # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml settings: cyclop: # The maximal code complexity to report. @@ -196,7 +194,8 @@ linters: # Default: [] deny: - pkg: github.com/golang/protobuf - desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules + desc: Use google.golang.org/protobuf instead, see + https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules - pkg: github.com/satori/go.uuid desc: Use github.com/google/uuid instead, satori's package is not maintained - pkg: github.com/gofrs/uuid$ @@ -262,6 +261,7 @@ linters: - ^github.com/go-telegram/bot/models.+$ - ^github.com/gofiber/.+Config$ - ^github.com/golang-jwt/jwt/v5.+Claims$ + - ^github.com/minio/minio-go/.+Options$ - ^github.com/mitchellh/mapstructure.DecoderConfig$ - ^github.com/mymmrac/telego.+Params$ - ^github.com/prometheus/client_golang/.+Opts$ @@ -395,7 +395,7 @@ linters: nolintlint: # Exclude following linters from requiring an explanation. # Default: [] - allow-no-explanation: [funlen, gocognit, golines] + allow-no-explanation: [ funlen, gocognit, golines ] # Enable to require an explanation of nonzero length after each nolint directive. # Default: false require-explanation: true @@ -475,20 +475,21 @@ linters: # Excluding configuration per-path, per-linter, per-text and per-source. rules: - source: "TODO" - linters: [godot] + linters: [ godot ] - text: "should have a package comment" - linters: [revive] + linters: [ revive ] - text: "avoid package names that conflict with Go standard library package names" - linters: [revive] - - text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported' - linters: [revive] - - text: 'package comment should be of the form ".+"' + linters: [ revive ] + - text: "exported \\S+ \\S+ should have comment( \\(or a comment on this + block\\))? or be unexported" + linters: [ revive ] + - text: "package comment should be of the form \".+\"" source: "// ?(nolint|TODO)" - linters: [revive] - - text: 'comment on exported \S+ \S+ should be of the form ".+"' + linters: [ revive ] + - text: "comment on exported \\S+ \\S+ should be of the form \".+\"" source: "// ?(nolint|TODO)" - linters: [revive, staticcheck] - - path: '_test\.go' + linters: [ revive, staticcheck ] + - path: "_test\\.go" linters: - bodyclose - dupl diff --git a/go.mod b/go.mod index fe3b02b..da078ec 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gosimple/slug v1.15.0 + github.com/minio/minio-go/v7 v7.0.100 github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 github.com/samber/lo v1.52.0 @@ -25,7 +26,7 @@ require ( github.com/uptrace/bun/dialect/mysqldialect v1.2.18 go.uber.org/fx v1.24.0 go.uber.org/zap v1.27.1 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 ) require ( @@ -39,9 +40,11 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/go-core-fx/fxutil v0.0.0-20251027105421-acea37162eb9 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.6 // indirect github.com/go-openapi/spec v0.20.4 // indirect @@ -56,6 +59,8 @@ require ( github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/knadh/koanf/parsers/dotenv v1.1.0 // indirect github.com/knadh/koanf/parsers/yaml v1.1.0 // indirect @@ -68,16 +73,21 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/xid v1.6.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/swaggo/files/v2 v2.0.2 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect @@ -89,12 +99,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/net v0.51.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.35.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 5a27731..c3b5bf0 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ github.com/go-core-fx/sqlfx v0.1.0 h1:jWK4oUqvoJhcbLORWZXeRTzjstY6APter1fKsRYJbN github.com/go-core-fx/sqlfx v0.1.0/go.mod h1:D8fFoIeCUGthMN2nOeYIqs+yYH5CEDJBNJeMP4+Usk8= github.com/go-core-fx/validatorfx v0.0.2 h1:J+POBsdqyT2Hd3TZHgJ+0eBtCPHhHff8TJLcGowQ0Ew= github.com/go-core-fx/validatorfx v0.0.2/go.mod h1:1VtQoOEzBo3oapXgRFA65H+zSKvmCvb6yfAcdwpqSlE= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -96,6 +98,11 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/dotenv v1.1.0 h1:dQaM0Jw54zRsqDcaJ27pciNExuKfOXagCJW3K1h0hj0= @@ -131,6 +138,12 @@ github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEj github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8= +github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -140,6 +153,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= @@ -158,6 +173,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= @@ -171,6 +188,8 @@ github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= github.com/uptrace/bun v1.2.18 h1:3HnRcMfS6OBPMG1eSOzlbFJ/X/AyMEJb7rMxE6VQvDU= @@ -213,15 +232,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -232,11 +251,11 @@ golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/app.go b/internal/app.go index 62bdaf5..6f57097 100644 --- a/internal/app.go +++ b/internal/app.go @@ -3,14 +3,17 @@ package internal import ( "context" + "github.com/bit-issues/backend/internal/attachments" "github.com/bit-issues/backend/internal/comments" "github.com/bit-issues/backend/internal/config" "github.com/bit-issues/backend/internal/db" "github.com/bit-issues/backend/internal/jwt" "github.com/bit-issues/backend/internal/projects" "github.com/bit-issues/backend/internal/server" + "github.com/bit-issues/backend/internal/storage" "github.com/bit-issues/backend/internal/tasks" "github.com/bit-issues/backend/internal/users" + "github.com/bit-issues/backend/pkg/miniofx" "github.com/go-core-fx/bunfx" "github.com/go-core-fx/fiberfx" "github.com/go-core-fx/goosefx" @@ -43,11 +46,13 @@ func Run(version healthfx.Version) { // telegofx.Module(true), validatorfx.Module(), // watermillfx.Module(), + miniofx.Module(), // // APP MODULES config.Module(), db.Module(), server.Module(), + storage.Module(), // // BUSINESS MODULES fx.Supply(version), @@ -55,6 +60,7 @@ func Run(version healthfx.Version) { users.Module(), projects.Module(), tasks.Module(), + attachments.Module(), comments.Module(), // fx.Invoke(func(lc fx.Lifecycle, logger *zap.Logger) { diff --git a/internal/attachments/config.go b/internal/attachments/config.go new file mode 100644 index 0000000..29774f1 --- /dev/null +++ b/internal/attachments/config.go @@ -0,0 +1,5 @@ +package attachments + +type Config struct { + MaxSize uint64 +} diff --git a/internal/attachments/doc.go b/internal/attachments/doc.go new file mode 100644 index 0000000..4badd35 --- /dev/null +++ b/internal/attachments/doc.go @@ -0,0 +1,2 @@ +// Package attachments provides domain, repository and service logic for task attachments. +package attachments diff --git a/internal/attachments/domain.go b/internal/attachments/domain.go new file mode 100644 index 0000000..571915a --- /dev/null +++ b/internal/attachments/domain.go @@ -0,0 +1,77 @@ +package attachments + +import ( + "fmt" + "time" +) + +const ( + DefaultMaxFileSizeBytes uint64 = 104857600 + MaxFileNameLength int = 255 +) + +type AttachmentStatus string + +const ( + StatusPending AttachmentStatus = "pending" + StatusUploaded AttachmentStatus = "uploaded" +) + +type Attachment struct { + ID int64 + TaskID int64 + FileName string + StorageKey string + SizeBytes uint64 + Status AttachmentStatus + UploadedBy int64 + UploadedAt time.Time + DeletedAt *time.Time +} + +type AttachmentInput struct { + TaskID int64 + FileName string + SizeBytes uint64 + UploaderID int64 +} + +type UploadResult struct { + Attachment *Attachment + UploadURL string +} + +type AttachmentWithURL struct { + Attachment + + DownloadURL string +} + +func (i AttachmentInput) Validate(maxFileSize uint64) error { + if i.TaskID <= 0 { + return fmt.Errorf("%w: task_id must be positive", ErrValidationFailed) + } + + if i.UploaderID <= 0 { + return fmt.Errorf("%w: uploader_id must be positive", ErrValidationFailed) + } + + fileName := sanitizeFileName(i.FileName) + if fileName == "" { + return fmt.Errorf("%w: file_name is required", ErrValidationFailed) + } + + if len(fileName) > MaxFileNameLength { + return fmt.Errorf("%w: file_name too long (max %d characters)", ErrValidationFailed, MaxFileNameLength) + } + + if i.SizeBytes == 0 { + return fmt.Errorf("%w: size_bytes must be positive", ErrValidationFailed) + } + + if i.SizeBytes > maxFileSize { + return ErrFileTooLarge + } + + return nil +} diff --git a/internal/attachments/errors.go b/internal/attachments/errors.go new file mode 100644 index 0000000..8989628 --- /dev/null +++ b/internal/attachments/errors.go @@ -0,0 +1,12 @@ +package attachments + +import "errors" + +var ( + ErrNotFound = errors.New("attachment not found") + ErrValidationFailed = errors.New("validation failed") + ErrTaskNotFound = errors.New("task not found") + ErrUnauthorized = errors.New("unauthorized") + ErrFileTooLarge = errors.New("file exceeds maximum allowed size") + ErrNotUploaded = errors.New("attachment is not yet uploaded") +) diff --git a/internal/attachments/models.go b/internal/attachments/models.go new file mode 100644 index 0000000..86948ba --- /dev/null +++ b/internal/attachments/models.go @@ -0,0 +1,56 @@ +package attachments + +import ( + "time" + + "github.com/uptrace/bun" + "github.com/uptrace/bun/schema" +) + +type attachmentModel struct { + bun.BaseModel `bun:"table:attachments,alias:a"` + + ID int64 `bun:"id,pk,autoincrement"` + TaskID int64 `bun:"task_id,notnull"` + FileName string `bun:"file_name,notnull"` + StorageKey string `bun:"storage_key,notnull"` + SizeBytes uint64 `bun:"size_bytes,notnull"` + Status string `bun:"status,notnull"` + UploadedBy int64 `bun:"uploaded_by,notnull"` + UploadedAt time.Time `bun:"uploaded_at,notnull"` + DeletedAt *time.Time `bun:"deleted_at,soft_delete,nullzero"` +} + +func newAttachmentModel(input AttachmentInput, storageKey string) *attachmentModel { + return &attachmentModel{ + BaseModel: schema.BaseModel{}, + ID: 0, + + TaskID: input.TaskID, + FileName: sanitizeFileName(input.FileName), + StorageKey: storageKey, + SizeBytes: input.SizeBytes, + Status: string(StatusPending), + UploadedBy: input.UploaderID, + UploadedAt: time.Now().UTC(), + DeletedAt: nil, + } +} + +func (m *attachmentModel) toDomain() *Attachment { + if m == nil { + return nil + } + + return &Attachment{ + ID: m.ID, + TaskID: m.TaskID, + FileName: m.FileName, + StorageKey: m.StorageKey, + SizeBytes: m.SizeBytes, + Status: AttachmentStatus(m.Status), + UploadedBy: m.UploadedBy, + UploadedAt: m.UploadedAt, + DeletedAt: m.DeletedAt, + } +} diff --git a/internal/attachments/module.go b/internal/attachments/module.go new file mode 100644 index 0000000..ecbd35b --- /dev/null +++ b/internal/attachments/module.go @@ -0,0 +1,15 @@ +package attachments + +import ( + "github.com/go-core-fx/logger" + "go.uber.org/fx" +) + +func Module() fx.Option { + return fx.Module( + "attachments", + logger.WithNamedLogger("attachments"), + fx.Provide(NewRepository, fx.Private), + fx.Provide(NewService), + ) +} diff --git a/internal/attachments/repository.go b/internal/attachments/repository.go new file mode 100644 index 0000000..1ba7aa8 --- /dev/null +++ b/internal/attachments/repository.go @@ -0,0 +1,83 @@ +package attachments + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/uptrace/bun" +) + +type Repository struct { + db *bun.DB +} + +func NewRepository(db *bun.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Create(ctx context.Context, input AttachmentInput, storageKey string) (*Attachment, error) { + model := newAttachmentModel(input, storageKey) + + if _, err := r.db.NewInsert().Model(model).Returning("*").Exec(ctx); err != nil { + return nil, fmt.Errorf("failed to create attachment: %w", err) + } + + return model.toDomain(), nil +} + +func (r *Repository) GetByID(ctx context.Context, id int64) (*Attachment, error) { + var model attachmentModel + if err := r.db.NewSelect(). + Model(&model). + Where("id = ?", id). + Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to get attachment by id: %w", err) + } + + return model.toDomain(), nil +} + +func (r *Repository) ListByTask(ctx context.Context, taskID int64) ([]Attachment, error) { + models := make([]attachmentModel, 0) + if err := r.db.NewSelect(). + Model(&models). + Where("task_id = ?", taskID). + Where("status = ?", StatusUploaded). + OrderExpr("uploaded_at ASC"). + Scan(ctx); err != nil { + return nil, fmt.Errorf("failed to list attachments: %w", err) + } + + result := make([]Attachment, 0, len(models)) + for _, item := range models { + result = append(result, *item.toDomain()) + } + + return result, nil +} + +func (r *Repository) Confirm(ctx context.Context, id int64) error { + _, err := r.db.NewUpdate(). + Model((*attachmentModel)(nil)). + Set("status = ?", StatusUploaded). + Where("id = ?", id). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to confirm attachment: %w", err) + } + + return nil +} + +func (r *Repository) Delete(ctx context.Context, id int64) error { + if _, err := r.db.NewDelete().Model((*attachmentModel)(nil)).Where("id = ?", id).Exec(ctx); err != nil { + return fmt.Errorf("failed to delete attachment: %w", err) + } + + return nil +} diff --git a/internal/attachments/service.go b/internal/attachments/service.go new file mode 100644 index 0000000..5565241 --- /dev/null +++ b/internal/attachments/service.go @@ -0,0 +1,180 @@ +package attachments + +import ( + "context" + "fmt" + "path" + "path/filepath" + "strconv" + "strings" + + "github.com/bit-issues/backend/internal/storage" + "github.com/bit-issues/backend/internal/tasks" + "github.com/bit-issues/backend/internal/users" + "github.com/google/uuid" + "go.uber.org/zap" +) + +type Service struct { + config Config + + attachments *Repository + + tasksSvc *tasks.Service + storageSvc *storage.Service + + logger *zap.Logger +} + +func NewService( + config Config, + attachments *Repository, + tasksSvc *tasks.Service, + storageSvc *storage.Service, + logger *zap.Logger, +) *Service { + if config.MaxSize == 0 { + config.MaxSize = DefaultMaxFileSizeBytes + } + + return &Service{ + config: config, + + attachments: attachments, + + tasksSvc: tasksSvc, + storageSvc: storageSvc, + + logger: logger, + } +} + +func (s *Service) InitUpload(ctx context.Context, input AttachmentInput) (*UploadResult, error) { + if err := input.Validate(s.config.MaxSize); err != nil { + return nil, err + } + + ok, err := s.tasksSvc.Exists(ctx, input.TaskID) + if err != nil { + return nil, fmt.Errorf("failed to check if task exists: %w", err) + } + if !ok { + return nil, ErrTaskNotFound + } + + storageKey := s.buildStorageKey(input.TaskID, input.FileName) + uploadURL, err := s.storageSvc.PresignedPutObject(ctx, storageKey) + if err != nil { + return nil, fmt.Errorf("failed to create upload url: %w", err) + } + + attachment, err := s.attachments.Create(ctx, input, storageKey) + if err != nil { + return nil, err + } + + return &UploadResult{ + Attachment: attachment, + UploadURL: uploadURL, + }, nil +} + +func (s *Service) ListByTask(ctx context.Context, taskID int64) ([]AttachmentWithURL, error) { + items, err := s.attachments.ListByTask(ctx, taskID) + if err != nil { + return nil, err + } + + result := make([]AttachmentWithURL, 0, len(items)) + for _, item := range items { + downloadURL, urlErr := s.storageSvc.PresignedGetObject(ctx, item.StorageKey) + if urlErr != nil { + return nil, fmt.Errorf("failed to create download url: %w", urlErr) + } + + result = append(result, AttachmentWithURL{Attachment: item, DownloadURL: downloadURL}) + } + + return result, nil +} + +func (s *Service) GetDownloadURL(ctx context.Context, id int64) (string, error) { + attachment, err := s.attachments.GetByID(ctx, id) + if err != nil { + return "", err + } + + if attachment.Status != StatusUploaded { + return "", ErrNotUploaded + } + + downloadURL, err := s.storageSvc.PresignedGetObject(ctx, attachment.StorageKey) + if err != nil { + return "", fmt.Errorf("failed to create download url: %w", err) + } + + return downloadURL, nil +} + +func (s *Service) ConfirmUpload(ctx context.Context, id int64, uploaderID int64) (*Attachment, error) { + attachment, err := s.attachments.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if attachment.UploadedBy != uploaderID { + return nil, ErrUnauthorized + } + + if confirmErr := s.attachments.Confirm(ctx, id); confirmErr != nil { + return nil, confirmErr + } + + attachment.Status = StatusUploaded + return attachment, nil +} + +func (s *Service) Delete(ctx context.Context, user *users.User, id int64) error { + attachment, err := s.attachments.GetByID(ctx, id) + if err != nil { + return err + } + + task, err := s.tasksSvc.GetByID(ctx, attachment.TaskID) + if err != nil { + return fmt.Errorf("failed to get task: %w", err) + } + + if user.Role != users.RoleAdmin && user.ID != attachment.UploadedBy && user.ID != task.AuthorID { + return ErrUnauthorized + } + + if delErr := s.attachments.Delete(ctx, id); delErr != nil { + return delErr + } + + if delErr := s.storageSvc.Delete(ctx, attachment.StorageKey); delErr != nil { + return fmt.Errorf("attachment metadata deleted, but object cleanup failed: %w", delErr) + } + + return nil +} + +func (s *Service) buildStorageKey(taskID int64, fileName string) string { + safeName := sanitizeFileName(fileName) + return path.Join(strconv.FormatInt(taskID, 10), uuid.Must(uuid.NewV7()).String()+"-"+safeName) +} + +func sanitizeFileName(name string) string { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "" + } + + base := filepath.Base(trimmed) + if base == "." || base == string(filepath.Separator) { + return "" + } + + return strings.ReplaceAll(base, "\\", "") +} diff --git a/internal/config/config.go b/internal/config/config.go index 97bc7e5..b518139 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,10 +36,21 @@ type jwtConfig struct { Issuer string `koanf:"issuer"` } +type storageConfig struct { + URL string `koanf:"url"` + LinksTTL time.Duration `koanf:"links_ttl"` +} + +type attachmentsConfig struct { + MaxSize uint64 `koanf:"max_size"` +} + type Config struct { - HTTP http `koanf:"http"` - Database databaseConfig `koanf:"database"` - JWT jwtConfig `koanf:"jwt"` + HTTP http `koanf:"http"` + Database databaseConfig `koanf:"database"` + JWT jwtConfig `koanf:"jwt"` + Storage storageConfig `koanf:"storage"` + Attachments attachmentsConfig `koanf:"attachments"` } func Default() Config { @@ -67,6 +78,13 @@ func Default() Config { AccessTTL: time.Minute * 15, Issuer: "bitissues.dev", }, + Storage: storageConfig{ + URL: "s3://storage.bitissues.dev/attachments?endpoint=storage.bitissues.dev®ion=us-east-1", + LinksTTL: time.Minute * 15, + }, + Attachments: attachmentsConfig{ + MaxSize: 10 * 1024 * 1024, + }, } } diff --git a/internal/config/module.go b/internal/config/module.go index f38c126..70df1a5 100644 --- a/internal/config/module.go +++ b/internal/config/module.go @@ -1,7 +1,9 @@ package config import ( + "github.com/bit-issues/backend/internal/attachments" "github.com/bit-issues/backend/internal/jwt" + "github.com/bit-issues/backend/internal/storage" "github.com/go-core-fx/fiberfx" "github.com/go-core-fx/fiberfx/openapi" "github.com/go-core-fx/sqlfx" @@ -37,12 +39,27 @@ func Module() fx.Option { } }, ), - fx.Provide(func(cfg Config) jwt.Config { - return jwt.Config{ - Secret: cfg.JWT.Secret, - AccessTTL: cfg.JWT.AccessTTL, - Issuer: cfg.JWT.Issuer, - } - }), + fx.Provide( + func(cfg Config) jwt.Config { + return jwt.Config{ + Secret: cfg.JWT.Secret, + AccessTTL: cfg.JWT.AccessTTL, + Issuer: cfg.JWT.Issuer, + } + }, + func(cfg Config) storage.Config { + return storage.Config{ + URL: cfg.Storage.URL, + LinksTTL: cfg.Storage.LinksTTL, + } + }, + ), + fx.Provide( + func(cfg Config) attachments.Config { + return attachments.Config{ + MaxSize: cfg.Attachments.MaxSize, + } + }, + ), ) } diff --git a/internal/db/migrations/20260422000000_create_attachments.sql b/internal/db/migrations/20260422000000_create_attachments.sql new file mode 100644 index 0000000..0d8a90c --- /dev/null +++ b/internal/db/migrations/20260422000000_create_attachments.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS `attachments` ( + `id` SERIAL, + `task_id` BIGINT UNSIGNED NOT NULL, + `file_name` VARCHAR(255) NOT NULL, + `storage_key` VARCHAR(512) NOT NULL, + `size_bytes` BIGINT UNSIGNED NOT NULL, + `status` ENUM('pending', 'uploaded') NOT NULL DEFAULT 'pending', + `uploaded_by` BIGINT UNSIGNED NOT NULL, + `uploaded_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + INDEX `idx_attachments_task_id` (`task_id`), + INDEX `idx_attachments_storage_key` (`storage_key`), + INDEX `idx_attachments_status` (`status`), + INDEX `idx_attachments_deleted_at` (`deleted_at`), + CONSTRAINT `fk_attachments_task` FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_attachments_uploader` FOREIGN KEY (`uploaded_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; +-- +goose StatementEnd +--- +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS `attachments`; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/server/docs/docs.go b/internal/server/docs/docs.go index 9c33fe6..e97b2f1 100644 --- a/internal/server/docs/docs.go +++ b/internal/server/docs/docs.go @@ -1378,6 +1378,29 @@ const docTemplate = `{ } } }, + "tasks.AttachmentResponse": { + "type": "object", + "properties": { + "download_url": { + "type": "string" + }, + "file_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "size_bytes": { + "type": "integer" + }, + "uploaded_at": { + "type": "string" + }, + "uploaded_by": { + "$ref": "#/definitions/dto.UserBrief" + } + } + }, "tasks.CommentCreateRequest": { "description": "Comment creation request with content.", "type": "object", @@ -1472,6 +1495,12 @@ const docTemplate = `{ "assignee": { "$ref": "#/definitions/dto.UserBrief" }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/tasks.AttachmentResponse" + } + }, "author": { "$ref": "#/definitions/dto.UserBrief" }, diff --git a/internal/server/tasks/dto.go b/internal/server/tasks/dto.go index 4d35148..876a7bc 100644 --- a/internal/server/tasks/dto.go +++ b/internal/server/tasks/dto.go @@ -4,6 +4,7 @@ import ( "strings" "time" + "github.com/bit-issues/backend/internal/attachments" "github.com/bit-issues/backend/internal/comments" "github.com/bit-issues/backend/internal/server/dto" "github.com/bit-issues/backend/internal/tasks" @@ -139,13 +140,19 @@ func newTaskResponse(task *tasks.Task) TaskResponse { type TaskDetailsResponse struct { TaskResponse - Comments []CommentResponse `json:"comments"` + Comments []CommentResponse `json:"comments"` + Attachments []AttachmentResponse `json:"attachments"` } -func newTaskDetailsResponse(task *tasks.Task, comments []comments.Comment) *TaskDetailsResponse { +func newTaskDetailsResponse( + task *tasks.Task, + comments []comments.Comment, + attachmentList []attachments.AttachmentWithURL, +) *TaskDetailsResponse { return &TaskDetailsResponse{ TaskResponse: newTaskResponse(task), Comments: toCommentsList(comments), + Attachments: toAttachmentsList(attachmentList), } } diff --git a/internal/server/tasks/dto_attachments.go b/internal/server/tasks/dto_attachments.go new file mode 100644 index 0000000..522ff2c --- /dev/null +++ b/internal/server/tasks/dto_attachments.go @@ -0,0 +1,92 @@ +package tasks + +import ( + "time" + + "github.com/bit-issues/backend/internal/attachments" + "github.com/bit-issues/backend/internal/server/dto" +) + +type AttachmentUploadRequest struct { + FileName string `json:"file_name" validate:"required"` + SizeBytes uint64 `json:"size_bytes" validate:"required,min=1"` +} + +type AttachmentUploadResponse struct { + ID int64 `json:"id"` + FileName string `json:"file_name"` + SizeBytes uint64 `json:"size_bytes"` + UploadURL string `json:"upload_url"` +} + +type AttachmentConfirmResponse struct { + ID int64 `json:"id"` + FileName string `json:"file_name"` + SizeBytes uint64 `json:"size_bytes"` + DownloadURL string `json:"download_url"` + UploadedAt string `json:"uploaded_at"` + UploadedBy dto.UserBrief `json:"uploaded_by"` +} + +type AttachmentDownloadResponse struct { + DownloadURL string `json:"download_url"` +} + +type AttachmentResponse struct { + ID int64 `json:"id"` + FileName string `json:"file_name"` + SizeBytes uint64 `json:"size_bytes"` + UploadedBy dto.UserBrief `json:"uploaded_by"` + UploadedAt string `json:"uploaded_at"` + DownloadURL string `json:"download_url"` +} + +func toUploadResponse(result *attachments.UploadResult) AttachmentUploadResponse { + return AttachmentUploadResponse{ + ID: result.Attachment.ID, + FileName: result.Attachment.FileName, + SizeBytes: result.Attachment.SizeBytes, + UploadURL: result.UploadURL, + } +} + +func toConfirmResponse(attachment *attachments.Attachment, downloadURL string) AttachmentConfirmResponse { + return AttachmentConfirmResponse{ + ID: attachment.ID, + FileName: attachment.FileName, + SizeBytes: attachment.SizeBytes, + DownloadURL: downloadURL, + UploadedAt: attachment.UploadedAt.UTC().Format(time.RFC3339), + UploadedBy: dto.UserBrief{ + ID: attachment.UploadedBy, + Email: "", + Role: "", + CreatedAt: "", + }, + } +} + +func toAttachmentResponse(item attachments.AttachmentWithURL) AttachmentResponse { + return AttachmentResponse{ + ID: item.ID, + FileName: item.FileName, + SizeBytes: item.SizeBytes, + UploadedAt: item.UploadedAt.UTC().Format(time.RFC3339), + DownloadURL: item.DownloadURL, + UploadedBy: dto.UserBrief{ + ID: item.UploadedBy, + Email: "", + Role: "", + CreatedAt: "", + }, + } +} + +func toAttachmentsList(items []attachments.AttachmentWithURL) []AttachmentResponse { + result := make([]AttachmentResponse, 0, len(items)) + for _, item := range items { + result = append(result, toAttachmentResponse(item)) + } + + return result +} diff --git a/internal/server/tasks/handler.go b/internal/server/tasks/handler.go index c86ba86..6f7e41d 100644 --- a/internal/server/tasks/handler.go +++ b/internal/server/tasks/handler.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" + "github.com/bit-issues/backend/internal/attachments" "github.com/bit-issues/backend/internal/comments" "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" "github.com/bit-issues/backend/internal/tasks" @@ -19,24 +20,27 @@ import ( type Handler struct { handler.Base - tasksSvc *tasks.Service - commentsSvc *comments.Service - usersSvc *users.Service + tasksSvc *tasks.Service + commentsSvc *comments.Service + attachmentsSvc *attachments.Service + usersSvc *users.Service } // NewHandler creates a new Handler instance with the given dependencies. func NewHandler( tasksSvc *tasks.Service, commentsSvc *comments.Service, + attachmentsSvc *attachments.Service, usersSvc *users.Service, validate *validator.Validate, ) handler.Handler { return &Handler{ Base: handler.Base{Validator: validate}, - tasksSvc: tasksSvc, - commentsSvc: commentsSvc, - usersSvc: usersSvc, + tasksSvc: tasksSvc, + commentsSvc: commentsSvc, + attachmentsSvc: attachmentsSvc, + usersSvc: usersSvc, } } @@ -69,6 +73,12 @@ func (h *Handler) Register(r fiber.Router) { validation.DecorateWithBodyEx(h.Validator, h.updateComment), ) comments.Delete("/:id", h.deleteComment) + + attachments := tasks.Group("/:task_id/attachments") + attachments.Post("/", validation.DecorateWithBodyEx(h.Validator, h.attachmentInitUpload)) + attachments.Put("/:id/confirm", h.attachmentConfirmUpload) + attachments.Get("/:id/download", h.attachmentGetDownloadURL) + attachments.Delete("/:id", h.attachmentDelete) } // @Summary List all tasks @@ -138,7 +148,12 @@ func (h *Handler) get(c *fiber.Ctx) error { return fmt.Errorf("failed to get comments: %w", err) } - return c.JSON(newTaskDetailsResponse(task, comments)) + attachmentList, err := h.attachmentsSvc.ListByTask(c.Context(), task.ID) + if err != nil { + return fmt.Errorf("failed to get attachments: %w", err) + } + + return c.JSON(newTaskDetailsResponse(task, comments, attachmentList)) } // @Summary Create a new task @@ -171,7 +186,7 @@ func (h *Handler) post(c *fiber.Ctx, req *TaskCreateRequest) error { return fmt.Errorf("failed to create task: %w", err) } - return c.Status(fiber.StatusCreated).JSON(newTaskDetailsResponse(task, nil)) + return c.Status(fiber.StatusCreated).JSON(newTaskDetailsResponse(task, nil, nil)) } // @Summary Update a task @@ -294,6 +309,19 @@ func (h *Handler) errorsHandler(c *fiber.Ctx) error { case errors.Is(err, comments.ErrValidationFailed): return fiber.NewError(fiber.StatusBadRequest, err.Error()) + case errors.Is(err, attachments.ErrNotFound): + return fiber.NewError(fiber.StatusNotFound, err.Error()) + case errors.Is(err, attachments.ErrValidationFailed): + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + case errors.Is(err, attachments.ErrTaskNotFound): + return fiber.NewError(fiber.StatusNotFound, err.Error()) + case errors.Is(err, attachments.ErrUnauthorized): + return fiber.NewError(fiber.StatusForbidden, err.Error()) + case errors.Is(err, attachments.ErrFileTooLarge): + return fiber.NewError(fiber.StatusRequestEntityTooLarge, err.Error()) + case errors.Is(err, attachments.ErrNotUploaded): + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + default: return err //nolint:wrapcheck // err is already wrapped } diff --git a/internal/server/tasks/handler_attachments.go b/internal/server/tasks/handler_attachments.go new file mode 100644 index 0000000..df0bcaa --- /dev/null +++ b/internal/server/tasks/handler_attachments.go @@ -0,0 +1,94 @@ +package tasks + +import ( + "fmt" + "strconv" + + "github.com/bit-issues/backend/internal/attachments" + "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" + "github.com/gofiber/fiber/v2" +) + +func (h *Handler) attachmentInitUpload(c *fiber.Ctx, req *AttachmentUploadRequest) error { + taskID, err := strconv.ParseInt(c.Params("task_id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid task ID") + } + + user, ok := jwtauth.GetUser(c) + if !ok { + return fiber.NewError(fiber.StatusUnauthorized, "unauthorized") + } + + result, err := h.attachmentsSvc.InitUpload(c.Context(), attachments.AttachmentInput{ + TaskID: taskID, + FileName: req.FileName, + SizeBytes: req.SizeBytes, + UploaderID: user.ID, + }) + if err != nil { + return fmt.Errorf("failed to initialize upload: %w", err) + } + + return c.Status(fiber.StatusCreated).JSON(toUploadResponse(result)) +} + +func (h *Handler) attachmentConfirmUpload(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid attachment ID") + } + + user, ok := jwtauth.GetUser(c) + if !ok { + return fiber.NewError(fiber.StatusUnauthorized, "unauthorized") + } + + attachment, err := h.attachmentsSvc.ConfirmUpload(c.Context(), id, user.ID) + if err != nil { + return fmt.Errorf("failed to confirm attachment upload: %w", err) + } + + downloadURL, err := h.attachmentsSvc.GetDownloadURL(c.Context(), id) + if err != nil { + return fmt.Errorf("failed to create download url: %w", err) + } + + return c.JSON(toConfirmResponse(attachment, downloadURL)) +} + +func (h *Handler) attachmentGetDownloadURL(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid attachment ID") + } + + if _, ok := jwtauth.GetUser(c); !ok { + return fiber.NewError(fiber.StatusUnauthorized, "unauthorized") + } + + downloadURL, err := h.attachmentsSvc.GetDownloadURL(c.Context(), id) + if err != nil { + return fmt.Errorf("failed to get download url: %w", err) + } + + return c.JSON(AttachmentDownloadResponse{DownloadURL: downloadURL}) +} + +func (h *Handler) attachmentDelete(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid attachment ID") + } + + user, ok := jwtauth.GetUser(c) + if !ok { + return fiber.NewError(fiber.StatusUnauthorized, "unauthorized") + } + + if delErr := h.attachmentsSvc.Delete(c.Context(), user, id); delErr != nil { + return fmt.Errorf("failed to delete attachment: %w", delErr) + } + + return c.SendStatus(fiber.StatusNoContent) +} diff --git a/internal/storage/config.go b/internal/storage/config.go new file mode 100644 index 0000000..1f24dc0 --- /dev/null +++ b/internal/storage/config.go @@ -0,0 +1,8 @@ +package storage + +import "time" + +type Config struct { + URL string + LinksTTL time.Duration +} diff --git a/internal/storage/module.go b/internal/storage/module.go new file mode 100644 index 0000000..4ee2983 --- /dev/null +++ b/internal/storage/module.go @@ -0,0 +1,29 @@ +package storage + +import ( + "fmt" + "net/url" + + "github.com/bit-issues/backend/pkg/miniofx" + "github.com/go-core-fx/logger" + "go.uber.org/fx" +) + +func Module() fx.Option { + return fx.Module( + "storage", + logger.WithNamedLogger("storage"), + fx.Provide(func(c Config) (miniofx.Config, error) { + u, err := url.Parse(c.URL) + if err != nil { + return miniofx.Config{}, fmt.Errorf("failed to parse storage URL: %w", err) + } + + return miniofx.Config{ + Endpoint: u.Query().Get("endpoint"), + Region: u.Query().Get("region"), + }, nil + }), + fx.Provide(NewService), + ) +} diff --git a/internal/storage/service.go b/internal/storage/service.go new file mode 100644 index 0000000..61bae32 --- /dev/null +++ b/internal/storage/service.go @@ -0,0 +1,83 @@ +package storage + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" + + "github.com/minio/minio-go/v7" + "go.uber.org/zap" +) + +type Service struct { + bucketName string + keyPrefix string + presignExpiry time.Duration + + client *minio.Client + + logger *zap.Logger +} + +func NewService(config Config, client *minio.Client, logger *zap.Logger) (*Service, error) { + u, err := url.Parse(config.URL) + if err != nil { + return nil, fmt.Errorf("failed to parse storage URL: %w", err) + } + + return &Service{ + bucketName: u.Hostname(), + keyPrefix: strings.TrimPrefix(u.Path, "/"), + presignExpiry: config.LinksTTL, + + client: client, + + logger: logger, + }, nil +} + +func (s *Service) PresignedPutObject(ctx context.Context, key string) (string, error) { + u, err := s.client.PresignedPutObject( + ctx, + s.bucketName, + s.objectKey(key), + s.presignExpiry, + ) + if err != nil { + return "", fmt.Errorf("failed to create presigned url: %w", err) + } + + return u.String(), nil +} + +func (s *Service) PresignedGetObject(ctx context.Context, key string) (string, error) { + u, err := s.client.PresignedGetObject( + ctx, + s.bucketName, + s.objectKey(key), + s.presignExpiry, + nil, + ) + if err != nil { + return "", fmt.Errorf("failed to create presigned url: %w", err) + } + + return u.String(), nil +} + +func (s *Service) Delete(ctx context.Context, key string) error { + if err := s.client.RemoveObject(ctx, s.bucketName, s.objectKey(key), minio.RemoveObjectOptions{}); err != nil { + return fmt.Errorf("failed to delete object: %w", err) + } + + return nil +} + +func (s *Service) objectKey(key string) string { + if s.keyPrefix == "" { + return key + } + return strings.TrimSuffix(s.keyPrefix, "/") + "/" + strings.TrimPrefix(key, "/") +} diff --git a/internal/tasks/repository.go b/internal/tasks/repository.go index ed15be7..5f89172 100644 --- a/internal/tasks/repository.go +++ b/internal/tasks/repository.go @@ -67,10 +67,18 @@ func (r *Repository) Create(ctx context.Context, input TaskInput) (*Task, error) return task, nil } +func (r *Repository) Exists(ctx context.Context, id int64) (bool, error) { + ok, err := r.db.NewSelect().Model((*taskModel)(nil)).Where("id = ?", id).Exists(ctx) + if err != nil { + return false, fmt.Errorf("failed to check task existence: %w", err) + } + return ok, nil +} + // GetByID retrieves a task by its global database ID. func (r *Repository) GetByID(ctx context.Context, id int64) (*Task, error) { var model taskModel - if err := r.db.NewSelect().Model(&model).Where("id = ?", id).Where("deleted_at IS NULL").Scan(ctx); err != nil { + if err := r.db.NewSelect().Model(&model).Where("id = ?", id).Scan(ctx); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -191,10 +199,9 @@ func (r *Repository) Update(ctx context.Context, id int64, update TaskUpdate) er return nil } -// Delete soft-deletes a task by setting its deleted_at timestamp. +// Delete soft-deletes a task. func (r *Repository) Delete(ctx context.Context, id int64) error { - result, err := r.db.NewUpdate().Model((*taskModel)(nil)). - Set("deleted_at = NOW()"). + result, err := r.db.NewDelete().Model((*taskModel)(nil)). Where("id = ?", id). Exec(ctx) if err != nil { diff --git a/internal/tasks/service.go b/internal/tasks/service.go index c8968b9..6c44fc1 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -49,6 +49,11 @@ func (s *Service) Create(ctx context.Context, input TaskInput) (*Task, error) { return s.tasks.Create(ctx, input) } +// Exists checks if a task with the given ID exists. +func (s *Service) Exists(ctx context.Context, id int64) (bool, error) { + return s.tasks.Exists(ctx, id) +} + // GetByID retrieves a task by its global ID. func (s *Service) GetByID(ctx context.Context, id int64) (*Task, error) { return s.tasks.GetByID(ctx, id) diff --git a/pkg/miniofx/client.go b/pkg/miniofx/client.go new file mode 100644 index 0000000..1d63381 --- /dev/null +++ b/pkg/miniofx/client.go @@ -0,0 +1,23 @@ +package miniofx + +import ( + "fmt" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +func NewClient(config Config) (*minio.Client, error) { + endpoint := config.Endpoint + + client, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewEnvAWS(), + Secure: true, + Region: config.Region, + }) + if err != nil { + return nil, fmt.Errorf("failed to create minio client: %w", err) + } + + return client, nil +} diff --git a/pkg/miniofx/config.go b/pkg/miniofx/config.go new file mode 100644 index 0000000..8f32c99 --- /dev/null +++ b/pkg/miniofx/config.go @@ -0,0 +1,6 @@ +package miniofx + +type Config struct { + Endpoint string + Region string +} diff --git a/pkg/miniofx/module.go b/pkg/miniofx/module.go new file mode 100644 index 0000000..1c760e9 --- /dev/null +++ b/pkg/miniofx/module.go @@ -0,0 +1,14 @@ +package miniofx + +import ( + "github.com/go-core-fx/logger" + "go.uber.org/fx" +) + +func Module() fx.Option { + return fx.Module( + "minio", + logger.WithNamedLogger("minio"), + fx.Provide(NewClient), + ) +} diff --git a/requests.http b/requests.http index 28466d7..cb78f3c 100644 --- a/requests.http +++ b/requests.http @@ -313,3 +313,52 @@ Authorization: Bearer {{accessToken}} # Get task with comments (comments are included in task details) GET {{baseURL}}/tasks/{{taskId}} Authorization: Bearer {{accessToken}} + +### +# Attachments API + +### +# Initialize an attachment upload +# @name initAttachmentUpload +@attachmentId={{initAttachmentUpload.response.body.$.id}} +@uploadURL={{initAttachmentUpload.response.body.$.upload_url}} +POST {{baseURL}}/tasks/{{taskId}}/attachments +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "file_name": "LICENSE", + "size_bytes": 11358 +} + +### +# Upload file using presigned URL +# Note: Use the upload_url from the initAttachmentUpload response +PUT {{uploadURL}} +Content-Type: application/octet-stream + +< ./LICENSE + +### +# Confirm attachment upload +# Required after uploading - commits the attachment +PUT {{baseURL}}/tasks/{{taskId}}/attachments/{{attachmentId}}/confirm +Authorization: Bearer {{accessToken}} + +### +# Get attachment download URL +# @name getDownloadURL +@downloadURL={{getDownloadURL.response.body.$.download_url}} +GET {{baseURL}}/tasks/{{taskId}}/attachments/{{attachmentId}}/download +Authorization: Bearer {{accessToken}} + +### +# Download file using presigned URL +# Note: Use the download_url from the download response +GET {{downloadURL}} + +### +# Delete an attachment +# Requires attachment ID from initAttachmentUpload response +DELETE {{baseURL}}/tasks/{{taskId}}/attachments/{{attachmentId}} +Authorization: Bearer {{accessToken}}