@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
@@ -71,6 +71,27 @@ func (e LoginResponseRole) Valid() bool {
71
71
  }
72
72
  }
73
73
 
74
+ // CreateIdeaForm defines model for CreateIdeaForm.
75
+ type CreateIdeaForm struct {
76
+ // Category COOL_IDEA or OUTSTANDING_IMPACT
77
+ Category string `json:"category"`
78
+
79
+ // Direction One of the five ESG directions (PRD 6.6)
80
+ Direction string `json:"direction"`
81
+
82
+ // Name 1-75 code points after clean-up; no / \ : * ? " < > |
83
+ Name string `json:"name"`
84
+
85
+ // Org AAID, BSID, ICSD, TSID, PLED or DEID
86
+ Org string `json:"org"`
87
+
88
+ // Slides One PDF or PPTX, at most 5 MiB, named {I|O}_{org}_{leaderName}_{leaderEmpNo}_{name}.{pdf|pptx}
89
+ Slides openapi_types.File `json:"slides"`
90
+
91
+ // Summary 1-300 code points after clean-up
92
+ Summary string `json:"summary"`
93
+ }
94
+
74
95
  // CsrfTokenResponse defines model for CsrfTokenResponse.
75
96
  type CsrfTokenResponse struct {
76
97
  Token string `json:"token"`
@@ -93,6 +114,9 @@ type FieldError struct {
93
114
  Code string `json:"code"`
94
115
  Expected *string `json:"expected,omitempty"`
95
116
  Field string `json:"field"`
117
+
118
+ // Rule PASSWORD_POLICY only, the violated rule id (PRD 6.4)
119
+ Rule *string `json:"rule,omitempty"`
96
120
  }
97
121
 
98
122
  // IdeaDetail defines model for IdeaDetail.
@@ -245,6 +269,33 @@ type RegisterParams struct {
245
269
  type ListIdeasParams struct {
246
270
  Page *int `form:"page,omitempty" json:"page,omitempty"`
247
271
  Size *int `form:"size,omitempty" json:"size,omitempty"`
272
+
273
+ // Org Closed set; an invalid value is 422 INVALID_ENUM (never ignored). Validated in the handler.
274
+ Org *string `form:"org,omitempty" json:"org,omitempty"`
275
+ Category *string `form:"category,omitempty" json:"category,omitempty"`
276
+ Direction *string `form:"direction,omitempty" json:"direction,omitempty"`
277
+ LeaderEmpNo *string `form:"leaderEmpNo,omitempty" json:"leaderEmpNo,omitempty"`
278
+
279
+ // CreatedFrom ISO 8601 instant with offset or Z; inclusive. Validated in the handler.
280
+ CreatedFrom *string `form:"createdFrom,omitempty" json:"createdFrom,omitempty"`
281
+
282
+ // CreatedTo ISO 8601 instant with offset or Z; exclusive. Validated in the handler.
283
+ CreatedTo *string `form:"createdTo,omitempty" json:"createdTo,omitempty"`
284
+
285
+ // Q Case-insensitive "name contains"; `%` and `_` are literal.
286
+ Q *string `form:"q,omitempty" json:"q,omitempty"`
287
+
288
+ // Sort createdAt (default) or name; anything else is 422 INVALID_ENUM. Validated in the handler.
289
+ Sort *string `form:"sort,omitempty" json:"sort,omitempty"`
290
+
291
+ // Order asc or desc (default desc for createdAt, asc for name); anything else is 422 INVALID_ENUM. Validated in the handler.
292
+ Order *string `form:"order,omitempty" json:"order,omitempty"`
293
+ }
294
+
295
+ // CreateIdeaParams defines parameters for CreateIdea.
296
+ type CreateIdeaParams struct {
297
+ // XCSRFToken Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400).
298
+ XCSRFToken *CsrfToken `json:"X-CSRF-Token,omitempty"`
248
299
  }
249
300
 
250
301
  // LoginJSONRequestBody defines body for Login for application/json ContentType.
@@ -253,6 +304,9 @@ type LoginJSONRequestBody = LoginRequest
253
304
  // RegisterJSONRequestBody defines body for Register for application/json ContentType.
254
305
  type RegisterJSONRequestBody = RegisterRequest
255
306
 
307
+ // CreateIdeaMultipartRequestBody defines body for CreateIdea for multipart/form-data ContentType.
308
+ type CreateIdeaMultipartRequestBody = CreateIdeaForm
309
+
256
310
  // ServerInterface represents all server handlers.
257
311
  type ServerInterface interface {
258
312
  // GetCsrfToken Session-bound CSRF token (anonymous before login)
@@ -276,9 +330,15 @@ type ServerInterface interface {
276
330
  // ListIdeas List Ideas visible to the caller
277
331
  // (GET /ideas)
278
332
  ListIdeas(w http.ResponseWriter, r *http.Request, params ListIdeasParams)
333
+ // CreateIdea Create an Idea with its slides (User only)
334
+ // (POST /ideas)
335
+ CreateIdea(w http.ResponseWriter, r *http.Request, params CreateIdeaParams)
279
336
  // GetIdea Idea detail
280
337
  // (GET /ideas/{id})
281
338
  GetIdea(w http.ResponseWriter, r *http.Request, id IdeaId)
339
+ // DownloadSlides Download the Idea's slides (Leader, Member, Admin)
340
+ // (GET /ideas/{id}/slides)
341
+ DownloadSlides(w http.ResponseWriter, r *http.Request, id IdeaId)
282
342
  }
283
343
 
284
344
  // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint.
@@ -327,12 +387,24 @@ func (_ Unimplemented) ListIdeas(w http.ResponseWriter, r *http.Request, params
327
387
  w.WriteHeader(http.StatusNotImplemented)
328
388
  }
329
389
 
390
+ // CreateIdea Create an Idea with its slides (User only)
391
+ // (POST /ideas)
392
+ func (_ Unimplemented) CreateIdea(w http.ResponseWriter, r *http.Request, params CreateIdeaParams) {
393
+ w.WriteHeader(http.StatusNotImplemented)
394
+ }
395
+
330
396
  // GetIdea Idea detail
331
397
  // (GET /ideas/{id})
332
398
  func (_ Unimplemented) GetIdea(w http.ResponseWriter, r *http.Request, id IdeaId) {
333
399
  w.WriteHeader(http.StatusNotImplemented)
334
400
  }
335
401
 
402
+ // DownloadSlides Download the Idea's slides (Leader, Member, Admin)
403
+ // (GET /ideas/{id}/slides)
404
+ func (_ Unimplemented) DownloadSlides(w http.ResponseWriter, r *http.Request, id IdeaId) {
405
+ w.WriteHeader(http.StatusNotImplemented)
406
+ }
407
+
336
408
  // ServerInterfaceWrapper converts contexts to parameters.
337
409
  type ServerInterfaceWrapper struct {
338
410
  Handler ServerInterface
@@ -542,6 +614,123 @@ func (siw *ServerInterfaceWrapper) ListIdeas(w http.ResponseWriter, r *http.Requ
542
614
  return
543
615
  }
544
616
 
617
+ // ------------- Optional query parameter "org" -------------
618
+
619
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "org", r.URL.Query(), &params.Org, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
620
+ if err != nil {
621
+ var requiredError *runtime.RequiredParameterError
622
+ if errors.As(err, &requiredError) {
623
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "org"})
624
+ } else {
625
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "org", Err: err})
626
+ }
627
+ return
628
+ }
629
+
630
+ // ------------- Optional query parameter "category" -------------
631
+
632
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "category", r.URL.Query(), &params.Category, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
633
+ if err != nil {
634
+ var requiredError *runtime.RequiredParameterError
635
+ if errors.As(err, &requiredError) {
636
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "category"})
637
+ } else {
638
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "category", Err: err})
639
+ }
640
+ return
641
+ }
642
+
643
+ // ------------- Optional query parameter "direction" -------------
644
+
645
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "direction", r.URL.Query(), &params.Direction, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
646
+ if err != nil {
647
+ var requiredError *runtime.RequiredParameterError
648
+ if errors.As(err, &requiredError) {
649
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "direction"})
650
+ } else {
651
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "direction", Err: err})
652
+ }
653
+ return
654
+ }
655
+
656
+ // ------------- Optional query parameter "leaderEmpNo" -------------
657
+
658
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "leaderEmpNo", r.URL.Query(), &params.LeaderEmpNo, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
659
+ if err != nil {
660
+ var requiredError *runtime.RequiredParameterError
661
+ if errors.As(err, &requiredError) {
662
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "leaderEmpNo"})
663
+ } else {
664
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "leaderEmpNo", Err: err})
665
+ }
666
+ return
667
+ }
668
+
669
+ // ------------- Optional query parameter "createdFrom" -------------
670
+
671
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "createdFrom", r.URL.Query(), &params.CreatedFrom, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
672
+ if err != nil {
673
+ var requiredError *runtime.RequiredParameterError
674
+ if errors.As(err, &requiredError) {
675
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "createdFrom"})
676
+ } else {
677
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "createdFrom", Err: err})
678
+ }
679
+ return
680
+ }
681
+
682
+ // ------------- Optional query parameter "createdTo" -------------
683
+
684
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "createdTo", r.URL.Query(), &params.CreatedTo, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
685
+ if err != nil {
686
+ var requiredError *runtime.RequiredParameterError
687
+ if errors.As(err, &requiredError) {
688
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "createdTo"})
689
+ } else {
690
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "createdTo", Err: err})
691
+ }
692
+ return
693
+ }
694
+
695
+ // ------------- Optional query parameter "q" -------------
696
+
697
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "q", r.URL.Query(), &params.Q, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
698
+ if err != nil {
699
+ var requiredError *runtime.RequiredParameterError
700
+ if errors.As(err, &requiredError) {
701
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "q"})
702
+ } else {
703
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "q", Err: err})
704
+ }
705
+ return
706
+ }
707
+
708
+ // ------------- Optional query parameter "sort" -------------
709
+
710
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "sort", r.URL.Query(), &params.Sort, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
711
+ if err != nil {
712
+ var requiredError *runtime.RequiredParameterError
713
+ if errors.As(err, &requiredError) {
714
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "sort"})
715
+ } else {
716
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sort", Err: err})
717
+ }
718
+ return
719
+ }
720
+
721
+ // ------------- Optional query parameter "order" -------------
722
+
723
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "order", r.URL.Query(), &params.Order, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
724
+ if err != nil {
725
+ var requiredError *runtime.RequiredParameterError
726
+ if errors.As(err, &requiredError) {
727
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "order"})
728
+ } else {
729
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "order", Err: err})
730
+ }
731
+ return
732
+ }
733
+
545
734
  handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
