@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.
Files changed (111) hide show
  1. package/backend/api/openapi.yaml +83 -0
  2. package/backend/cmd/server/main.go +35 -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/keyboard.go +75 -0
  13. package/backend/internal/account/keyboard_test.go +43 -0
  14. package/backend/internal/account/logging_integration_test.go +1 -1
  15. package/backend/internal/account/password.go +125 -0
  16. package/backend/internal/account/validate.go +4 -21
  17. package/backend/internal/api/api.gen.go +224 -0
  18. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  19. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  20. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  21. package/backend/internal/auth/internal/store/session.go +15 -7
  22. package/backend/internal/auth/login.go +16 -4
  23. package/backend/internal/auth/login_integration_test.go +14 -1
  24. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  25. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  26. package/backend/internal/auth/login_test.go +15 -0
  27. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  28. package/backend/internal/auth/purge.go +93 -0
  29. package/backend/internal/auth/purge_integration_test.go +220 -0
  30. package/backend/internal/auth/purge_test.go +51 -0
  31. package/backend/internal/auth/session.go +0 -6
  32. package/backend/internal/auth/session_integration_test.go +3 -3
  33. package/backend/internal/auth/throttle.go +122 -0
  34. package/backend/internal/deadline/gate.go +80 -0
  35. package/backend/internal/deadline/gate_integration_test.go +67 -0
  36. package/backend/internal/deadline/gate_test.go +61 -0
  37. package/backend/internal/idea/create.go +352 -0
  38. package/backend/internal/idea/create_integration_test.go +650 -0
  39. package/backend/internal/idea/handler.go +148 -0
  40. package/backend/internal/idea/handler_integration_test.go +107 -0
  41. package/backend/internal/idea/integration_test.go +149 -0
  42. package/backend/internal/idea/internal/store/list.go +146 -0
  43. package/backend/internal/idea/internal/store/list_test.go +33 -0
  44. package/backend/internal/idea/internal/store/store.go +166 -0
  45. package/backend/internal/idea/list.go +166 -0
  46. package/backend/internal/idea/list_integration_test.go +252 -0
  47. package/backend/internal/idea/list_test.go +96 -0
  48. package/backend/internal/idea/service.go +53 -0
  49. package/backend/internal/server/api.go +17 -0
  50. package/backend/internal/server/public.go +1 -0
  51. package/backend/internal/server/slides.go +21 -0
  52. package/backend/internal/shared/config/config.go +44 -1
  53. package/backend/internal/shared/config/config_test.go +21 -13
  54. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  55. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  56. package/backend/internal/shared/dbtest/faults.go +162 -0
  57. package/backend/internal/shared/job/job.go +54 -0
  58. package/backend/internal/shared/job/job_test.go +105 -0
  59. package/backend/internal/shared/web/multipart.go +175 -0
  60. package/backend/internal/shared/web/multipart_test.go +169 -0
  61. package/backend/internal/shared/web/problem.go +1 -0
  62. package/backend/internal/shared/web/request.go +42 -1
  63. package/backend/internal/shared/web/web_test.go +36 -0
  64. package/backend/internal/storage/expectedfilename.go +43 -0
  65. package/backend/internal/storage/expectedfilename_test.go +16 -0
  66. package/backend/internal/storage/internal/store/pending.go +41 -0
  67. package/backend/internal/storage/minio.go +139 -0
  68. package/backend/internal/storage/minio_integration_test.go +87 -0
  69. package/backend/internal/storage/minio_test.go +84 -0
  70. package/backend/internal/storage/pending.go +144 -0
  71. package/backend/internal/storage/pending_integration_test.go +324 -0
  72. package/backend/internal/storage/pending_test.go +23 -0
  73. package/backend/internal/storage/validator.go +100 -0
  74. package/backend/internal/storage/validator_test.go +95 -0
  75. package/backend/test/contract/expected_filename_test.go +60 -0
  76. package/backend/test/contract/password_test.go +46 -0
  77. package/backend/test/matrix/file_test.go +52 -0
  78. package/backend/test/matrix/load_test.go +62 -0
  79. package/backend/test/matrix/matrix_integration_test.go +291 -0
  80. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  81. package/docs/API/api-endpoints.md +1 -1
  82. package/docs/API/error-codes.md +1 -1
  83. package/frontend/public/auth-hero-login.jpg +0 -0
  84. package/frontend/public/auth-hero-register.jpg +0 -0
  85. package/frontend/src/app/App.test.tsx +2 -42
  86. package/frontend/src/app/App.tsx +11 -2
  87. package/frontend/src/app/index.css +4 -4
  88. package/frontend/src/app/layouts.tsx +7 -135
  89. package/frontend/src/app/pages/JoinRequests.tsx +2 -2
  90. package/frontend/src/app/pages/NotFound.tsx +1 -1
  91. package/frontend/src/app/pages/admin.tsx +1 -1
  92. package/frontend/src/app/pages/auth.tsx +53 -25
  93. package/frontend/src/app/pages/ideas.tsx +7 -7
  94. package/frontend/src/app/pages/login.test.tsx +3 -54
  95. package/frontend/src/app/pages/register.test.tsx +1 -1
  96. package/frontend/src/app/router.tsx +2 -6
  97. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  98. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  99. package/frontend/src/features/auth/LoginForm.tsx +8 -1
  100. package/frontend/src/shared/api/activity.ts +20 -0
  101. package/frontend/src/shared/api/http.ts +2 -0
  102. package/frontend/src/shared/api/index.ts +1 -0
  103. package/frontend/src/shared/i18n/locales/en.ts +1 -0
  104. package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
  105. package/package.json +1 -1
  106. package/testdata/expected-filename.json +93 -0
  107. package/testdata/password.json +425 -0
  108. package/testdata/permission-matrix.json +157 -0
  109. package/testdata/schema/expected-filename.schema.json +32 -0
  110. package/testdata/schema/password.schema.json +59 -0
  111. package/testdata/schema/permission-matrix.schema.json +73 -0
