@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/backend/api/openapi.yaml +119 -0
  2. package/backend/cmd/server/main.go +36 -2
  3. package/backend/db/migration/V005__idea.sql +49 -0
  4. package/backend/db/migration/V006__login_lock.sql +15 -0
  5. package/backend/db/migration/V007__login_attempt.sql +15 -0
  6. package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
  7. package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
  8. package/backend/go.mod +17 -3
  9. package/backend/go.sum +48 -7
  10. package/backend/internal/account/account_test.go +6 -6
  11. package/backend/internal/account/integration_test.go +45 -9
  12. package/backend/internal/account/internal/passwords/NOTICE +36 -0
  13. package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
  14. package/backend/internal/account/internal/passwords/passwords.go +37 -0
  15. package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
  16. package/backend/internal/account/keyboard.go +75 -0
  17. package/backend/internal/account/keyboard_test.go +43 -0
  18. package/backend/internal/account/logging_integration_test.go +1 -1
  19. package/backend/internal/account/password.go +131 -0
  20. package/backend/internal/account/sequence.go +54 -0
  21. package/backend/internal/account/sequence_test.go +38 -0
  22. package/backend/internal/account/validate.go +4 -21
  23. package/backend/internal/api/api.gen.go +262 -0
  24. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  25. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  26. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  27. package/backend/internal/auth/internal/store/session.go +15 -7
  28. package/backend/internal/auth/login.go +16 -4
  29. package/backend/internal/auth/login_integration_test.go +14 -1
  30. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  31. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  32. package/backend/internal/auth/login_test.go +15 -0
  33. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  34. package/backend/internal/auth/purge.go +93 -0
  35. package/backend/internal/auth/purge_integration_test.go +220 -0
  36. package/backend/internal/auth/purge_test.go +51 -0
  37. package/backend/internal/auth/session.go +0 -6
  38. package/backend/internal/auth/session_integration_test.go +3 -3
  39. package/backend/internal/auth/throttle.go +122 -0
  40. package/backend/internal/deadline/gate.go +80 -0
  41. package/backend/internal/deadline/gate_integration_test.go +67 -0
  42. package/backend/internal/deadline/gate_test.go +61 -0
  43. package/backend/internal/idea/create.go +356 -0
  44. package/backend/internal/idea/create_integration_test.go +680 -0
  45. package/backend/internal/idea/download.go +193 -0
  46. package/backend/internal/idea/download_integration_test.go +311 -0
  47. package/backend/internal/idea/download_test.go +25 -0
  48. package/backend/internal/idea/handler.go +151 -0
  49. package/backend/internal/idea/handler_integration_test.go +107 -0
  50. package/backend/internal/idea/integration_test.go +149 -0
  51. package/backend/internal/idea/internal/store/list.go +146 -0
  52. package/backend/internal/idea/internal/store/list_test.go +33 -0
  53. package/backend/internal/idea/internal/store/store.go +166 -0
  54. package/backend/internal/idea/list.go +166 -0
  55. package/backend/internal/idea/list_integration_test.go +252 -0
  56. package/backend/internal/idea/list_test.go +96 -0
  57. package/backend/internal/idea/service.go +54 -0
  58. package/backend/internal/server/api.go +22 -0
  59. package/backend/internal/server/binding.go +4 -0
  60. package/backend/internal/server/public.go +1 -0
  61. package/backend/internal/server/server.go +20 -1
  62. package/backend/internal/server/slides.go +21 -0
  63. package/backend/internal/server/slides_test.go +82 -0
  64. package/backend/internal/shared/config/config.go +44 -1
  65. package/backend/internal/shared/config/config_test.go +21 -13
  66. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  67. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  68. package/backend/internal/shared/dbtest/faults.go +162 -0
  69. package/backend/internal/shared/job/job.go +54 -0
  70. package/backend/internal/shared/job/job_test.go +105 -0
  71. package/backend/internal/shared/web/multipart.go +175 -0
  72. package/backend/internal/shared/web/multipart_test.go +169 -0
  73. package/backend/internal/shared/web/problem.go +1 -0
  74. package/backend/internal/shared/web/request.go +42 -1
  75. package/backend/internal/shared/web/transport.go +4 -3
  76. package/backend/internal/shared/web/web_test.go +36 -0
  77. package/backend/internal/storage/expectedfilename.go +43 -0
  78. package/backend/internal/storage/expectedfilename_test.go +16 -0
  79. package/backend/internal/storage/internal/store/pending.go +41 -0
  80. package/backend/internal/storage/minio.go +139 -0
  81. package/backend/internal/storage/minio_integration_test.go +87 -0
  82. package/backend/internal/storage/minio_test.go +84 -0
  83. package/backend/internal/storage/pending.go +144 -0
  84. package/backend/internal/storage/pending_integration_test.go +324 -0
  85. package/backend/internal/storage/pending_test.go +23 -0
  86. package/backend/internal/storage/validator.go +100 -0
  87. package/backend/internal/storage/validator_test.go +95 -0
  88. package/backend/test/arch/arch_test.go +5 -1
  89. package/backend/test/contract/expected_filename_test.go +60 -0
  90. package/backend/test/contract/password_test.go +46 -0
  91. package/backend/test/matrix/load_test.go +12 -0
  92. package/backend/test/matrix/matrix_integration_test.go +92 -5
  93. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  94. package/docs/API/api-endpoints.md +1 -1
  95. package/docs/API/error-codes.md +1 -1
  96. package/docs/Architecture/Architecture.md +1 -1
  97. package/docs/Architecture/Backend.md +2 -1
  98. package/frontend/Dockerfile +2 -0
  99. package/frontend/package-lock.json +1 -0
  100. package/frontend/package.json +1 -0
  101. package/frontend/src/app/App.tsx +11 -2
  102. package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
  103. package/frontend/src/app/pages/ideas.tsx +23 -9
  104. package/frontend/src/app/pages/login.test.tsx +38 -1
  105. package/frontend/src/app/pages/register.test.tsx +32 -0
  106. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  107. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  108. package/frontend/src/features/auth/LoginForm.tsx +21 -3
  109. package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
  110. package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
  111. package/frontend/src/features/auth/RegisterForm.tsx +11 -2
  112. package/frontend/src/features/auth/common-passwords.ts +31 -0
  113. package/frontend/src/features/auth/password-policy.test.ts +64 -0
  114. package/frontend/src/features/auth/password-policy.ts +168 -0
  115. package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
  116. package/frontend/src/features/auth/useCountdown.ts +28 -0
  117. package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
  118. package/frontend/src/features/idea/create.ts +123 -0
  119. package/frontend/src/shared/api/activity.ts +20 -0
  120. package/frontend/src/shared/api/http.ts +2 -0
  121. package/frontend/src/shared/api/index.ts +1 -0
  122. package/frontend/src/shared/api/types.generated.ts +319 -1
  123. package/frontend/src/shared/i18n/locales/en.ts +46 -4
  124. package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
  125. package/frontend/src/shared/slides/index.ts +74 -0
  126. package/frontend/src/shared/slides/slides.test.ts +51 -0
  127. package/frontend/src/shared/ui/copy-button.tsx +52 -0
  128. package/frontend/src/shared/ui/native-select.tsx +27 -0
  129. package/frontend/src/shared/ui/textarea.tsx +19 -0
  130. package/frontend/src/test-setup.ts +37 -2
  131. package/frontend/vite.config.ts +3 -1
  132. package/package.json +1 -1
  133. package/testdata/expected-filename.json +93 -0
  134. package/testdata/password.json +582 -0
  135. package/testdata/permission-matrix.json +71 -0
  136. package/testdata/schema/expected-filename.schema.json +32 -0
  137. package/testdata/schema/password.schema.json +59 -0
  138. package/testdata/schema/permission-matrix.schema.json +22 -1