546
735
  siw.Handler.ListIdeas(w, r, params)
547
736
  }))
@@ -553,6 +742,47 @@ func (siw *ServerInterfaceWrapper) ListIdeas(w http.ResponseWriter, r *http.Requ
553
742
  handler.ServeHTTP(w, r)
554
743
  }
555
744
 
745
+ // CreateIdea operation middleware
746
+ func (siw *ServerInterfaceWrapper) CreateIdea(w http.ResponseWriter, r *http.Request) {
747
+
748
+ var err error
749
+ _ = err
750
+
751
+ // Parameter object where we will unmarshal all parameters from the context
752
+ var params CreateIdeaParams
753
+
754
+ headers := r.Header
755
+
756
+ // ------------- Optional header parameter "X-CSRF-Token" -------------
757
+ if valueList, found := headers[http.CanonicalHeaderKey("X-CSRF-Token")]; found {
758
+ var XCSRFToken CsrfToken
759
+ n := len(valueList)
760
+ if n != 1 {
761
+ siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "X-CSRF-Token", Count: n})
762
+ return
763
+ }
764
+
765
+ err = runtime.BindStyledParameterWithOptions("simple", "X-CSRF-Token", valueList[0], &XCSRFToken, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""})
766
+ if err != nil {
767
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "X-CSRF-Token", Err: err})
768
+ return
769
+ }
770
+
771
+ params.XCSRFToken = &XCSRFToken
772
+
773
+ }
774
+
775
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
776
+ siw.Handler.CreateIdea(w, r, params)
777
+ }))
778
+
779
+ for _, middleware := range siw.HandlerMiddlewares {
780
+ handler = middleware(handler)
781
+ }
782
+
783
+ handler.ServeHTTP(w, r)
784
+ }
785
+
556
786
  // GetIdea operation middleware