@@ -0,0 +1,54 @@
1
+ // Package job runs background work on a fixed period with random jitter
2
+ // (Backend.md §9). Every replica runs its own loop; the work itself must be
3
+ // idempotent and safe to run concurrently on several replicas, because no
4
+ // leader is elected.
5
+ package job
6
+
7
+ import (
8
+ "context"
9
+ "log/slog"
10
+ "math/rand/v2"
11
+ "time"
12
+ )
13
+
14
+ // Spec describes one periodic job.
15
+ type Spec struct {
16
+ Name string // log attribute "job"
17
+ Interval time.Duration // base period between two runs
18
+ Jitter time.Duration // each wait adds a random [0, Jitter), so replicas drift apart
19
+ }
20
+
21
+ // Run calls fn once per Interval plus jitter until ctx is cancelled, then
22
+ // returns. The first call also waits a random [0, Jitter), so replicas started
23
+ // together do not run in step. A failing run is logged and the loop goes on;
24
+ // a run in progress sees ctx cancelled and should return promptly.
25
+ func Run(ctx context.Context, log *slog.Logger, s Spec, fn func(context.Context) error) {
26
+ run(ctx, log, s, fn, rand.Int64N)
27
+ }
28
+
29
+ // run is Run with the random source injected for tests.
30
+ func run(ctx context.Context, log *slog.Logger, s Spec, fn func(context.Context) error, randN func(int64) int64) {
31
+ log = log.With("job", s.Name)
32
+ t := time.NewTimer(jitter(s.Jitter, randN))
33
+ defer t.Stop()
34
+ for {
35
+ select {
36
+ case <-ctx.Done():
37
+ log.Debug("job stopped")
38
+ return
39
+ case <-t.C:
40
+ }
41
+ if err := fn(ctx); err != nil && ctx.Err() == nil {
42
+ log.Warn("job run failed", "error", err.Error())
43
+ }
44
+ t.Reset(s.Interval + jitter(s.Jitter, randN))
45
+ }
46
+ }
47
+
48
+ // jitter returns a random duration in [0, upTo), or 0 when upTo <= 0.
49
+ func jitter(upTo time.Duration, randN func(int64) int64) time.Duration {
50
+ if upTo <= 0 {
51
+ return 0
52
+ }
53
+ return time.Duration(randN(int64(upTo)))
54
+ }
@@ -0,0 +1,105 @@
1
+ package job
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "errors"
7
+ "log/slog"
8
+ "strings"
9
+ "sync"
10
+ "sync/atomic"
11
+ "testing"
12
+ "time"
13
+ )
14
+
15
+ func noRand(int64) int64 { return 0 }
16
+
17
+ func logger(buf *bytes.Buffer) *slog.Logger {
18
+ return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
19
+ }
20
+
21
+ type syncBuf struct {
22
+ mu sync.Mutex
23
+ b bytes.Buffer
24
+ }
25
+
26
+ func (s *syncBuf) Write(p []byte) (int, error) { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Write(p) }
27
+ func (s *syncBuf) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
28
+
29
+ func TestRunRepeatsUntilCancelled(t *testing.T) {
30
+ ctx, cancel := context.WithCancel(context.Background())
31
+ var calls atomic.Int32
32
+ done := make(chan struct{})
33
+ go func() {
34
+ defer close(done)
35
+ run(ctx, slog.New(slog.DiscardHandler), Spec{Name: "t", Interval: time.Millisecond}, func(context.Context) error {
36
+ if calls.Add(1) == 3 {
37
+ cancel()
38
+ }
39
+ return nil
40
+ }, noRand)
41
+ }()
42
+ select {
43
+ case <-done:
44
+ case <-time.After(5 * time.Second):
45
+ t.Fatal("Run did not return after cancel")
46
+ }
47
+ if n := calls.Load(); n != 3 {
48
+ t.Errorf("calls = %d, want 3 (no run after cancel)", n)
49
+ }
50
+ }
51
+
52
+ func TestRunLogsFailureAndContinues(t *testing.T) {
53
+ buf := &syncBuf{}
54
+ ctx, cancel := context.WithCancel(context.Background())
55
+ var calls atomic.Int32
56
+ run(ctx, slog.New(slog.NewTextHandler(buf, nil)), Spec{Name: "purge", Interval: time.Millisecond}, func(context.Context) error {
57
+ if calls.Add(1) == 2 {
58
+ cancel()
59
+ }
60
+ return errors.New("boom")
61
+ }, noRand)
62
+ out := buf.String()
63
+ // The first failure is logged; the second, returned after cancel, is not.
64
+ if strings.Count(out, "job run failed") != 1 || !strings.Contains(out, "job=purge") || !strings.Contains(out, "error=boom") {
65
+ t.Errorf("log = %q", out)
66
+ }
67
+ }
68
+
69
+ func TestRunFirstCallWaitsForJitter(t *testing.T) {
70
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
71
+ defer cancel()
72
+ var buf bytes.Buffer
73
+ called := false
74
+ run(ctx, logger(&buf), Spec{Name: "t", Interval: time.Millisecond, Jitter: time.Hour}, func(context.Context) error {
75
+ called = true
76
+ return nil
77
+ }, func(n int64) int64 { return n - 1 })
78
+ if called {
79
+ t.Error("first run must wait for its jitter")
80
+ }
81
+ if !strings.Contains(buf.String(), "job stopped") {
82
+ t.Errorf("log = %q", buf.String())
83
+ }
84
+ }
85
+
86
+ func TestJitter(t *testing.T) {
87
+ var asked int64
88
+ got := jitter(time.Minute, func(n int64) int64 { asked = n; return n / 2 })
89
+ if asked != int64(time.Minute) || got != 30*time.Second {
90
+ t.Errorf("jitter = %v (asked %d)", got, asked)
91
+ }
92
+ for _, d := range []time.Duration{0, -time.Second} {
93
+ if got := jitter(d, func(int64) int64 { panic("must not be called") }); got != 0 {
94
+ t.Errorf("jitter(%v) = %v", d, got)
95
+ }
96
+ }
97
+ }
98
+
99
+ func TestRunUsesRealRandom(t *testing.T) {
100
+ ctx, cancel := context.WithCancel(context.Background())
101
+ Run(ctx, slog.New(slog.DiscardHandler), Spec{Name: "t", Interval: time.Hour, Jitter: time.Nanosecond}, func(context.Context) error {
102
+ cancel()
103
+ return nil
104
+ })
105
+ }
@@ -0,0 +1,175 @@
1
+ package web
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "errors"
6
+ "io"
7
+ "mime"
8
+ "mime/multipart"
9
+ "net/http"
10
+ "os"
11
+ "unicode/utf8"
12
+ )
13
+
14
+ // maxTextPartBytes caps one text part. Text fields are short (PRD §6.6); a
15
+ // larger one is malformed, not an excuse to buffer.
16
+ const maxTextPartBytes = 64 << 10
17
+
18
+ // MultipartSpec describes the one multipart shape a route accepts.
19
+ type MultipartSpec struct {
20
+ // FileField is the single file part; it is required and may appear once.
21
+ FileField string
22
+ // TextFields are required text parts, each exactly once. Other parts are
23
+ // drained and ignored.
24
+ TextFields []string
25
+ // TooLarge is the code answered when the body limit trips (FILE_TOO_LARGE
26
+ // on slide routes).
27
+ TooLarge ErrorCode
28
+ // TempDir is where the file is spooled; empty means os.TempDir() (the
29
+ // pod's /tmp emptyDir).
30
+ TempDir string
31
+ }
32
+
33
+ // SpooledFile is the file part written to a temporary file. Its size and
34
+ // SHA-256 were computed while streaming.
35
+ type SpooledFile struct {
36
+ Filename string // as sent, UTF-8, path components not stripped (PRD §6.8)
37
+ Size int64
38
+ SHA256 [sha256.Size]byte
39
+ f *os.File
40
+ }
41
+
42
+ // ReaderAt gives random access for the validator (ZIP central directory).
43
+ func (s *SpooledFile) ReaderAt() io.ReaderAt { return s.f }
44
+
45
+ // Open returns a fresh reader positioned at the start, for the upload.
46
+ func (s *SpooledFile) Open() io.Reader { return io.NewSectionReader(s.f, 0, s.Size) }
47
+
48
+ // Form is a spooled multipart request. The caller must Close it (defer), which
49
+ // removes the temporary file whether the request succeeded or failed.
50
+ type Form struct {
51
+ Fields map[string]string
52
+ File *SpooledFile
53
+ }
54
+
55
+ // Close removes the temporary file. Safe on a nil Form and when called twice.
56
+ func (m *Form) Close() error {
57
+ if m == nil || m.File == nil || m.File.f == nil {
58
+ return nil
59
+ }
60
+ f := m.File.f
61
+ m.File.f = nil
62
+ err := f.Close()
63
+ if rerr := os.Remove(f.Name()); rerr != nil && !errors.Is(rerr, os.ErrNotExist) {
64
+ err = errors.Join(err, rerr)
65
+ }
66
+ return err
67
+ }
68
+
69
+ // ReadMultipart streams the body with r.MultipartReader (never
70
+ // ParseMultipartForm, which buffers in memory and creates temp files we do
71
+ // not control). The file part is copied into a temp file through
72
+ // io.MultiWriter(file, sha256). A non-multipart body, a missing or repeated
73
+ // part, or a file part without a filename is 400 MALFORMED_REQUEST; the body
74
+ // limit is spec.TooLarge. On any error nothing is left on disk.
75
+ func ReadMultipart(r *http.Request, spec MultipartSpec) (*Form, error) {
76
+ mr, err := r.MultipartReader()
77
+ if err != nil {
78
+ return nil, malformed("request is not valid multipart/form-data", err)
79
+ }
80
+ form := &Form{Fields: make(map[string]string, len(spec.TextFields))}
81
+ ok := false
82
+ defer func() {
83
+ if !ok {
84
+ _ = form.Close()
85
+ }
86
+ }()
87
+ wanted := make(map[string]bool, len(spec.TextFields))
88
+ for _, n := range spec.TextFields {
89
+ wanted[n] = true
90
+ }
91
+ for {
92
+ part, err := mr.NextPart()
93
+ if errors.Is(err, io.EOF) {
94
+ break
95
+ }
96
+ if err != nil {
97
+ return nil, spec.readErr(err, "unreadable multipart body")
98
+ }
99
+ name := part.FormName()
100
+ switch {
101
+ case name == spec.FileField:
102
+ if form.File != nil {
103
+ return nil, malformed("repeated part: "+name, nil)
104
+ }
105
+ form.File, err = spec.spool(part, spec.TempDir)
106
+ if err != nil {
107
+ return nil, err
108
+ }
109
+ case wanted[name]:
110
+ if _, dup := form.Fields[name]; dup {
111
+ return nil, malformed("repeated part: "+name, nil)
112
+ }
113
+ b, err := io.ReadAll(io.LimitReader(part, maxTextPartBytes+1))
114
+ if err != nil {
115
+ return nil, spec.readErr(err, "unreadable multipart body")
116
+ }
117
+ if len(b) > maxTextPartBytes || !utf8.Valid(b) {
118
+ return nil, malformed("invalid part: "+name, nil)
119
+ }
120
+ form.Fields[name] = string(b)
121
+ default:
122
+ if _, err := io.Copy(io.Discard, part); err != nil {
123
+ return nil, spec.readErr(err, "unreadable multipart body")
124
+ }
125
+ }
126
+ }
127
+ if form.File == nil {
128
+ return nil, malformed("missing part: "+spec.FileField, nil)
129
+ }
130
+ for _, n := range spec.TextFields {
131
+ if _, have := form.Fields[n]; !have {
132
+ return nil, malformed("missing part: "+n, nil)
133
+ }
134
+ }
135
+ ok = true
136
+ return form, nil
137
+ }
138
+
139
+ func (s MultipartSpec) readErr(err error, detail string) error {
140
+ if IsBodyTooLarge(err) {
141
+ return NewError(s.TooLarge, "Request body too large.")
142
+ }
143
+ return malformed(detail, err)
144
+ }
145
+
146
+ func (s MultipartSpec) spool(part *multipart.Part, dir string) (*SpooledFile, error) {
147
+ // Part.FileName() applies filepath.Base; the filename must be taken as sent.
148
+ _, params, err := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
149
+ name, has := params["filename"]
150
+ if err != nil || !has || !utf8.ValidString(name) {
151
+ return nil, malformed("part "+s.FileField+" needs a UTF-8 filename", err)
152
+ }
153
+ f, err := os.CreateTemp(dir, "upload-*")
154
+ if err != nil {
155
+ return nil, err // plain error: mapped to 500 by the pipeline
156
+ }
157
+ out := &SpooledFile{Filename: name, f: f}
158
+ h := sha256.New()
159
+ n, err := io.Copy(io.MultiWriter(f, h), part)
160
+ if err != nil {
161
+ _ = f.Close()
162
+ _ = os.Remove(f.Name())
163
+ if IsBodyTooLarge(err) {
164
+ return nil, NewError(s.TooLarge, "Request body too large.")
165
+ }
166
+ var pe *os.PathError
167
+ if errors.As(err, &pe) {
168
+ return nil, err // disk failure, not the client's fault
169
+ }
170
+ return nil, malformed("unreadable multipart body", err)
171
+ }
172
+ out.Size = n
173
+ h.Sum(out.SHA256[:0])
174
+ return out, nil
175
+ }
@@ -0,0 +1,169 @@
1
+ package web
2
+
3
+ import (
4
+ "bytes"
5
+ "crypto/sha256"
6
+ "errors"
7
+ "io"
8
+ "mime/multipart"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+ "testing"
15
+ )
16
+
17
+ type mpPart struct{ name, filename, body string }
18
+
19
+ func mpRequest(t *testing.T, parts []mpPart) *http.Request {
20
+ t.Helper()
21
+ var buf bytes.Buffer
22
+ w := multipart.NewWriter(&buf)
23
+ for _, p := range parts {
24
+ disp := `form-data; name="` + p.name + `"`
25
+ if p.filename != "" {
26
+ disp += `; filename="` + p.filename + `"`
27
+ }
28
+ pw, err := w.CreatePart(map[string][]string{"Content-Disposition": {disp}})
29
+ if err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ _, _ = pw.Write([]byte(p.body))
33
+ }
34
+ _ = w.Close()
35
+ r := httptest.NewRequest("POST", "/ideas", &buf)
36
+ r.Header.Set("Content-Type", w.FormDataContentType())
37
+ return r
38
+ }
39
+
40
+ func spec(dir string) MultipartSpec {
41
+ return MultipartSpec{FileField: "slides", TextFields: []string{"name"}, TooLarge: CodeFileTooLarge, TempDir: dir}
42
+ }
43
+
44
+ func assertEmpty(t *testing.T, dir string) {
45
+ t.Helper()
46
+ if es, _ := os.ReadDir(dir); len(es) != 0 {
47
+ t.Fatalf("temp dir not empty: %v", es)
48
+ }
49
+ }
50
+
51
+ func wantCode(t *testing.T, err error, code ErrorCode) {
52
+ t.Helper()
53
+ var e *Error
54
+ if !errors.As(err, &e) || e.Code != code {
55
+ t.Fatalf("err = %v, want %s", err, code)
56
+ }
57
+ }
58
+
59
+ func TestReadMultipartSuccess(t *testing.T) {
60
+ dir := t.TempDir()
61
+ r := mpRequest(t, []mpPart{{"name", "", "Idea"}, {"extra", "", "ignored"}, {"slides", `a/b\c.pdf`, "%PDF-1"}})
62
+ f, err := ReadMultipart(r, spec(dir))
63
+ if err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ if f.Fields["name"] != "Idea" || f.File.Size != 6 || f.File.Filename != `a/b\c.pdf` {
67
+ t.Fatalf("form = %+v file = %+v", f.Fields, f.File)
68
+ }
69
+ if f.File.SHA256 != sha256.Sum256([]byte("%PDF-1")) {
70
+ t.Fatal("sha256 mismatch")
71
+ }
72
+ b, _ := io.ReadAll(f.File.Open())
73
+ head := make([]byte, 5)
74
+ _, _ = f.File.ReaderAt().ReadAt(head, 0)
75
+ if string(b) != "%PDF-1" || string(head) != "%PDF-" {
76
+ t.Fatalf("content %q head %q", b, head)
77
+ }
78
+ if es, _ := os.ReadDir(dir); len(es) != 1 {
79
+ t.Fatalf("want one temp file while open, got %d", len(es))
80
+ }
81
+ if err := f.Close(); err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ if err := f.Close(); err != nil {
85
+ t.Fatal("second Close must be a no-op:", err)
86
+ }
87
+ assertEmpty(t, dir)
88
+ }
89
+
90
+ func TestReadMultipartMalformedRemovesTempFile(t *testing.T) {
91
+ tests := []struct {
92
+ name string
93
+ parts []mpPart
94
+ }{
95
+ {"missing file", []mpPart{{"name", "", "x"}}},
96
+ {"missing text", []mpPart{{"slides", "a.pdf", "x"}}},
97
+ {"duplicate file", []mpPart{{"name", "", "x"}, {"slides", "a.pdf", "x"}, {"slides", "b.pdf", "y"}}},
98
+ {"duplicate text", []mpPart{{"name", "", "x"}, {"name", "", "y"}, {"slides", "a.pdf", "x"}}},
99
+ {"file without filename", []mpPart{{"name", "", "x"}, {"slides", "", "x"}}},
100
+ {"non-UTF-8 filename", []mpPart{{"name", "", "x"}, {"slides", "\xff.pdf", "x"}}},
101
+ {"non-UTF-8 text", []mpPart{{"name", "", "\xff"}, {"slides", "a.pdf", "x"}}},
102
+ {"oversized text", []mpPart{{"name", "", strings.Repeat("a", maxTextPartBytes+1)}, {"slides", "a.pdf", "x"}}},
103
+ }
104
+ for _, tc := range tests {
105
+ t.Run(tc.name, func(t *testing.T) {
106
+ dir := t.TempDir()
107
+ f, err := ReadMultipart(mpRequest(t, tc.parts), spec(dir))
108
+ if f != nil {
109
+ t.Fatal("form must be nil on error")
110
+ }
111
+ wantCode(t, err, CodeMalformedRequest)
112
+ assertEmpty(t, dir)
113
+ })
114
+ }
115
+ }
116
+
117
+ func TestReadMultipartNotMultipart(t *testing.T) {
118
+ r := httptest.NewRequest("POST", "/ideas", strings.NewReader("{}"))
119
+ r.Header.Set("Content-Type", "application/json")
120
+ _, err := ReadMultipart(r, spec(t.TempDir()))
121
+ wantCode(t, err, CodeMalformedRequest)
122
+ }
123
+
124
+ func TestReadMultipartTruncatedBody(t *testing.T) {
125
+ dir := t.TempDir()
126
+ r := mpRequest(t, []mpPart{{"name", "", "x"}, {"slides", "a.pdf", strings.Repeat("a", 1000)}})
127
+ body, _ := io.ReadAll(r.Body)
128
+ r.Body = io.NopCloser(bytes.NewReader(body[:len(body)/2]))
129
+ _, err := ReadMultipart(r, spec(dir))
130
+ wantCode(t, err, CodeMalformedRequest)
131
+ assertEmpty(t, dir)
132
+ }
133
+
134
+ func TestReadMultipartBodyLimit(t *testing.T) {
135
+ for _, tc := range []struct {
136
+ name string
137
+ parts []mpPart
138
+ }{
139
+ {"inside file part", []mpPart{{"name", "", "x"}, {"slides", "a.pdf", strings.Repeat("a", 4000)}}},
140
+ {"inside ignored part", []mpPart{{"junk", "", strings.Repeat("a", 4000)}, {"name", "", "x"}, {"slides", "a.pdf", "x"}}},
141
+ {"inside text part", []mpPart{{"name", "", strings.Repeat("a", 4000)}, {"slides", "a.pdf", "x"}}},
142
+ } {
143
+ t.Run(tc.name, func(t *testing.T) {
144
+ dir := t.TempDir()
145
+ r := mpRequest(t, tc.parts)
146
+ rec := httptest.NewRecorder()
147
+ r.Body = http.MaxBytesReader(rec, r.Body, 1024)
148
+ _, err := ReadMultipart(r, spec(dir))
149
+ wantCode(t, err, CodeFileTooLarge)
150
+ assertEmpty(t, dir)
151
+ })
152
+ }
153
+ }
154
+
155
+ func TestReadMultipartTempDirUnwritable(t *testing.T) {
156
+ missing := filepath.Join(t.TempDir(), "nope")
157
+ _, err := ReadMultipart(mpRequest(t, []mpPart{{"name", "", "x"}, {"slides", "a.pdf", "x"}}), spec(missing))
158
+ var e *Error
159
+ if err == nil || errors.As(err, &e) {
160
+ t.Fatalf("want plain (500) error, got %v", err)
161
+ }
162
+ }
163
+
164
+ func TestFormCloseNil(t *testing.T) {
165
+ var f *Form
166
+ if err := f.Close(); err != nil {
167
+ t.Fatal(err)
168
+ }
169
+ }
@@ -17,6 +17,7 @@ type FieldError struct {
17
17
  Field string `json:"field"`
18
18
  Code ErrorCode `json:"code"`
19
19
  Expected string `json:"expected,omitempty"`
20
+ Rule string `json:"rule,omitempty"` // PASSWORD_POLICY only: the violated rule id (PRD §6.4)
20
21
  }
