@johnnyboy11234/esg-idea-contest 0.2.4 → 0.3.1
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.
- package/backend/api/openapi.yaml +83 -0
- package/backend/cmd/server/main.go +35 -2
- package/backend/db/migration/V005__idea.sql +49 -0
- package/backend/db/migration/V006__login_lock.sql +15 -0
- package/backend/db/migration/V007__login_attempt.sql +15 -0
- package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
- package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
- package/backend/go.mod +17 -3
- package/backend/go.sum +48 -7
- package/backend/internal/account/account_test.go +6 -6
- package/backend/internal/account/integration_test.go +45 -9
- package/backend/internal/account/keyboard.go +75 -0
- package/backend/internal/account/keyboard_test.go +43 -0
- package/backend/internal/account/logging_integration_test.go +1 -1
- package/backend/internal/account/password.go +125 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +224 -0
- package/backend/internal/auth/internal/store/login_attempt.go +53 -0
- package/backend/internal/auth/internal/store/login_lock.go +77 -0
- package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
- package/backend/internal/auth/internal/store/session.go +15 -7
- package/backend/internal/auth/login.go +16 -4
- package/backend/internal/auth/login_integration_test.go +14 -1
- package/backend/internal/auth/login_ip_integration_test.go +153 -0
- package/backend/internal/auth/login_lock_integration_test.go +205 -0
- package/backend/internal/auth/login_test.go +15 -0
- package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
- package/backend/internal/auth/purge.go +93 -0
- package/backend/internal/auth/purge_integration_test.go +220 -0
- package/backend/internal/auth/purge_test.go +51 -0
- package/backend/internal/auth/session.go +0 -6
- package/backend/internal/auth/session_integration_test.go +3 -3
- package/backend/internal/auth/throttle.go +122 -0
- package/backend/internal/deadline/gate.go +80 -0
- package/backend/internal/deadline/gate_integration_test.go +67 -0
- package/backend/internal/deadline/gate_test.go +61 -0
- package/backend/internal/idea/create.go +352 -0
- package/backend/internal/idea/create_integration_test.go +650 -0
- package/backend/internal/idea/handler.go +148 -0
- package/backend/internal/idea/handler_integration_test.go +107 -0
- package/backend/internal/idea/integration_test.go +149 -0
- package/backend/internal/idea/internal/store/list.go +146 -0
- package/backend/internal/idea/internal/store/list_test.go +33 -0
- package/backend/internal/idea/internal/store/store.go +166 -0
- package/backend/internal/idea/list.go +166 -0
- package/backend/internal/idea/list_integration_test.go +252 -0
- package/backend/internal/idea/list_test.go +96 -0
- package/backend/internal/idea/service.go +53 -0
- package/backend/internal/server/api.go +17 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/shared/config/config.go +44 -1
- package/backend/internal/shared/config/config_test.go +21 -13
- package/backend/internal/shared/db/intx_integration_test.go +179 -0
- package/backend/internal/shared/dbtest/dbtest.go +21 -3
- package/backend/internal/shared/dbtest/faults.go +162 -0
- package/backend/internal/shared/job/job.go +54 -0
- package/backend/internal/shared/job/job_test.go +105 -0
- package/backend/internal/shared/web/multipart.go +175 -0
- package/backend/internal/shared/web/multipart_test.go +169 -0
- package/backend/internal/shared/web/problem.go +1 -0
- package/backend/internal/shared/web/request.go +42 -1
- package/backend/internal/shared/web/web_test.go +36 -0
- package/backend/internal/storage/expectedfilename.go +43 -0
- package/backend/internal/storage/expectedfilename_test.go +16 -0
- package/backend/internal/storage/internal/store/pending.go +41 -0
- package/backend/internal/storage/minio.go +139 -0
- package/backend/internal/storage/minio_integration_test.go +87 -0
- package/backend/internal/storage/minio_test.go +84 -0
- package/backend/internal/storage/pending.go +144 -0
- package/backend/internal/storage/pending_integration_test.go +324 -0
- package/backend/internal/storage/pending_test.go +23 -0
- package/backend/internal/storage/validator.go +100 -0
- package/backend/internal/storage/validator_test.go +95 -0
- package/backend/test/contract/expected_filename_test.go +60 -0
- package/backend/test/contract/password_test.go +46 -0
- package/backend/test/matrix/file_test.go +52 -0
- package/backend/test/matrix/load_test.go +62 -0
- package/backend/test/matrix/matrix_integration_test.go +291 -0
- package/backend/test/migration/pending_file_deletion_test.go +46 -0
- package/docs/API/api-endpoints.md +1 -1
- package/docs/API/error-codes.md +1 -1
- package/frontend/public/auth-hero-login.jpg +0 -0
- package/frontend/public/auth-hero-register.jpg +0 -0
- package/frontend/src/app/App.test.tsx +2 -42
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/app/index.css +4 -4
- package/frontend/src/app/layouts.tsx +7 -135
- package/frontend/src/app/pages/JoinRequests.tsx +2 -2
- package/frontend/src/app/pages/NotFound.tsx +1 -1
- package/frontend/src/app/pages/admin.tsx +1 -1
- package/frontend/src/app/pages/auth.tsx +53 -25
- package/frontend/src/app/pages/ideas.tsx +7 -7
- package/frontend/src/app/pages/login.test.tsx +3 -54
- package/frontend/src/app/pages/register.test.tsx +1 -1
- package/frontend/src/app/router.tsx +2 -6
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- package/frontend/src/features/auth/LoginForm.tsx +8 -1
- package/frontend/src/shared/api/activity.ts +20 -0
- package/frontend/src/shared/api/http.ts +2 -0
- package/frontend/src/shared/api/index.ts +1 -0
- package/frontend/src/shared/i18n/locales/en.ts +1 -0
- package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +425 -0
- package/testdata/permission-matrix.json +157 -0
- package/testdata/schema/expected-filename.schema.json +32 -0
- package/testdata/schema/password.schema.json +59 -0
- package/testdata/schema/permission-matrix.schema.json +73 -0
|
@@ -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
|
+
}
|