557
787
  func (siw *ServerInterfaceWrapper) GetIdea(w http.ResponseWriter, r *http.Request) {
558
788
 
@@ -579,6 +809,32 @@ func (siw *ServerInterfaceWrapper) GetIdea(w http.ResponseWriter, r *http.Reques
579
809
  handler.ServeHTTP(w, r)
580
810
  }
581
811
 
812
+ // DownloadSlides operation middleware
813
+ func (siw *ServerInterfaceWrapper) DownloadSlides(w http.ResponseWriter, r *http.Request) {
814
+
815
+ var err error
816
+ _ = err
817
+
818
+ // ------------- Path parameter "id" -------------
819
+ var id IdeaId
820
+
821
+ err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid", ValueIsUnescaped: r.URL.RawPath == ""})
822
+ if err != nil {
823
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
824
+ return
825
+ }
826
+
827
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
828
+ siw.Handler.DownloadSlides(w, r, id)
829
+ }))
830
+
831
+ for _, middleware := range siw.HandlerMiddlewares {
832
+ handler = middleware(handler)
833
+ }
834
+
835
+ handler.ServeHTTP(w, r)
836
+ }
837
+
582
838
  type UnescapedCookieParamError struct {
583
839
  ParamName string
584
840
  Err error
@@ -713,9 +969,15 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
713
969
  r.Group(func(r chi.Router) {
714
970
  r.Get(options.BaseURL+"/ideas", wrapper.ListIdeas)
715
971
  })
972
+ r.Group(func(r chi.Router) {
973
+ r.Post(options.BaseURL+"/ideas", wrapper.CreateIdea)
974
+ })
716
975
  r.Group(func(r chi.Router) {
717
976
  r.Get(options.BaseURL+"/ideas/{id}", wrapper.GetIdea)
718
977
  })
978
+ r.Group(func(r chi.Router) {
979
+ r.Get(options.BaseURL+"/ideas/{id}/slides", wrapper.DownloadSlides)
980
+ })
719
981
 
720
982
  return r
721
983
  }
