diff --git a/docs/api/common.md b/docs/api/common.md deleted file mode 100644 index e9edc96..0000000 --- a/docs/api/common.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -layout: default -title: 通用 -parent: API -nav_order: 1 ---- - -# 通用(Common) - -## 目录 -- [文件上传](#文件上传) - -## 文件上传 -请求类型需为 multipart/form-data -``` -PATCH /member/avatar -``` -#### 参数 - -| 名称 | 类型 | in | 描述 | -| ------------- | ------ | ------ | ------------------------- | -| Authorization | string | header | | -| file | File | form | 需上传的文件(小于 10mb) | - -#### 示例 - -##### 请求 -``` -PATCH /upload -``` - -##### 响应 - -``` -{ - url:"https://sunday-res.oss-cn-hangzhou.aliyuncs.com/weekend/1662184635.jpg" -} -``` - -#### Http 状态码 - -| HTTP Status Code | 描述 | -| ---------------- | -------------------- | -| 200 | OK | -| 422 | Unprocessable Entity | diff --git a/migrations/000008_add_event_photo.down.sql b/migrations/000008_add_event_photo.down.sql new file mode 100644 index 0000000..b1d575a --- /dev/null +++ b/migrations/000008_add_event_photo.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.event_photo; diff --git a/migrations/000008_add_event_photo.up.sql b/migrations/000008_add_event_photo.up.sql new file mode 100644 index 0000000..437c7c0 --- /dev/null +++ b/migrations/000008_add_event_photo.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS public.event_photo ( + photo_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + event_id bigint NOT NULL, + object_key character varying(255) NOT NULL, + member_id character(10) NOT NULL, + gmt_create timestamp with time zone NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_event_photo_event_id ON public.event_photo (event_id); diff --git a/model/dto/common.go b/model/dto/common.go deleted file mode 100644 index db525d3..0000000 --- a/model/dto/common.go +++ /dev/null @@ -1,5 +0,0 @@ -package dto - -type FileUploadResponse struct { - Url string `json:"url"` -} diff --git a/model/event.go b/model/event.go index 7035046..b6db6ba 100644 --- a/model/event.go +++ b/model/event.go @@ -25,6 +25,7 @@ type Event struct { Status string `json:"status"` Size string `json:"size"` Logs []EventLog `json:"logs"` + Photos []EventPhoto `json:"photos" db:"-"` GmtCreate string `json:"gmtCreate" db:"gmt_create"` GmtModified string `json:"gmtModified" db:"gmt_modified"` } @@ -57,6 +58,15 @@ type EventLog struct { GmtCreate string `json:"gmtCreate" db:"gmt_create"` } +type EventPhoto struct { + PhotoId int64 `json:"photoId" db:"photo_id"` + EventId int64 `json:"-" db:"event_id"` + ObjectKey string `json:"-" db:"object_key"` + Url string `json:"url" db:"-"` + MemberId string `json:"memberId" db:"member_id"` + GmtCreate string `json:"gmtCreate" db:"gmt_create"` +} + type EventActionRelation struct { EventLogId int64 `json:"event_log_id"` EventActionId int64 `json:"event_action_id"` diff --git a/repo/event.go b/repo/event.go index 530bfba..3d3ea01 100644 --- a/repo/event.go +++ b/repo/event.go @@ -34,6 +34,10 @@ func getLogStatement() squirrel.SelectBuilder { return sq.Select(EventLogFields...).From("event_log_view") } +func getPhotoStatement() squirrel.SelectBuilder { + return sq.Select("photo_id", "event_id", "object_key", "member_id", "gmt_create").From("event_photo").OrderBy("photo_id") +} + /* when a struct contains sub struct, the struct's field has the db tag of "struct'dbTag.subStruct.dbTag" for example: the db tag here for event is event, the db tag for mode.Event has field "EventId" with db tag "event_id" @@ -343,3 +347,43 @@ func GetEventClientId(eventId int64) (int64, error) { } return clientId, nil } + +func GetEventPhotos(eventId int64) ([]model.EventPhoto, error) { + sql, args, _ := getPhotoStatement().Where(squirrel.Eq{"event_id": eventId}).ToSql() + photos := []model.EventPhoto{} + err := db.Select(&photos, sql, args...) + return photos, err +} + +func CreateEventPhotos(eventId int64, photos []model.EventPhoto, check func(status string, count int) error) error { + tx, err := db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec("SELECT 1 FROM event WHERE event_id = $1 FOR UPDATE", eventId); err != nil { + return err + } + var current struct { + Status string `db:"status"` + Count int `db:"count"` + } + if err := tx.Get(¤t, "SELECT status, (SELECT count(*) FROM event_photo WHERE event_id = $1) AS count FROM event_view WHERE event_id = $1", eventId); err != nil { + return err + } + if err := check(current.Status, current.Count); err != nil { + return err + } + insert := sq.Insert("event_photo").Columns("event_id", "object_key", "member_id", "gmt_create") + for _, p := range photos { + insert = insert.Values(eventId, p.ObjectKey, p.MemberId, p.GmtCreate) + } + sql, args, err := insert.ToSql() + if err != nil { + return err + } + if _, err := tx.Exec(sql, args...); err != nil { + return err + } + return tx.Commit() +} diff --git a/repo/event_photo_internal_test.go b/repo/event_photo_internal_test.go new file mode 100644 index 0000000..adc8747 --- /dev/null +++ b/repo/event_photo_internal_test.go @@ -0,0 +1,60 @@ +package repo + +import ( + "errors" + "os" + "sync" + "testing" + "time" + + "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + "github.com/nbtca/saturday/model" +) + +func TestCreateEventPhotosChecksCapacityUnderLock(t *testing.T) { + dsn := os.Getenv("TEST_DB_DATASOURCE") + if dsn == "" { + t.Skip("TEST_DB_DATASOURCE is not set") + } + db = sqlx.MustConnect("postgres", dsn) + sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar) + + var eventId int64 + if err := db.Get(&eventId, `INSERT INTO event (client_id, gmt_create, gmt_modified) VALUES (1, now(), now()) RETURNING event_id`); err != nil { + t.Fatal(err) + } + db.MustExec(`INSERT INTO event_event_status_relation (event_id, event_status_id) SELECT $1, event_status_id FROM event_status WHERE status = 'accepted'`, eventId) + t.Cleanup(func() { + db.MustExec(`DELETE FROM event_photo WHERE event_id = $1`, eventId) + db.MustExec(`DELETE FROM event_event_status_relation WHERE event_id = $1`, eventId) + db.MustExec(`DELETE FROM event WHERE event_id = $1`, eventId) + }) + + full := errors.New("full") + batch := make([]model.EventPhoto, 15) + for i := range batch { + batch[i] = model.EventPhoto{EventId: eventId, ObjectKey: "k", MemberId: "2333333333", GmtCreate: "2026-10-15T10:00:00+08:00"} + } + check := func(status string, count int) error { + time.Sleep(50 * time.Millisecond) + if status != "accepted" || count+len(batch) > 20 { + return full + } + return nil + } + + var wg sync.WaitGroup + errs := make([]error, 2) + for i := range errs { + wg.Go(func() { errs[i] = CreateEventPhotos(eventId, batch, check) }) + } + wg.Wait() + + var count int + db.Get(&count, `SELECT count(*) FROM event_photo WHERE event_id = $1`, eventId) + if count != 15 || (errs[0] == nil) == (errs[1] == nil) || !(errors.Is(errs[0], full) || errors.Is(errs[1], full)) { + t.Errorf("count=%d errs=%v", count, errs) + } +} diff --git a/router/common.go b/router/common.go deleted file mode 100644 index dc8d484..0000000 --- a/router/common.go +++ /dev/null @@ -1,20 +0,0 @@ -package router - -import ( - "context" - - "github.com/danielgtaylor/huma/v2" - "github.com/nbtca/saturday/model/dto" - "github.com/nbtca/saturday/util" -) - -type CommonRouter struct{} - -func (CommonRouter) Upload(ctx context.Context, input *UploadFileInput) (*util.CommonResponse[dto.FileUploadResponse], error) { - // TODO: Implement multipart file upload with Huma - // For now, this needs special handling since Huma's multipart support may require custom implementation - // This endpoint may need to remain as Gin for now until Huma multipart is properly implemented - return nil, huma.Error501NotImplemented("Upload endpoint migration pending - requires multipart support") -} - -var CommonRouterApp = CommonRouter{} diff --git a/router/event.go b/router/event.go index 1328e2c..d4ba737 100644 --- a/router/event.go +++ b/router/event.go @@ -3,6 +3,7 @@ package router import ( "bytes" "context" + "errors" "fmt" "time" @@ -63,10 +64,14 @@ func (er EventRouter) GetEventById(ctx context.Context, input *GetEventByIdInput return nil, err } - if event.MemberId != auth.ID && event.ClientId != clientId { + if !canViewEvent(event, auth, clientId) { return nil, huma.Error401Unauthorized("not authorized") } + if err := service.LoadPhotos(&event); err != nil { + return nil, huma.Error500InternalServerError(err.Error()) + } + return util.MakeCommonResponse(event), nil } @@ -232,6 +237,58 @@ func (EventRouter) AlterCommit(ctx context.Context, input *AlterCommitEventInput return util.MakeCommonResponse(event), nil } +func (EventRouter) UploadPhotos(ctx context.Context, input *UploadEventPhotosInput) (*util.CommonResponse[model.Event], error) { + files := input.RawBody.File["photos"] + if len(files) == 0 { + return nil, huma.Error422UnprocessableEntity("at least one photo is required") + } + for _, f := range files { + if contentType := f.Header.Get("Content-Type"); contentType != "" && contentType != "image/jpeg" && contentType != "image/webp" { + return nil, huma.Error422UnprocessableEntity("expected webp or jpeg") + } + } + + auth, err := middleware.AuthenticateUser(input.Authorization, "member", "admin") + if err != nil { + return nil, err + } + + event, err := middleware.LoadEvent(input.EventId) + if err != nil { + return nil, err + } + + if event.Photos, err = repo.GetEventPhotos(event.EventId); err != nil { + return nil, huma.Error500InternalServerError(err.Error()) + } + + uploads := make([]service.PhotoUpload, len(files)) + for i, f := range files { + file, err := f.Open() + if err != nil { + return nil, huma.Error422UnprocessableEntity("failed to open photo") + } + defer file.Close() + uploads[i] = service.PhotoUpload{File: file, Size: f.Size} + } + if err := service.EventServiceApp.AddPhotos(&event, middleware.CreateIdentityFromAuth(auth), uploads); err != nil { + if errors.Is(err, service.ErrPhotoStorage) { + util.Logger.Error(err) + return nil, huma.Error500InternalServerError(service.ErrPhotoStorage.Error()) + } + return nil, huma.Error422UnprocessableEntity(err.Error()) + } + + event, err = middleware.LoadEvent(input.EventId) + if err != nil { + return nil, err + } + if err := service.LoadPhotos(&event); err != nil { + return nil, huma.Error500InternalServerError(err.Error()) + } + return util.MakeCommonResponse(event), nil +} + func (EventRouter) RejectCommit(ctx context.Context, input *RejectCommitEventInput) (*util.CommonResponse[model.Event], error) { auth, err := middleware.AuthenticateUser(input.Authorization, "admin") if err != nil { @@ -390,3 +447,7 @@ func (er EventRouter) Cancel(ctx context.Context, input *CancelClientEventInput) } var EventRouterApp = EventRouter{} + +func canViewEvent(event model.Event, auth *middleware.AuthContext, clientId int64) bool { + return auth.Role == "admin" || (auth.ID != "" && event.MemberId == auth.ID) || event.ClientId == clientId +} diff --git a/router/event_internal_test.go b/router/event_internal_test.go new file mode 100644 index 0000000..5539bc3 --- /dev/null +++ b/router/event_internal_test.go @@ -0,0 +1,34 @@ +package router + +import ( + "testing" + + "github.com/nbtca/saturday/middleware" + "github.com/nbtca/saturday/model" +) + +func TestCanViewEvent(t *testing.T) { + assigned := model.Event{MemberId: "2333333333", ClientId: 7} + open := model.Event{MemberId: "", ClientId: 7} + tests := []struct { + name string + event model.Event + auth middleware.AuthContext + clientId int64 + want bool + }{ + {"assigned member", assigned, middleware.AuthContext{ID: "2333333333", Role: "member"}, 1, true}, + {"other member", assigned, middleware.AuthContext{ID: "1111111111", Role: "member"}, 1, false}, + {"admin", assigned, middleware.AuthContext{ID: "1111111111", Role: "admin"}, 1, true}, + {"owning client", assigned, middleware.AuthContext{}, 7, true}, + {"other client on open event", open, middleware.AuthContext{}, 8, false}, + {"other client on assigned event", assigned, middleware.AuthContext{}, 8, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := canViewEvent(tt.event, &tt.auth, tt.clientId); got != tt.want { + t.Errorf("canViewEvent() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/router/input_types.go b/router/input_types.go index bf69959..8385551 100644 --- a/router/input_types.go +++ b/router/input_types.go @@ -1,6 +1,8 @@ package router import ( + "mime/multipart" + "github.com/nbtca/saturday/model" "github.com/nbtca/saturday/model/dto" ) @@ -134,6 +136,13 @@ type AlterCommitEventInput struct { } } +type UploadEventPhotosInput struct { + MemberAuthInput + EventPathInput + // Keep file headers unopened until the handler can own and close each file. + RawBody multipart.Form +} + type RejectCommitEventInput struct { AdminAuthInput EventPathInput @@ -201,13 +210,6 @@ type CreateTokenViaLogtoInput struct { ClientAuthInput } -// Upload endpoint input - -type UploadFileInput struct { - AuthenticatedInput - // File upload will be handled specially -} - // Webhook inputs (these may stay as Gin handlers) type GithubWebhookInput struct { diff --git a/router/main.go b/router/main.go index 9d092a1..ef97050 100644 --- a/router/main.go +++ b/router/main.go @@ -20,10 +20,24 @@ type PingResponse struct { Pong string `json:"message" example:"ping" doc:"Ping message"` } +const maxRequestBytes = 25 << 20 + func SetupRouter() *chi.Mux { // Create Chi router router := chi.NewRouter() + router.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes) + defer func() { + if r.MultipartForm != nil { + r.MultipartForm.RemoveAll() + } + }() + next.ServeHTTP(w, r) + }) + }) + // Add CORS middleware router.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"https://repair.nbtca.space", "https://nbtca.space", "http://localhost:5173"}, @@ -308,6 +322,31 @@ func SetupRouter() *chi.Mux { Tags: []string{"Event", "Member"}, }, EventRouterApp.AlterCommit) + huma.Register(api, huma.Operation{ + OperationID: "upload-event-photos", + Method: http.MethodPost, + Path: "/member/events/{EventId}/photos", + Summary: "Upload event photos", + Tags: []string{"Event", "Member"}, + RequestBody: &huma.RequestBody{ + Required: true, + Content: map[string]*huma.MediaType{ + "multipart/form-data": { + Schema: &huma.Schema{ + Type: "object", + Required: []string{"photos"}, + Properties: map[string]*huma.Schema{ + "photos": {Type: "array", Items: &huma.Schema{Type: "string", Format: "binary", ContentEncoding: "binary"}}, + }, + }, + Encoding: map[string]*huma.Encoding{ + "photos": {ContentType: "image/webp,image/jpeg"}, + }, + }, + }, + }, + }, EventRouterApp.UploadPhotos) + // Admin authenticated endpoints huma.Register(api, huma.Operation{ OperationID: "create-members", @@ -365,18 +404,5 @@ func SetupRouter() *chi.Mux { Tags: []string{"Event", "Admin"}, }, EventRouterApp.Close) - // TODO: Upload endpoint - needs special multipart handling - // For now, keep as commented until Huma multipart is implemented - /* - huma.Register(api, huma.Operation{ - OperationID: "upload-file", - Method: http.MethodPost, - Path: "/upload", - Summary: "Upload file", - Tags: []string{"Common", "Private"}, - Middlewares: huma.Middlewares{middleware.HumaAuth("member", "admin", "client")}, - }, CommonRouterApp.Upload) - */ - return router } diff --git a/router/main_internal_test.go b/router/main_internal_test.go new file mode 100644 index 0000000..6e0b503 --- /dev/null +++ b/router/main_internal_test.go @@ -0,0 +1,31 @@ +package router + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "strings" + "testing" +) + +func TestOversizedUploadIsRejectedBeforeAuth(t *testing.T) { + body := &bytes.Buffer{} + form := multipart.NewWriter(body) + part, _ := form.CreatePart(textproto.MIMEHeader{ + "Content-Disposition": {`form-data; name="photos"; filename="a.jpg"`}, + "Content-Type": {"image/jpeg"}, + }) + part.Write(bytes.Repeat([]byte{0xff}, maxRequestBytes+1)) + form.Close() + + req := httptest.NewRequest(http.MethodPost, "/member/events/1/photos", body) + req.Header.Set("Content-Type", form.FormDataContentType()) + res := httptest.NewRecorder() + SetupRouter().ServeHTTP(res, req) + + if res.Code == http.StatusUnauthorized || !strings.Contains(res.Body.String(), "too large") { + t.Errorf("status=%d body=%s", res.Code, res.Body.String()) + } +} diff --git a/router/upload_cleanup_unix_test.go b/router/upload_cleanup_unix_test.go new file mode 100644 index 0000000..66bc6ca --- /dev/null +++ b/router/upload_cleanup_unix_test.go @@ -0,0 +1,91 @@ +//go:build darwin || linux + +package router + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "os" + "runtime" + "runtime/debug" + "testing" + + "golang.org/x/sys/unix" +) + +func countUploadTestDescriptors() int { + count := 0 + for fd := 0; fd < 4096; fd++ { + if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); err == nil { + count++ + } + } + return count +} + +func TestRejectedUploadsReleaseResourcesWithoutGC(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("TMPDIR", tempDir) + runtime.GC() + previousGC := debug.SetGCPercent(-1) + defer debug.SetGCPercent(previousGC) + router := SetupRouter() + + for _, tc := range []struct { + name string + path string + contentType string + wantStatus int + }{ + {"unauthorized", "/member/events/1/photos", "image/jpeg", http.StatusUnauthorized}, + {"invalid path", "/member/events/invalid/photos", "image/jpeg", http.StatusUnprocessableEntity}, + {"invalid media type", "/member/events/1/photos", "image/png", http.StatusUnprocessableEntity}, + } { + t.Run(tc.name, func(t *testing.T) { + var body bytes.Buffer + form := multipart.NewWriter(&body) + part, err := form.CreatePart(textproto.MIMEHeader{ + "Content-Disposition": {`form-data; name="photos"; filename="photo.jpg"`}, + "Content-Type": {tc.contentType}, + }) + if err != nil { + t.Fatal(err) + } + // Exceed Huma's 8 KiB memory threshold so file handles are real OS resources. + if _, err := part.Write(bytes.Repeat([]byte{0xff}, 16<<10)); err != nil { + t.Fatal(err) + } + if err := form.Close(); err != nil { + t.Fatal(err) + } + before := countUploadTestDescriptors() + for i := 0; i < 10; i++ { + req := httptest.NewRequest(http.MethodPost, tc.path, bytes.NewReader(body.Bytes())) + req.Header.Set("Authorization", "invalid") + req.Header.Set("Content-Type", form.FormDataContentType()) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + // Also clean up on failure against an implementation lacking middleware cleanup. + if req.MultipartForm != nil { + t.Cleanup(func() { req.MultipartForm.RemoveAll() }) + } + if res.Code != tc.wantStatus { + t.Fatalf("status=%d body=%s", res.Code, res.Body.String()) + } + } + if after := countUploadTestDescriptors(); after != before { + t.Errorf("open file descriptors: before=%d after=%d", before, after) + } + entries, err := os.ReadDir(tempDir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Errorf("retained %d multipart temporary files", len(entries)) + } + }) + } +} diff --git a/service/event.go b/service/event.go index 3e70efc..e0905d9 100644 --- a/service/event.go +++ b/service/event.go @@ -4,10 +4,13 @@ import ( "bytes" "database/sql" "encoding/json" + "errors" "fmt" + "io" "log" "net/http" "net/url" + "slices" "time" "github.com/google/go-github/v69/github" @@ -892,6 +895,119 @@ func (service EventService) Act(event *model.Event, identity model.Identity, act return nil } +type PhotoUpload struct { + File io.ReadSeeker + Size int64 +} + +var ErrPhotoStorage = errors.New("failed to store photos") + +const ( + maxPhotoSize = 5 << 20 + maxPhotosPerEvent = 20 +) + +func checkPhotoCapacity(status string, count, adding int) error { + if status != util.Accepted && status != util.Committed { + return errors.New("photos can only be added to accepted or committed events") + } + if count+adding > maxPhotosPerEvent { + return fmt.Errorf("an event can have at most %d photos", maxPhotosPerEvent) + } + return nil +} + +func ValidatePhotoUpload(event model.Event, identity model.Identity, files []PhotoUpload) ([]string, error) { + if identity.Role != "admin" && (identity.Id == "" || event.MemberId != identity.Id) { + return nil, errors.New("only the assigned member or an admin can add photos") + } + if len(files) == 0 { + return nil, errors.New("no photos") + } + if err := checkPhotoCapacity(event.Status, len(event.Photos), len(files)); err != nil { + return nil, err + } + types := make([]string, len(files)) + head := make([]byte, 512) + for i, f := range files { + if f.Size > maxPhotoSize { + return nil, fmt.Errorf("photo %d exceeds 5 MB", i+1) + } + n, err := io.ReadFull(f.File, head) + if err != nil && err != io.ErrUnexpectedEOF { + return nil, err + } + if _, err := f.File.Seek(0, io.SeekStart); err != nil { + return nil, err + } + types[i] = http.DetectContentType(head[:n]) + if !slices.Contains([]string{"image/webp", "image/jpeg"}, types[i]) { + return nil, fmt.Errorf("photo %d is %s, expected webp or jpeg", i+1, types[i]) + } + } + return types, nil +} + +func (service EventService) AddPhotos(event *model.Event, identity model.Identity, files []PhotoUpload) error { + types, err := ValidatePhotoUpload(*event, identity, files) + if err != nil { + return err + } + photos := make([]model.EventPhoto, 0, len(files)) + removeUploaded := func() { + keys := make([]string, len(photos)) + for i, p := range photos { + keys[i] = p.ObjectKey + } + if len(keys) > 0 { + if err := util.Photos.Delete(keys...); err != nil { + util.Logger.Error("delete orphan photos failed: ", err) + } + } + } + for i, f := range files { + key := util.PhotoKey(event.EventId, types[i]) + if err := util.Photos.Put(key, types[i], f.File); err != nil { + removeUploaded() + return fmt.Errorf("%w: %w", ErrPhotoStorage, err) + } + photos = append(photos, model.EventPhoto{EventId: event.EventId, ObjectKey: key, MemberId: identity.Id, GmtCreate: util.GetDate()}) + } + var rejected error + err = repo.CreateEventPhotos(event.EventId, photos, func(status string, count int) error { + rejected = checkPhotoCapacity(status, count, len(photos)) + return rejected + }) + if err != nil { + removeUploaded() + if rejected != nil { + return rejected + } + return fmt.Errorf("%w: %w", ErrPhotoStorage, err) + } + return nil +} + +func LoadPhotos(event *model.Event) error { + photos, err := repo.GetEventPhotos(event.EventId) + if err != nil { + return err + } + event.Photos = photos + return SignPhotos(event) +} + +func SignPhotos(event *model.Event) error { + for i := range event.Photos { + url, err := util.Photos.SignURL(event.Photos[i].ObjectKey) + if err != nil { + return err + } + event.Photos[i].Url = url + } + return nil +} + func ValidateEventSize(size string) error { // size can be one of xs,s,m,l,xl if size != "xs" && size != "s" && size != "m" && size != "l" && size != "xl" { diff --git a/service/event_test.go b/service/event_test.go index 1ccaf1d..c5a03e5 100644 --- a/service/event_test.go +++ b/service/event_test.go @@ -1 +1,121 @@ package service_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/nbtca/saturday/model" + "github.com/nbtca/saturday/service" + "github.com/nbtca/saturday/util" +) + +var ( + jpegBytes = []byte("\xff\xd8\xff\xe0\x00\x10JFIF\x00") + webpBytes = []byte("RIFF\x00\x00\x00\x00WEBPVP8 ") + pngBytes = []byte("\x89PNG\r\n\x1a\n") +) + +func upload(b []byte) service.PhotoUpload { + return service.PhotoUpload{File: bytes.NewReader(b), Size: int64(len(b))} +} + +type memoryStore struct { + puts []string + deleted []string + failOn int +} + +func (m *memoryStore) Put(key, contentType string, r io.Reader) error { + if len(m.puts)+1 == m.failOn { + return errors.New("InvalidAccessKeyId") + } + m.puts = append(m.puts, key) + return nil +} + +func (m *memoryStore) Delete(keys ...string) error { + m.deleted = append(m.deleted, keys...) + return nil +} + +func (m *memoryStore) SignURL(key string) (string, error) { return "https://signed/" + key, nil } + +func TestValidatePhotoUpload(t *testing.T) { + member := model.Identity{Id: "2333333333", Role: "member"} + accepted := model.Event{EventId: 1, MemberId: "2333333333", Status: util.Accepted} + + types, err := service.ValidatePhotoUpload(accepted, member, []service.PhotoUpload{upload(jpegBytes), upload(webpBytes)}) + if err != nil || strings.Join(types, ",") != "image/jpeg,image/webp" { + t.Fatalf("valid upload: types=%v err=%v", types, err) + } + + committed := accepted + committed.Status = util.Committed + if _, err := service.ValidatePhotoUpload(committed, member, []service.PhotoUpload{upload(jpegBytes)}); err != nil { + t.Errorf("committed event should accept photos: %v", err) + } + + admin := model.Identity{Id: "1111111111", Role: "admin"} + if _, err := service.ValidatePhotoUpload(accepted, admin, []service.PhotoUpload{upload(jpegBytes)}); err != nil { + t.Errorf("admin should be allowed: %v", err) + } + + cases := []struct { + name string + event model.Event + identity model.Identity + files []service.PhotoUpload + }{ + {"other member", accepted, model.Identity{Id: "1111111111", Role: "member"}, []service.PhotoUpload{upload(jpegBytes)}}, + {"client", accepted, model.Identity{Role: "client"}, []service.PhotoUpload{upload(jpegBytes)}}, + {"open event", model.Event{MemberId: "2333333333", Status: util.Open}, member, []service.PhotoUpload{upload(jpegBytes)}}, + {"closed event", model.Event{MemberId: "2333333333", Status: util.Closed}, member, []service.PhotoUpload{upload(jpegBytes)}}, + {"no files", accepted, member, nil}, + {"png", accepted, member, []service.PhotoUpload{upload(jpegBytes), upload(pngBytes)}}, + {"too large", accepted, member, []service.PhotoUpload{{File: bytes.NewReader(jpegBytes), Size: 5<<20 + 1}}}, + {"over 20", model.Event{MemberId: "2333333333", Status: util.Accepted, Photos: make([]model.EventPhoto, 20)}, member, []service.PhotoUpload{upload(jpegBytes)}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := service.ValidatePhotoUpload(c.event, c.identity, c.files); err == nil { + t.Error("expected rejection") + } + }) + } +} + +func TestAddPhotosRejectsWithoutWriting(t *testing.T) { + store := &memoryStore{} + util.Photos = store + event := model.Event{EventId: 1, MemberId: "2333333333", Status: util.Accepted} + err := service.EventServiceApp.AddPhotos(&event, model.Identity{Id: "2333333333", Role: "member"}, + []service.PhotoUpload{upload(jpegBytes), upload(pngBytes)}) + if err == nil || len(store.puts) != 0 { + t.Errorf("err=%v puts=%v", err, store.puts) + } +} + +func TestAddPhotosReportsStorageFailure(t *testing.T) { + store := &memoryStore{failOn: 2} + util.Photos = store + event := model.Event{EventId: 1, MemberId: "2333333333", Status: util.Accepted} + err := service.EventServiceApp.AddPhotos(&event, model.Identity{Id: "2333333333", Role: "member"}, + []service.PhotoUpload{upload(jpegBytes), upload(webpBytes)}) + if !errors.Is(err, service.ErrPhotoStorage) { + t.Errorf("err=%v, want ErrPhotoStorage", err) + } + if len(store.puts) != 1 || strings.Join(store.deleted, ",") != store.puts[0] { + t.Errorf("uploaded %v, deleted %v", store.puts, store.deleted) + } +} + +func TestSignPhotos(t *testing.T) { + util.Photos = &memoryStore{} + event := model.Event{Photos: []model.EventPhoto{{ObjectKey: "weekend/events/1/a.webp"}}} + if err := service.SignPhotos(&event); err != nil || event.Photos[0].Url != "https://signed/weekend/events/1/a.webp" { + t.Errorf("err=%v url=%q", err, event.Photos[0].Url) + } +} diff --git a/util/oss.go b/util/oss.go deleted file mode 100644 index 72e3dcd..0000000 --- a/util/oss.go +++ /dev/null @@ -1,40 +0,0 @@ -package util - -import ( - "fmt" - "io" - "path/filepath" - "time" - - "github.com/aliyun/aliyun-oss-go-sdk/oss" - "github.com/spf13/viper" -) - -func Upload(name string, reader io.Reader) (string, error) { - // Endpoint以杭州为例,其它Region请按实际情况填写。 - endpoint := "http://oss-cn-hangzhou.aliyuncs.com" - // 阿里云账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM用户进行API访问或日常运维,请登录RAM控制台创建RAM用户。 - accessKeyId := viper.GetString("aliyun.access_key_id") - accessKeySecret := viper.GetString("aliyun.access_key_secret") - bucketName := "sunday-res" - // 上传文件到OSS时需要指定包含文件后缀在内的完整路径,例如abc/efg/123.jpg。 - timestamp := time.Now().Unix() - - ext := filepath.Ext(name) - objectName := fmt.Sprintf("weekend/%v%v", timestamp, ext) - - client, err := oss.New(endpoint, accessKeyId, accessKeySecret) - if err != nil { - return "", err - } - // 获取存储空间。 - bucket, err := client.Bucket(bucketName) - if err != nil { - return "", err - } - // 上传文件。 - if err = bucket.PutObject(objectName, reader); err != nil { - return "", err - } - return "https://oss.nbtca.space/" + objectName, nil -} diff --git a/util/photo.go b/util/photo.go new file mode 100644 index 0000000..10a8a6f --- /dev/null +++ b/util/photo.go @@ -0,0 +1,77 @@ +package util + +import ( + "crypto/rand" + "fmt" + "io" + "sync" + + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "github.com/spf13/viper" +) + +type PhotoStore interface { + Put(key, contentType string, r io.Reader) error + Delete(keys ...string) error + SignURL(key string) (string, error) +} + +var Photos PhotoStore = &ossPhotoStore{} + +type ossPhotoStore struct { + mu sync.Mutex + credentials string + cached *oss.Bucket +} + +func (s *ossPhotoStore) bucket() (*oss.Bucket, error) { + id, secret := viper.GetString("aliyun.access_key_id"), viper.GetString("aliyun.access_key_secret") + s.mu.Lock() + defer s.mu.Unlock() + if s.cached != nil && s.credentials == id+":"+secret { + return s.cached, nil + } + client, err := oss.New("https://oss-cn-hangzhou.aliyuncs.com", id, secret) + if err != nil { + return nil, err + } + bucket, err := client.Bucket("sunday-res") + if err != nil { + return nil, err + } + s.credentials, s.cached = id+":"+secret, bucket + return bucket, nil +} + +func (s *ossPhotoStore) Put(key, contentType string, r io.Reader) error { + bucket, err := s.bucket() + if err != nil { + return err + } + return bucket.PutObject(key, r, oss.ContentType(contentType), oss.ObjectACL(oss.ACLPrivate)) +} + +func (s *ossPhotoStore) Delete(keys ...string) error { + bucket, err := s.bucket() + if err != nil { + return err + } + _, err = bucket.DeleteObjects(keys, oss.DeleteObjectsQuiet(true)) + return err +} + +func (s *ossPhotoStore) SignURL(key string) (string, error) { + bucket, err := s.bucket() + if err != nil { + return "", err + } + return bucket.SignURL(key, oss.HTTPGet, 3600) +} + +func PhotoKey(eventId int64, contentType string) string { + ext := ".jpg" + if contentType == "image/webp" { + ext = ".webp" + } + return fmt.Sprintf("weekend/events/%d/%s%s", eventId, rand.Text(), ext) +} diff --git a/util/photo_internal_test.go b/util/photo_internal_test.go new file mode 100644 index 0000000..70e2992 --- /dev/null +++ b/util/photo_internal_test.go @@ -0,0 +1,23 @@ +package util + +import ( + "testing" + + "github.com/spf13/viper" +) + +func TestOSSPhotoStoreReusesBucketUntilCredentialsChange(t *testing.T) { + viper.Set("aliyun.access_key_id", "id-a") + viper.Set("aliyun.access_key_secret", "secret") + store := &ossPhotoStore{} + first, _ := store.bucket() + second, _ := store.bucket() + if first == nil || first != second { + t.Fatal("bucket should be reused while credentials are unchanged") + } + viper.Set("aliyun.access_key_id", "id-b") + third, _ := store.bucket() + if third == first { + t.Error("bucket should be rebuilt after credentials change") + } +} diff --git a/util/photo_test.go b/util/photo_test.go new file mode 100644 index 0000000..48f5ca0 --- /dev/null +++ b/util/photo_test.go @@ -0,0 +1,22 @@ +package util_test + +import ( + "regexp" + "testing" + + "github.com/nbtca/saturday/util" +) + +func TestPhotoKey(t *testing.T) { + webp := util.PhotoKey(42, "image/webp") + if !regexp.MustCompile(`^weekend/events/42/[A-Z2-7]{26}\.webp$`).MatchString(webp) { + t.Errorf("unexpected key %q", webp) + } + jpeg := util.PhotoKey(42, "image/jpeg") + if !regexp.MustCompile(`^weekend/events/42/[A-Z2-7]{26}\.jpg$`).MatchString(jpeg) { + t.Errorf("unexpected key %q", jpeg) + } + if util.PhotoKey(42, "image/webp") == webp { + t.Error("keys must be unique") + } +}