Skip to content

Commit 3178555

Browse files
committed
Implement attachments module with direct S3 upload flow
1 parent 7f79126 commit 3178555

19 files changed

Lines changed: 884 additions & 10 deletions

File tree

go.mod

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ require (
3939
github.com/beorn7/perks v1.0.1 // indirect
4040
github.com/cespare/xxhash/v2 v2.3.0 // indirect
4141
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
42+
github.com/dustin/go-humanize v1.0.1 // indirect
4243
github.com/fsnotify/fsnotify v1.9.0 // indirect
4344
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
4445
github.com/go-core-fx/fxutil v0.0.0-20251027105421-acea37162eb9 // indirect
46+
github.com/go-ini/ini v1.67.0 // indirect
4547
github.com/go-openapi/jsonpointer v0.19.5 // indirect
4648
github.com/go-openapi/jsonreference v0.19.6 // indirect
4749
github.com/go-openapi/spec v0.20.4 // indirect
@@ -56,6 +58,8 @@ require (
5658
github.com/joho/godotenv v1.5.1 // indirect
5759
github.com/josharian/intern v1.0.0 // indirect
5860
github.com/klauspost/compress v1.18.5 // indirect
61+
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
62+
github.com/klauspost/crc32 v1.3.0 // indirect
5963
github.com/knadh/koanf/maps v0.1.2 // indirect
6064
github.com/knadh/koanf/parsers/dotenv v1.1.0 // indirect
6165
github.com/knadh/koanf/parsers/yaml v1.1.0 // indirect
@@ -68,16 +72,22 @@ require (
6872
github.com/mattn/go-isatty v0.0.20 // indirect
6973
github.com/mattn/go-runewidth v0.0.21 // indirect
7074
github.com/mfridman/interpolate v0.0.2 // indirect
75+
github.com/minio/crc64nvme v1.1.1 // indirect
76+
github.com/minio/md5-simd v1.1.2 // indirect
77+
github.com/minio/minio-go/v7 v7.0.100 // indirect
7178
github.com/mitchellh/copystructure v1.2.0 // indirect
7279
github.com/mitchellh/reflectwalk v1.0.2 // indirect
7380
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
81+
github.com/philhofer/fwd v1.2.0 // indirect
7482
github.com/prometheus/client_model v0.6.2 // indirect
7583
github.com/prometheus/common v0.67.5 // indirect
7684
github.com/prometheus/procfs v0.19.2 // indirect
7785
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
7886
github.com/rogpeppe/go-internal v1.14.1 // indirect
87+
github.com/rs/xid v1.6.0 // indirect
7988
github.com/sethvargo/go-retry v0.3.0 // indirect
8089
github.com/swaggo/files/v2 v2.0.2 // indirect
90+
github.com/tinylib/msgp v1.6.1 // indirect
8191
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
8292
github.com/valyala/bytebufferpool v1.0.0 // indirect
8393
github.com/valyala/fasthttp v1.69.0 // indirect

go.sum

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ github.com/go-core-fx/sqlfx v0.1.0 h1:jWK4oUqvoJhcbLORWZXeRTzjstY6APter1fKsRYJbN
4646
github.com/go-core-fx/sqlfx v0.1.0/go.mod h1:D8fFoIeCUGthMN2nOeYIqs+yYH5CEDJBNJeMP4+Usk8=
4747
github.com/go-core-fx/validatorfx v0.0.2 h1:J+POBsdqyT2Hd3TZHgJ+0eBtCPHhHff8TJLcGowQ0Ew=
4848
github.com/go-core-fx/validatorfx v0.0.2/go.mod h1:1VtQoOEzBo3oapXgRFA65H+zSKvmCvb6yfAcdwpqSlE=
49+
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
50+
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
4951
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
5052
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
5153
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
9698
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
9799
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
98100
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
101+
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
102+
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
103+
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
104+
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
105+
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
99106
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
100107
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
101108
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
131138
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
132139
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
133140
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
141+
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
142+
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
143+
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
144+
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
145+
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
146+
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
134147
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
135148
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
136149
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
140153
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
141154
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
142155
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
156+
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
157+
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
143158
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
144159
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
145160
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
158173
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
159174
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
160175
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
176+
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
177+
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
161178
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
162179
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
163180
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
171188
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
172189
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
173190
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
191+
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
192+
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
174193
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
175194
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
176195
github.com/uptrace/bun v1.2.18 h1:3HnRcMfS6OBPMG1eSOzlbFJ/X/AyMEJb7rMxE6VQvDU=

internal/app.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package internal
33
import (
44
"context"
55

6+
"github.com/bit-issues/backend/internal/attachments"
67
"github.com/bit-issues/backend/internal/comments"
78
"github.com/bit-issues/backend/internal/config"
89
"github.com/bit-issues/backend/internal/db"
910
"github.com/bit-issues/backend/internal/jwt"
1011
"github.com/bit-issues/backend/internal/projects"
12+
"github.com/bit-issues/backend/internal/s3"
1113
"github.com/bit-issues/backend/internal/server"
1214
"github.com/bit-issues/backend/internal/tasks"
1315
"github.com/bit-issues/backend/internal/users"
@@ -55,6 +57,8 @@ func Run(version healthfx.Version) {
5557
users.Module(),
5658
projects.Module(),
5759
tasks.Module(),
60+
s3.Module(),
61+
attachments.Module(),
5862
comments.Module(),
5963
//
6064
fx.Invoke(func(lc fx.Lifecycle, logger *zap.Logger) {

internal/attachments/doc.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Package attachments provides domain, repository and service logic for task attachments.
2+
package attachments

internal/attachments/domain.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package attachments
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"strings"
7+
"time"
8+
)
9+
10+
const DefaultMaxFileSizeBytes uint64 = 104857600
11+
12+
type AttachmentStatus string
13+
14+
const (
15+
StatusPending AttachmentStatus = "pending"
16+
StatusUploaded AttachmentStatus = "uploaded"
17+
)
18+
19+
type Attachment struct {
20+
ID int64
21+
TaskID int64
22+
FileName string
23+
StorageKey string
24+
SizeBytes uint64
25+
Status AttachmentStatus
26+
UploadedBy int64
27+
UploadedAt time.Time
28+
DeletedAt *time.Time
29+
TaskAuthorID int64
30+
}
31+
32+
type AttachmentInput struct {
33+
TaskID int64
34+
FileName string
35+
SizeBytes uint64
36+
UploaderID int64
37+
}
38+
39+
func (i AttachmentInput) Validate(maxFileSize uint64) error {
40+
if i.TaskID <= 0 {
41+
return fmt.Errorf("%w: task_id must be positive", ErrValidationFailed)
42+
}
43+
44+
if i.UploaderID <= 0 {
45+
return fmt.Errorf("%w: uploader_id must be positive", ErrValidationFailed)
46+
}
47+
48+
fileName := sanitizeFileName(i.FileName)
49+
if fileName == "" {
50+
return fmt.Errorf("%w: file_name is required", ErrValidationFailed)
51+
}
52+
53+
if len(fileName) > 255 {
54+
return fmt.Errorf("%w: file_name too long (max 255 characters)", ErrValidationFailed)
55+
}
56+
57+
if i.SizeBytes == 0 {
58+
return fmt.Errorf("%w: size_bytes must be positive", ErrValidationFailed)
59+
}
60+
61+
if i.SizeBytes > maxFileSize {
62+
return ErrFileTooLarge
63+
}
64+
65+
return nil
66+
}
67+
68+
func sanitizeFileName(name string) string {
69+
trimmed := strings.TrimSpace(name)
70+
if trimmed == "" {
71+
return ""
72+
}
73+
74+
base := filepath.Base(trimmed)
75+
if base == "." || base == string(filepath.Separator) {
76+
return ""
77+
}
78+
79+
return strings.ReplaceAll(base, "\\", "")
80+
}

internal/attachments/errors.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package attachments
2+
3+
import "errors"
4+
5+
var (
6+
ErrNotFound = errors.New("attachment not found")
7+
ErrValidationFailed = errors.New("validation failed")
8+
ErrTaskNotFound = errors.New("task not found")
9+
ErrUnauthorized = errors.New("unauthorized")
10+
ErrFileTooLarge = errors.New("file exceeds maximum allowed size")
11+
ErrNotUploaded = errors.New("attachment is not yet uploaded")
12+
ErrAlreadyConfirmed = errors.New("attachment already confirmed")
13+
)

internal/attachments/models.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package attachments
2+
3+
import (
4+
"time"
5+
6+
"github.com/uptrace/bun"
7+
)
8+
9+
type attachmentModel struct {
10+
bun.BaseModel `bun:"table:attachments,alias:a"`
11+
12+
ID int64 `bun:"id,pk,autoincrement"`
13+
TaskID int64 `bun:"task_id,notnull"`
14+
FileName string `bun:"file_name,notnull"`
15+
StorageKey string `bun:"s3_key,notnull"`
16+
SizeBytes uint64 `bun:"size_bytes,notnull"`
17+
Status string `bun:"status,notnull"`
18+
UploadedBy int64 `bun:"uploaded_by,notnull"`
19+
UploadedAt time.Time `bun:"uploaded_at,notnull"`
20+
DeletedAt *time.Time `bun:"deleted_at,soft_delete,nullzero"`
21+
TaskAuthorID int64 `bun:"task_author_id,scanonly"`
22+
}
23+
24+
func newAttachmentModel(input AttachmentInput, storageKey string) *attachmentModel {
25+
return &attachmentModel{
26+
TaskID: input.TaskID,
27+
FileName: sanitizeFileName(input.FileName),
28+
StorageKey: storageKey,
29+
SizeBytes: input.SizeBytes,
30+
Status: string(StatusPending),
31+
UploadedBy: input.UploaderID,
32+
UploadedAt: time.Now().UTC(),
33+
DeletedAt: nil,
34+
}
35+
}
36+
37+
func (m *attachmentModel) toDomain() *Attachment {
38+
if m == nil {
39+
return nil
40+
}
41+
42+
return &Attachment{
43+
ID: m.ID,
44+
TaskID: m.TaskID,
45+
FileName: m.FileName,
46+
StorageKey: m.StorageKey,
47+
SizeBytes: m.SizeBytes,
48+
Status: AttachmentStatus(m.Status),
49+
UploadedBy: m.UploadedBy,
50+
UploadedAt: m.UploadedAt,
51+
DeletedAt: m.DeletedAt,
52+
TaskAuthorID: m.TaskAuthorID,
53+
}
54+
}

internal/attachments/module.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package attachments
2+
3+
import (
4+
"github.com/bit-issues/backend/internal/config"
5+
"github.com/go-core-fx/logger"
6+
"go.uber.org/fx"
7+
)
8+
9+
func Module() fx.Option {
10+
return fx.Module(
11+
"attachments",
12+
logger.WithNamedLogger("attachments"),
13+
fx.Provide(NewRepository, fx.Private),
14+
fx.Provide(func(cfg config.Config) Config {
15+
return Config{
16+
MaxFileSizeBytes: cfg.S3.MaxFileSizeBytes,
17+
UploadURLTTL: cfg.S3.UploadURLTTL,
18+
DownloadURLTTL: cfg.S3.DownloadURLTTL,
19+
BucketName: cfg.S3.BucketName,
20+
}
21+
}),
22+
fx.Provide(NewService),
23+
)
24+
}

0 commit comments

Comments
 (0)