@@ -0,0 +1,53 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "fmt"
8
+ "time"
9
+ )
10
+
11
+ // IPPolicy is the per-IP sliding-window rule (PRD §6.5).
12
+ type IPPolicy struct {
13
+ MaxFailures int
14
+ WindowSeconds int
15
+ }
16
+
17
+ // RecordIPFailure stores one failed attempt of ip at database time.
18
+ func RecordIPFailure(ctx context.Context, db *sql.DB, ip string) error {
19
+ if _, err := db.ExecContext(ctx,
20
+ `INSERT INTO login_attempt (ip, attempted_at) VALUES (?, NOW(6))`, ip); err != nil {
21
+ return fmt.Errorf("record login attempt: %w", err)
22
+ }
23
+ return nil
24
+ }
25
+
26
+ // IPRetryAfter returns how long ip stays over the limit, 0 when it is not.
27
+ // The address is over the limit while at least MaxFailures failures lie inside
28
+ // the window; it is free again when the MaxFailures-th newest one leaves it,
29
+ // so that row's age decides (with exactly MaxFailures rows this is the oldest).
30
+ func IPRetryAfter(ctx context.Context, db *sql.DB, ip string, p IPPolicy) (time.Duration, error) {
31
+ var us int64
32
+ err := db.QueryRowContext(ctx,
33
+ `SELECT TIMESTAMPDIFF(MICROSECOND, NOW(6), attempted_at + INTERVAL ? SECOND)
34
+ FROM login_attempt
35
+ WHERE ip = ? AND attempted_at > NOW(6) - INTERVAL ? SECOND
36
+ ORDER BY attempted_at DESC
37
+ LIMIT 1 OFFSET ?`, p.WindowSeconds, ip, p.WindowSeconds, p.MaxFailures-1).Scan(&us)
38
+ if errors.Is(err, sql.ErrNoRows) {
39
+ return 0, nil
40
+ }
41
+ if err != nil {
42
+ return 0, fmt.Errorf("read login attempts: %w", err)
43
+ }
44
+ return time.Duration(us) * time.Microsecond, nil
45
+ }
46
+
47
+ // DeleteOldAttempts removes at most limit login_attempt rows outside the
48
+ // window; IPRetryAfter only reads rows inside it.
49
+ func DeleteOldAttempts(ctx context.Context, db *sql.DB, p IPPolicy, limit int) (int64, error) {
50
+ return deleteRows(ctx, db, "purge login attempts",
51
+ `DELETE FROM login_attempt WHERE attempted_at <= NOW(6) - INTERVAL ? SECOND LIMIT ?`,
52
+ p.WindowSeconds, limit)
53
+ }
@@ -0,0 +1,77 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "fmt"
8
+ "time"
9
+ )
10
+
11
+ // LockPolicy is the key-lock rule (PRD §6.5), in seconds and failures.
12
+ type LockPolicy struct {
13
+ MaxFailures int
14
+ WindowSeconds int // a failure older than this no longer counts as consecutive
15
+ LockSeconds int // fixed lock length
16
+ }
17
+
18
+ // RecordFailure counts one failed attempt for key in ONE statement on database
19
+ // time. The assignments run left to right in MariaDB, so later columns see the
20
+ // new fail_count. A lock that is still active is never extended or re-counted
21
+ // (a concurrent attempt that passed Check before the lock was set); an expired
22
+ // lock or a stale window starts again at 1.
23
+ func RecordFailure(ctx context.Context, db *sql.DB, key string, p LockPolicy) error {
24
+ _, err := db.ExecContext(ctx,
25
+ `INSERT INTO login_lock (lock_key, fail_count, last_fail_at, locked_until)
26
+ VALUES (?, 1, NOW(6), NULL)
27
+ ON DUPLICATE KEY UPDATE
28
+ fail_count = IF(locked_until > NOW(6), fail_count,
29
+ IF(locked_until IS NOT NULL OR last_fail_at <= NOW(6) - INTERVAL ? SECOND, 1, fail_count + 1)),
30
+ locked_until = CASE WHEN locked_until > NOW(6) THEN locked_until
31
+ WHEN fail_count >= ? THEN NOW(6) + INTERVAL ? SECOND
32
+ ELSE NULL END,
33
+ last_fail_at = NOW(6)`,
34
+ key, p.WindowSeconds, p.MaxFailures, p.LockSeconds)
35
+ if err != nil {
36
+ return fmt.Errorf("record login failure: %w", err)
37
+ }
38
+ return nil
39
+ }
40
+
41
+ // LockRemaining returns how long key stays locked, 0 when it is not locked.
42
+ func LockRemaining(ctx context.Context, db *sql.DB, key string) (time.Duration, error) {
43
+ var us sql.NullInt64
44
+ err := db.QueryRowContext(ctx,
45
+ `SELECT TIMESTAMPDIFF(MICROSECOND, NOW(6), locked_until)
46
+ FROM login_lock WHERE lock_key = ? AND locked_until > NOW(6)`, key).Scan(&us)
47
+ if errors.Is(err, sql.ErrNoRows) {
48
+ return 0, nil
49
+ }
50
+ if err != nil {
51
+ return 0, fmt.Errorf("read login lock: %w", err)
52
+ }
53
+ return time.Duration(us.Int64) * time.Microsecond, nil
54
+ }
55
+
56
+ // ClearFailures resets the counter after a successful login. An active lock is
57
+ // left alone (a success cannot shorten it).
58
+ func ClearFailures(ctx context.Context, db *sql.DB, key string) error {
59
+ _, err := db.ExecContext(ctx,
60
+ `DELETE FROM login_lock WHERE lock_key = ? AND (locked_until IS NULL OR locked_until <= NOW(6))`, key)
61
+ if err != nil {
62
+ return fmt.Errorf("clear login failures: %w", err)
63
+ }
64
+ return nil
65
+ }
66
+
67
+ // DeleteStaleLocks removes at most limit login_lock rows that no longer
68
+ // matter: no active lock and the last failure outside the window. For such a
69
+ // row RecordFailure would start again at 1, exactly as for a missing row.
70
+ func DeleteStaleLocks(ctx context.Context, db *sql.DB, p LockPolicy, limit int) (int64, error) {
71
+ return deleteRows(ctx, db, "purge login locks",
72
+ `DELETE FROM login_lock
73
+ WHERE last_fail_at <= NOW(6) - INTERVAL ? SECOND
74
+ AND (locked_until IS NULL OR locked_until <= NOW(6))
75
+ LIMIT ?`,
76
+ p.WindowSeconds, limit)
77
+ }
@@ -0,0 +1,42 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+ "time"
8
+ )
9
+
10
+ // ClaimThrottleAudit reports whether the caller may write the THROTTLED audit
11
+ // row of (scope, subject): true for the first throttled attempt of a window,
12
+ // false while an earlier claim is still running. A winning claim holds until
13
+ // database now + hold. The claim is atomic, so concurrent attempts on several
14
+ // replicas produce exactly one winner.
15
+ func ClaimThrottleAudit(ctx context.Context, db *sql.DB, scope, subject string, hold time.Duration) (bool, error) {
16
+ us := hold.Microseconds()
17
+ res, err := db.ExecContext(ctx,
18
+ `UPDATE login_throttle_audit SET audited_until = NOW(6) + INTERVAL ? MICROSECOND
19
+ WHERE scope = ? AND subject = ? AND audited_until <= NOW(6)`, us, scope, subject)
20
+ if err != nil {
21
+ return false, fmt.Errorf("claim throttle audit: %w", err)
22
+ }
23
+ if n, _ := res.RowsAffected(); n > 0 {
24
+ return true, nil
25
+ }
26
+ res, err = db.ExecContext(ctx,
27
+ `INSERT IGNORE INTO login_throttle_audit (scope, subject, audited_until)
28
+ VALUES (?, ?, NOW(6) + INTERVAL ? MICROSECOND)`, scope, subject, us)
29
+ if err != nil {
30
+ return false, fmt.Errorf("claim throttle audit: %w", err)
31
+ }
32
+ n, _ := res.RowsAffected()
33
+ return n > 0, nil
34
+ }
35
+
36
+ // DeleteExpiredThrottleAudits removes at most limit claims that have run out.
37
+ // ClaimThrottleAudit treats a missing row like an expired one (the next
38
+ // throttled attempt wins), so deleting it changes no outcome.
39
+ func DeleteExpiredThrottleAudits(ctx context.Context, db *sql.DB, limit int) (int64, error) {
40
+ return deleteRows(ctx, db, "purge throttle audits",
41
+ `DELETE FROM login_throttle_audit WHERE audited_until <= NOW(6) LIMIT ?`, limit)
42
+ }
@@ -99,19 +99,27 @@ func DeleteByUser(ctx context.Context, db *sql.DB, userID uuid.UUID) error {
99
99
  return nil
100
100
  }