21
22
 
22
23
  // Error is the one error type handlers and services return. The central
@@ -1,8 +1,10 @@
1
1
  package web
2
2
 
3
3
  import (
4
+ "context"
4
5
  "net/http"
5
6
  "net/netip"
7
+ "strings"
6
8
 
7
9
  "github.com/go-chi/chi/v5"
8
10
  )
@@ -17,8 +19,47 @@ func RoutePattern(r *http.Request) string {
17
19
  return ""
18
20
  }
19
21
 
20
- // ClientIP is the TCP peer. Trusted-proxy handling is not decided yet.
22
+ type clientIPKey struct{}
23
+
24
+ // TrustedProxies resolves the client address from X-Forwarded-For when hops > 0
25
+ // and stores it for ClientIP. hops is the number of proxies in front of the
26
+ // service that append to the header; the client is the hops-th entry from the
27
+ // right (earlier entries are client-supplied and ignored). With hops <= 0, or
28
+ // when the header has fewer than hops valid entries, the TCP peer is used.
29
+ func TrustedProxies(hops int) func(http.Handler) http.Handler {
30
+ return func(next http.Handler) http.Handler {
31
+ if hops <= 0 {
32
+ return next
33
+ }
34
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35
+ if ip, ok := forwardedClient(r.Header.Values("X-Forwarded-For"), hops); ok {
36
+ r = r.WithContext(context.WithValue(r.Context(), clientIPKey{}, ip))
37
+ }
38
+ next.ServeHTTP(w, r)
39
+ })
40
+ }
41
+ }
42
+
43
+ func forwardedClient(values []string, hops int) (netip.Addr, bool) {
44
+ var parts []string
45
+ for _, v := range values {
46
+ parts = append(parts, strings.Split(v, ",")...)
47
+ }
48
+ if len(parts) < hops {
49
+ return netip.Addr{}, false
50
+ }
51
+ ip, err := netip.ParseAddr(strings.TrimSpace(parts[len(parts)-hops]))
52
+ if err != nil {
53
+ return netip.Addr{}, false
54
+ }
55
+ return ip.WithZone("").Unmap(), true
56
+ }
57
+
58
+ // ClientIP is the address TrustedProxies resolved, else the TCP peer.
21
59
  func ClientIP(r *http.Request) netip.Addr {
60
+ if ip, ok := r.Context().Value(clientIPKey{}).(netip.Addr); ok {
61
+ return ip
62
+ }
22
63
  ap, err := netip.ParseAddrPort(r.RemoteAddr)
23
64
  if err != nil {
24
65
  return netip.Addr{}
@@ -7,6 +7,7 @@ import (
7
7
  "log/slog"
8
8
  "net/http"
9
9
  "net/http/httptest"
10
+ "net/netip"
10
11
  "os"
11
12
  "sort"
12
13
  "strings"
@@ -181,3 +182,38 @@ func TestFieldErrorsRendered(t *testing.T) {
181
182
  t.Fatalf("%d %s", rec.Code, rec.Body)
182
183
  }
183
184
  }
185
+
186
+ func TestClientIPTrustedProxies(t *testing.T) {
187
+ for name, tc := range map[string]struct {
188
+ hops int
189
+ xff []string
190
+ want string
191
+ }{
192
+ "hops 0 ignores header": {0, []string{"198.51.100.1"}, "192.0.2.9"},
193
+ "one hop takes last entry": {1, []string{"203.0.113.7, 198.51.100.1"}, "198.51.100.1"},
194
+ "spoofed prefix is ignored": {1, []string{"10.0.0.1, 198.51.100.1"}, "198.51.100.1"},
195
+ "two hops": {2, []string{"203.0.113.7, 198.51.100.1, 10.1.1.1"}, "198.51.100.1"},
196
+ "several header lines": {1, []string{"203.0.113.7", "198.51.100.1"}, "198.51.100.1"},
197
+ "too few entries": {2, []string{"198.51.100.1"}, "192.0.2.9"},
198
+ "no header": {1, nil, "192.0.2.9"},
199
+ "garbage entry": {1, []string{"198.51.100.1, nope"}, "192.0.2.9"},
200
+ "ipv4-mapped and zone": {1, []string{"::ffff:198.51.100.1"}, "198.51.100.1"},
201
+ "ipv6": {1, []string{"2001:db8::1"}, "2001:db8::1"},
202
+ "empty entry": {1, []string{"198.51.100.1,"}, "192.0.2.9"},
203
+ } {
204
+ t.Run(name, func(t *testing.T) {
205
+ r := httptest.NewRequest("GET", "/", nil)
206
+ r.RemoteAddr = "192.0.2.9:4000"
207
+ for _, v := range tc.xff {
208
+ r.Header.Add("X-Forwarded-For", v)
209
+ }
210
+ var got netip.Addr
211
+ web.TrustedProxies(tc.hops)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
212
+ got = web.ClientIP(r)
213
+ })).ServeHTTP(httptest.NewRecorder(), r)
214
+ if got.String() != tc.want {
215
+ t.Fatalf("got %s want %s", got, tc.want)
216
+ }
217
+ })
218
+ }
219
+ }
@@ -0,0 +1,43 @@
1
+ package storage
2
+
3
+ import (
4
+ "golang.org/x/text/unicode/norm"
5
+
6
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
7
+ )
8
+
9
+ // MaxFilenameBytes is the longest expected filename in UTF-8 bytes (PRD §6.2).
10
+ const MaxFilenameBytes = 255
11
+
12
+ // longestExt is the extension used for the length rule: the file type is
13
+ // unknown at create/edit time, so ".pptx" (the longer one) is assumed.
14
+ const longestExt = ".pptx"
15
+
16
+ // ExpectedFilenameBase builds {I|O}_{org}_{leaderName}_{leaderEmpNo}_{ideaName}
17
+ // without extension by plain concatenation (PRD §6.8). All inputs are the
18
+ // already cleaned values; they are NFC-normalized again so the result never
19
+ // depends on how the caller stored them. category is COOL_IDEA or
20
+ // OUTSTANDING_IMPACT; any other value is a programming error and is reported
21
+ // as INTERNAL_ERROR rather than guessed.
22
+ func ExpectedFilenameBase(category, org, leaderName, leaderEmpNo, ideaName string) (string, error) {
23
+ var prefix string
24
+ switch category {
25
+ case "COOL_IDEA":
26
+ prefix = "I"
27
+ case "OUTSTANDING_IMPACT":
28
+ prefix = "O"
29
+ default:
30
+ return "", web.NewError(web.CodeInternalError, "")
31
+ }
32
+ base := prefix + "_" + org + "_" + leaderName + "_" + leaderEmpNo + "_" + ideaName
33
+ return norm.NFC.String(base), nil
34
+ }
35
+
36
+ // CheckFilenameLength enforces PRD §6.2: base plus ".pptx" must not exceed
37
+ // 255 UTF-8 bytes, else 422 FILENAME_TOO_LONG.
38
+ func CheckFilenameLength(base string) error {
39
+ if len(base)+len(longestExt) > MaxFilenameBytes {
40
+ return web.NewError(web.CodeFilenameTooLong, "Expected filename exceeds 255 UTF-8 bytes.")
41
+ }
42
+ return nil
43
+ }
@@ -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
+ }