@@ -0,0 +1,16 @@
1
+ package storage
2
+
3
+ import (
4
+ "errors"
5
+ "testing"
6
+
7
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
8
+ )
9
+
10
+ func TestExpectedFilenameBaseRejectsUnknownCategory(t *testing.T) {
11
+ _, err := ExpectedFilenameBase("OTHER", "AAID", "a", "000001", "b")
12
+ var we *web.Error
13
+ if !errors.As(err, &we) || we.Code != web.CodeInternalError {
14
+ t.Fatalf("err = %v, want INTERNAL_ERROR", err)
15
+ }
16
+ }
@@ -0,0 +1,41 @@
1
+ // Package store holds the SQL of the storage module (pending_file_deletion,
2
+ // V008). Only the storage module may import it.
3
+ package store
4
+
5
+ import (
6
+ "context"
7
+ "database/sql"
8
+ "fmt"
9
+ "time"
10
+ )
11
+
12
+ // Execer is satisfied by *sql.DB, *sql.Tx and *sql.Conn.
13
+ type Execer interface {
14
+ ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
15
+ }
16
+
17
+ // InsertPending records key for deletion; the job may act on it from database
18
+ // time NOW + delay. Returns the row id.
19
+ func InsertPending(ctx context.Context, q Execer, key, reason string, delay time.Duration) (int64, error) {
20
+ res, err := q.ExecContext(ctx,
21
+ `INSERT INTO pending_file_deletion (object_key, reason, next_attempt_at)
22
+ VALUES (?, ?, NOW(6) + INTERVAL ? MICROSECOND)`,
23
+ key, reason, delay.Microseconds())
24
+ if err != nil {
25
+ return 0, fmt.Errorf("insert pending_file_deletion: %w", err)
26
+ }
27
+ id, err := res.LastInsertId()
28
+ if err != nil {
29
+ return 0, fmt.Errorf("insert pending_file_deletion: %w", err)
30
+ }
31
+ return id, nil
32
+ }
33
+
34
+ // DeletePending removes one row after its object was deleted. A missing row
35
+ // is not an error.
36
+ func DeletePending(ctx context.Context, q Execer, id int64) error {
37
+ if _, err := q.ExecContext(ctx, `DELETE FROM pending_file_deletion WHERE id = ?`, id); err != nil {
38
+ return fmt.Errorf("delete pending_file_deletion %d: %w", id, err)
39
+ }
40
+ return nil
41
+ }
@@ -0,0 +1,139 @@
1
+ package storage
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "io"
8
+ "net"
9
+ "net/http"
10
+ "time"
11
+
12
+ "github.com/minio/minio-go/v7"
13
+ "github.com/minio/minio-go/v7/pkg/credentials"
14
+
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
16
+ )
17
+
18
+ const (
19
+ // DialTimeout bounds connection setup to MinIO (Backend.md §4.4, D50).
20
+ DialTimeout = 2 * time.Second
21
+ // CallTimeout bounds one whole MinIO call, including a streamed Get.
22
+ CallTimeout = 30 * time.Second
23
+ )
24
+
25
+ // ErrObjectNotFound is returned by Get when the key does not exist. It is a
26
+ // consistency problem (a row points at a missing object), not an outage.
27
+ var ErrObjectNotFound = errors.New("storage: object not found")
28
+
29
+ // SlideStore is the MinIO port. Failures other than ErrObjectNotFound are
30
+ // *web.Error with code STORAGE_UNAVAILABLE (503).
31
+ type SlideStore interface {
32
+ // Put writes size bytes from r under key. size must be exact (>= 0).
33
+ Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
34
+ // Get opens key; the caller must Close the reader. size is the object length.
35
+ Get(ctx context.Context, key string) (rc io.ReadCloser, size int64, err error)
36
+ // Delete removes key. Deleting a missing key succeeds.
37
+ Delete(ctx context.Context, key string) error
38
+ }
39
+
40
+ // MinIOConfig configures NewMinIO.
41
+ type MinIOConfig struct {
42
+ Endpoint string // host:port
43
+ AccessKey string
44
+ SecretKey string
45
+ Bucket string
46
+ Secure bool
47
+ }
48
+
49
+ type minioStore struct {
50
+ c *minio.Client
51
+ bucket string
52
+ }
53
+
54
+ // NewMinIO builds the client: 2 s dial timeout, and MaxRetries: 1 — minio-go
55
+ // treats 0 as "default of 10", so 1 is the only way to get a single attempt
56
+ // (Backend.md §4.4, A3c). It makes no network call.
57
+ func NewMinIO(cfg MinIOConfig) (SlideStore, error) {
58
+ tr := http.DefaultTransport.(*http.Transport).Clone() //nolint:forcetypeassert // stdlib guarantee
59
+ tr.DialContext = (&net.Dialer{Timeout: DialTimeout, KeepAlive: 30 * time.Second}).DialContext
60
+ c, err := minio.New(cfg.Endpoint, &minio.Options{
61
+ Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
62
+ Secure: cfg.Secure,
63
+ Transport: tr,
64
+ MaxRetries: 1,
65
+ })
66
+ if err != nil {
67
+ return nil, fmt.Errorf("storage: minio client: %w", err)
68
+ }
69
+ return &minioStore{c: c, bucket: cfg.Bucket}, nil
70
+ }
71
+
72
+ func unavailable(op string, err error) error {
73
+ e := web.NewError(web.CodeStorageUnavailable, "object storage is unavailable")
74
+ e.Cause = fmt.Errorf("minio %s: %w", op, err)
75
+ return e
76
+ }
77
+
78
+ func isNotFound(err error) bool {
79
+ return minio.ToErrorResponse(err).Code == "NoSuchKey"
80
+ }
81
+
82
+ func (s *minioStore) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
83
+ ctx, cancel := context.WithTimeout(ctx, CallTimeout)
84
+ defer cancel()
85
+ _, err := s.c.PutObject(ctx, s.bucket, key, r, size, minio.PutObjectOptions{
86
+ ContentType: contentType,
87
+ // Single PUT for slide-sized objects; no multipart retries to reason about.
88
+ DisableMultipart: true,
89
+ })
90
+ if err != nil {
91
+ return unavailable("put", err)
92
+ }
93
+ return nil
94
+ }
95
+
96
+ func (s *minioStore) Get(ctx context.Context, key string) (io.ReadCloser, int64, error) {
97
+ ctx, cancel := context.WithTimeout(ctx, CallTimeout)
98
+ obj, err := s.c.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{})
99
+ if err != nil {
100
+ cancel()
101
+ return nil, 0, unavailable("get", err)
102
+ }
103
+ // GetObject is lazy; Stat performs the request, so a down server or a
104
+ // missing key surfaces here, before the caller writes a first byte.
105
+ st, err := obj.Stat()
106
+ if err != nil {
107
+ _ = obj.Close()
108
+ cancel()
109
+ if isNotFound(err) {
110
+ return nil, 0, ErrObjectNotFound
111
+ }
112
+ return nil, 0, unavailable("stat", err)
113
+ }
114
+ return &cancelCloser{ReadCloser: obj, cancel: cancel}, st.Size, nil
115
+ }
116
+
117
+ func (s *minioStore) Delete(ctx context.Context, key string) error {
118
+ ctx, cancel := context.WithTimeout(ctx, CallTimeout)
119
+ defer cancel()
120
+ if err := s.c.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil {
121
+ if isNotFound(err) {
122
+ return nil
123
+ }
124
+ return unavailable("delete", err)
125
+ }
126
+ return nil
127
+ }
128
+
129
+ // cancelCloser releases the call context when the stream is closed.
130
+ type cancelCloser struct {
131
+ io.ReadCloser
132
+ cancel context.CancelFunc
133
+ }
134
+
135
+ func (c *cancelCloser) Close() error {
136
+ err := c.ReadCloser.Close()
137
+ c.cancel()
138
+ return err
139
+ }
@@ -0,0 +1,87 @@
1
+ //go:build integration
2
+
3
+ package storage_test
4
+
5
+ import (
6
+ "context"
7
+ "errors"
8
+ "io"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/minio/minio-go/v7"
13
+ "github.com/minio/minio-go/v7/pkg/credentials"
14
+ tcminio "github.com/testcontainers/testcontainers-go/modules/minio"
15
+
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/storage"
18
+ )
19
+
20
+ func startMinIO(t *testing.T) (storage.MinIOConfig, *tcminio.MinioContainer) {
21
+ t.Helper()
22
+ ctx := context.Background()
23
+ ctr, err := tcminio.Run(ctx, "cgr.dev/chainguard/minio:latest") // same image as docker-compose.yml
24
+ if err != nil {
25
+ t.Fatalf("start minio: %v", err)
26
+ }
27
+ t.Cleanup(func() { _ = ctr.Terminate(context.WithoutCancel(ctx)) })
28
+ ep, err := ctr.ConnectionString(ctx)
29
+ if err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ cfg := storage.MinIOConfig{Endpoint: ep, AccessKey: ctr.Username, SecretKey: ctr.Password, Bucket: "slides"}
33
+ admin, err := minio.New(ep, &minio.Options{Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, "")})
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ if err := admin.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{}); err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ return cfg, ctr
41
+ }
42
+
43
+ func TestMinIORoundTrip(t *testing.T) {
44
+ cfg, ctr := startMinIO(t)
45
+ ctx := context.Background()
46
+ s, err := storage.NewMinIO(cfg)
47
+ if err != nil {
48
+ t.Fatal(err)
49
+ }
50
+
51
+ body := "hello slides"
52
+ if err := s.Put(ctx, "a/b.pdf", strings.NewReader(body), int64(len(body)), "application/pdf"); err != nil {
53
+ t.Fatal(err)
54
+ }
55
+ rc, size, err := s.Get(ctx, "a/b.pdf")
56
+ if err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ got, _ := io.ReadAll(rc)
60
+ _ = rc.Close()
61
+ if string(got) != body || size != int64(len(body)) {
62
+ t.Fatalf("got %q size %d", got, size)
63
+ }
64
+
65
+ if _, _, err := s.Get(ctx, "missing"); !errors.Is(err, storage.ErrObjectNotFound) {
66
+ t.Fatalf("missing key: %v", err)
67
+ }
68
+ if err := s.Delete(ctx, "a/b.pdf"); err != nil {
69
+ t.Fatal(err)
70
+ }
71
+ if err := s.Delete(ctx, "a/b.pdf"); err != nil {
72
+ t.Fatalf("delete of missing key must succeed: %v", err)
73
+ }
74
+ if _, _, err := s.Get(ctx, "a/b.pdf"); !errors.Is(err, storage.ErrObjectNotFound) {
75
+ t.Fatalf("after delete: %v", err)
76
+ }
77
+
78
+ // MinIO down → 503 STORAGE_UNAVAILABLE.
79
+ if err := ctr.Terminate(ctx); err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ err = s.Put(ctx, "x", strings.NewReader("x"), 1, "application/pdf")
83
+ var we *web.Error
84
+ if !errors.As(err, &we) || we.Code != web.CodeStorageUnavailable {
85
+ t.Fatalf("down: %v", err)
86
+ }
87
+ }
@@ -0,0 +1,84 @@
1
+ package storage
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net"
7
+ "strings"
8
+ "sync/atomic"
9
+ "testing"
10
+
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
12
+ )
13
+
14
+ func newStore(t *testing.T, endpoint string) SlideStore {
15
+ t.Helper()
16
+ s, err := NewMinIO(MinIOConfig{Endpoint: endpoint, AccessKey: "a", SecretKey: "bbbbbbbb", Bucket: "slides"})
17
+ if err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ return s
21
+ }
22
+
23
+ func assertUnavailable(t *testing.T, err error) {
24
+ t.Helper()
25
+ var we *web.Error
26
+ if !errors.As(err, &we) || we.Code != web.CodeStorageUnavailable || we.Status != 503 {
27
+ t.Fatalf("want 503 STORAGE_UNAVAILABLE, got %v", err)
28
+ }
29
+ }
30
+
31
+ // refusing accepts a connection and drops it at once, counting attempts.
32
+ func refusing(t *testing.T) (addr string, attempts *atomic.Int32) {
33
+ t.Helper()
34
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
35
+ if err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ t.Cleanup(func() { _ = ln.Close() })
39
+ attempts = new(atomic.Int32)
40
+ go func() {
41
+ for {
42
+ c, err := ln.Accept()
43
+ if err != nil {
44
+ return
45
+ }
46
+ attempts.Add(1)
47
+ _ = c.Close()
48
+ }
49
+ }()
50
+ return ln.Addr().String(), attempts
51
+ }
52
+
53
+ // OI-52 / A3c: retries are off, so a failing server sees exactly one attempt.
54
+ func TestSingleAttemptPerCall(t *testing.T) {
55
+ ctx := context.Background()
56
+ calls := map[string]func(SlideStore) error{
57
+ "put": func(s SlideStore) error { return s.Put(ctx, "k", strings.NewReader("abc"), 3, "application/pdf") },
58
+ "get": func(s SlideStore) error { _, _, err := s.Get(ctx, "k"); return err },
59
+ "delete": func(s SlideStore) error { return s.Delete(ctx, "k") },
60
+ }
61
+ for name, call := range calls {
62
+ t.Run(name, func(t *testing.T) {
63
+ addr, attempts := refusing(t)
64
+ assertUnavailable(t, call(newStore(t, addr)))
65
+ if n := attempts.Load(); n != 1 {
66
+ t.Fatalf("connection attempts = %d, want 1", n)
67
+ }
68
+ })
69
+ }
70
+ }
71
+
72
+ func TestUnreachableIsUnavailable(t *testing.T) {
73
+ ln, _ := net.Listen("tcp", "127.0.0.1:0")
74
+ addr := ln.Addr().String()
75
+ _ = ln.Close() // nothing listens any more
76
+ err := newStore(t, addr).Put(context.Background(), "k", strings.NewReader("x"), 1, "application/pdf")
77
+ assertUnavailable(t, err)
78
+ }
79
+
80
+ func TestNewMinIORejectsBadEndpoint(t *testing.T) {
81
+ if _, err := NewMinIO(MinIOConfig{Endpoint: "http://host/path"}); err == nil {
82
+ t.Fatal("want error")
83
+ }
84
+ }
@@ -0,0 +1,144 @@
1
+ package storage
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+ "log/slog"
8
+ "time"
9
+
10
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/storage/internal/store"
12
+ )
13
+
14
+ // Reason is why an object key is in pending_file_deletion (Sequence.md §2).
15
+ type Reason string
16
+
17
+ const (
18
+ // ReasonReplaced: old key, recorded in the transaction that switched the pointer.
19
+ ReasonReplaced Reason = "REPLACED"
20
+ // ReasonDeleted: key of a deleted Idea, recorded in the delete transaction.
21
+ ReasonDeleted Reason = "DELETED"
22
+ // ReasonUploadAborted: new key whose transaction rolled back (and the
23
+ // immediate delete failed) or ended with an unknown outcome.
24
+ ReasonUploadAborted Reason = "UPLOAD_ABORTED"
25
+ )
26
+
27
+ // UploadAbortedGrace delays the clean-up of a key recorded after an unknown
28
+ // outcome: longer than the maximum lock wait plus transaction time, so a COMMIT
29
+ // the server is still finishing is never raced (D36). [Proposed] OI-19.
30
+ const UploadAbortedGrace = 10 * time.Minute
31
+
32
+ // bookkeepingTimeout bounds one database write that runs outside the request's
33
+ // own transaction (separate UPLOAD_ABORTED insert, pending row removal).
34
+ const bookkeepingTimeout = 5 * time.Second
35
+
36
+ // PendingID identifies a pending_file_deletion row.
37
+ type PendingID int64
38
+
39
+ // PendingDeletion writes pending_file_deletion (PRD §6.8, D25) and makes the
40
+ // best-effort post-commit delete. The table is drained by the clean-up job
41
+ // (next phase); nothing here waits for it.
42
+ type PendingDeletion struct {
43
+ db *sql.DB
44
+ store SlideStore
45
+ log *slog.Logger
46
+ }
47
+
48
+ // NewPendingDeletion builds the service. log nil discards.
49
+ func NewPendingDeletion(database *sql.DB, s SlideStore, log *slog.Logger) *PendingDeletion {
50
+ if log == nil {
51
+ log = slog.New(slog.DiscardHandler)
52
+ }
53
+ return &PendingDeletion{db: database, store: s, log: log}
54
+ }
55
+
56
+ // RecordInTx records key with REPLACED or DELETED inside the caller's
57
+ // transaction, so the row exists exactly when the change committed. After a
58
+ // Committed outcome the caller passes the id to DeleteAfterCommit.
59
+ func (p *PendingDeletion) RecordInTx(ctx context.Context, tx *sql.Tx, key string, reason Reason) (PendingID, error) {
60
+ if reason != ReasonReplaced && reason != ReasonDeleted {
61
+ return 0, fmt.Errorf("storage: RecordInTx with reason %q", reason)
62
+ }
63
+ id, err := store.InsertPending(ctx, tx, key, string(reason), 0)
64
+ return PendingID(id), err
65
+ }
66
+
67
+ // RecordUploadAborted records a newly uploaded key whose transaction did not
68
+ // commit, in its own transaction (best effort; the request is failing anyway).
69
+ //
70
+ // - RolledBack (the immediate delete failed): due at once.
71
+ // - Unknown (the COMMIT may have been applied): due after UploadAbortedGrace;
72
+ // the job keeps the object if an idea row references the key.
73
+ //
74
+ // Committed is a programming error: a committed key is referenced.
75
+ func (p *PendingDeletion) RecordUploadAborted(ctx context.Context, key string, outcome db.Outcome) error {
76
+ var delay time.Duration
77
+ switch outcome {
78
+ case db.RolledBack:
79
+ case db.Unknown:
80
+ delay = UploadAbortedGrace
81
+ default:
82
+ return fmt.Errorf("storage: RecordUploadAborted after %v", outcome)
83
+ }
84
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), bookkeepingTimeout)
85
+ defer cancel()
86
+ if _, err := store.InsertPending(ctx, p.db, key, string(ReasonUploadAborted), delay); err != nil {
87
+ p.log.ErrorContext(ctx, "pending_file_deletion insert failed; object may be orphaned",
88
+ slog.String("objectKey", key), slog.String("outcome", outcome.String()), slog.Any("error", err))
89
+ return err
90
+ }
91
+ return nil
92
+ }
93
+
94
+ // AbortUpload compensates a newly uploaded key whose transaction did not
95
+ // commit (Sequence.md §2). It never fails the request, which is failing
96
+ // anyway; every failure is logged.
97
+ //
98
+ // - RolledBack: delete the object now; if that fails, record UPLOAD_ABORTED
99
+ // due at once.
100
+ // - Unknown: keep the object (the COMMIT may have been applied) and record
101
+ // UPLOAD_ABORTED due after UploadAbortedGrace.
102
+ //
103
+ // The caller's cancellation is ignored, so a client that disconnects does not
104
+ // skip it. Committed is a programming error and is only logged.
105
+ func (p *PendingDeletion) AbortUpload(ctx context.Context, key string, outcome db.Outcome) {
106
+ switch outcome {
107
+ case db.RolledBack:
108
+ dctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), CallTimeout)
109
+ err := p.store.Delete(dctx, key)
110
+ cancel()
111
+ if err == nil {
112
+ return
113
+ }
114
+ p.log.WarnContext(ctx, "delete of a rolled-back upload failed; recording UPLOAD_ABORTED",
115
+ slog.String("objectKey", key), slog.Any("error", err))
116
+ case db.Unknown:
117
+ default:
118
+ p.log.ErrorContext(ctx, "AbortUpload called for a committed key; object kept",
119
+ slog.String("objectKey", key), slog.String("outcome", outcome.String()))
120
+ return
121
+ }
122
+ // RecordUploadAborted logs its own failure.
123
+ _ = p.RecordUploadAborted(ctx, key, outcome)
124
+ }
125
+
126
+ // DeleteAfterCommit deletes key from storage and then its pending row. It runs
127
+ // after the response outcome is fixed and never fails the request: a storage
128
+ // or database failure is logged and the row stays for the clean-up job
129
+ // (Sequence.md §4, §5). The caller's cancellation is ignored, so a client that
130
+ // disconnects does not skip it.
131
+ func (p *PendingDeletion) DeleteAfterCommit(ctx context.Context, id PendingID, key string) {
132
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), CallTimeout+bookkeepingTimeout)
133
+ defer cancel()
134
+ if err := p.store.Delete(ctx, key); err != nil {
135
+ p.log.WarnContext(ctx, "post-commit object delete failed; left for the clean-up job",
136
+ slog.String("objectKey", key), slog.Int64("pendingId", int64(id)), slog.Any("error", err))
137
+ return
138
+ }
139
+ if err := store.DeletePending(ctx, p.db, int64(id)); err != nil {
140
+ // The object is gone; the job's delete of a missing key succeeds.
141
+ p.log.WarnContext(ctx, "pending_file_deletion row not removed after delete",
142
+ slog.String("objectKey", key), slog.Int64("pendingId", int64(id)), slog.Any("error", err))
143
+ }
144
+ }