101
101
 
102
- // DeleteExpired removes sessions past either timeout and returns how many.
103
- func DeleteExpired(ctx context.Context, db *sql.DB, l Limits) (int64, error) {
104
- res, err := db.ExecContext(ctx,
102
+ // DeleteExpired removes at most limit sessions past either timeout and
103
+ // returns how many. The condition is the exact complement of Touch's, so a row
104
+ // it deletes could no longer be validated anyway.
105
+ func DeleteExpired(ctx context.Context, db *sql.DB, l Limits, limit int) (int64, error) {
106
+ return deleteRows(ctx, db, "purge sessions",
105
107
  `DELETE FROM session
106
108
  WHERE last_access_at <= NOW(6) - INTERVAL ? SECOND
107
- OR created_at <= NOW(6) - INTERVAL ? SECOND`,
108
- l.IdleSeconds, l.AbsoluteSeconds)
109
+ OR created_at <= NOW(6) - INTERVAL ? SECOND
110
+ LIMIT ?`,
111
+ l.IdleSeconds, l.AbsoluteSeconds, limit)
112
+ }
113
+
114
+ // deleteRows runs one DELETE and returns the affected row count.
115
+ func deleteRows(ctx context.Context, db *sql.DB, what, query string, args ...any) (int64, error) {
116
+ res, err := db.ExecContext(ctx, query, args...)
109
117
  if err != nil {
110
- return 0, fmt.Errorf("purge sessions: %w", err)
118
+ return 0, fmt.Errorf("%s: %w", what, err)
111
119
  }
112
120
  n, err := res.RowsAffected()
113
121
  if err != nil {
114
- return 0, fmt.Errorf("purge sessions rows: %w", err)
122
+ return 0, fmt.Errorf("%s rows: %w", what, err)
115
123
  }
116
124
  return n, nil
117
125
  }
@@ -33,15 +33,20 @@ const (
33
33
 
34
34
  var empNoFormat = regexp.MustCompile(`^[0-9]{6}$`)
35
35
 
36
- // Throttle is the login throttling hook (PRD §6.5). B1b-04 implements it on
37
- // login_lock / login_attempt; until then NoThrottle is wired. Errors fail the
38
- // login closed (500): an unreachable counter must not disable the limit.
36
+ // Throttle is the login throttling hook (PRD §6.5); DBThrottle implements it on
37
+ // login_lock. Errors fail the login closed (500): an unreachable counter must
38
+ // not disable the limit.
39
39
  type Throttle interface {
40
40
  // Check runs before any password work. A positive retryAfter means the
41
41
  // attempt is throttled: it answers 429, runs no bcrypt and is not counted.
42
42
  // key is the submitted employee-ID string as typed; implementations strip
43
43
  // control characters and cut it to 32 characters.
44
44
  Check(ctx context.Context, key string, ip netip.Addr) (retryAfter time.Duration, err error)
45
+ // AuditOnce is asked after Check reported a wait. It returns true for the
46
+ // first throttled attempt of a window and false for the rest, so a throttled
47
+ // key or address is audited once per window (PRD §6.11). Its error is not
48
+ // fatal: the attempt is audited anyway.
49
+ AuditOnce(ctx context.Context, key string, ip netip.Addr) (bool, error)
45
50
  // Fail counts one failed attempt (also for unknown accounts).
46
51
  Fail(ctx context.Context, key string, ip netip.Addr) error
47
52
  // Succeed clears the key's counter.
@@ -54,6 +59,9 @@ type NoThrottle struct{}
54
59
  // Check implements Throttle.
55
60
  func (NoThrottle) Check(context.Context, string, netip.Addr) (time.Duration, error) { return 0, nil }
56
61
 
62
+ // AuditOnce implements Throttle.
63
+ func (NoThrottle) AuditOnce(context.Context, string, netip.Addr) (bool, error) { return true, nil }
64
+
57
65
  // Fail implements Throttle.
58
66
  func (NoThrottle) Fail(context.Context, string, netip.Addr) error { return nil }
59
67
 
@@ -122,7 +130,11 @@ func (l *Logins) Login(ctx context.Context, cmd LoginCommand, req Request) (Logg
122
130
  if retry > 0 {
123
131
  e := web.NewError(web.CodeLoginThrottled, "too many login attempts")
124
132
  e.RetryAfter = retry
125
- return LoggedIn{}, loginRefused(e, audit.Throttled, "throttled", cmd.EmpNo)
133
+ if first, err := l.Throttle.AuditOnce(ctx, cmd.EmpNo, req.IP); err != nil || first {
134
+ // An error audits anyway: a duplicate row beats a missing one.
135
+ return LoggedIn{}, loginRefused(e, audit.Throttled, "throttled", cmd.EmpNo)
136
+ }
137
+ return LoggedIn{}, e
126
138
  }
127
139
 
128
140
  var (
@@ -42,6 +42,9 @@ func (f *fakeThrottle) Check(_ context.Context, key string, _ netip.Addr) (time.
42
42
  f.lastKey = key
43
43
  return f.retry, f.err
44
44
  }
45
+ func (f *fakeThrottle) AuditOnce(context.Context, string, netip.Addr) (bool, error) {
46
+ return true, nil
47
+ }
45
48
  func (f *fakeThrottle) Fail(context.Context, string, netip.Addr) error {
46
49
  f.mu.Lock()
47
50
  defer f.mu.Unlock()
@@ -67,13 +70,23 @@ func newLoginEnv(t *testing.T) loginEnv {
67
70
  }
68
71
 
69
72
  func newLoginEnvLog(t *testing.T, log *slog.Logger) loginEnv {
73
+ t.Helper()
74
+ return newLoginEnvWith(t, log, nil)
75
+ }
76
+
77
+ // newLoginEnvWith wires mk(db) as the throttle; nil keeps the fake.
78
+ func newLoginEnvWith(t *testing.T, log *slog.Logger, mk func(*sql.DB) auth.Throttle) loginEnv {
70
79
  t.Helper()
71
80
  db := dbtest.New(t)
72
81
  auditSvc := audit.New(db, nil)
73
82
  errs := server.ErrorAdapter{Audit: auditSvc}
74
83
  sessions := auth.New(db)
75
84
  th := &fakeThrottle{}
76
- logins, err := auth.NewLogins(sessions, auditSvc, th)
85
+ var thr auth.Throttle = th
86
+ if mk != nil {
87
+ thr = mk(db)
88
+ }
89
+ logins, err := auth.NewLogins(sessions, auditSvc, thr)
77
90
  if err != nil {
78
91
  t.Fatal(err)
79
92
  }