@johnnyboy11234/esg-idea-contest 0.3.0 → 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/load_test.go +12 -0
- package/backend/test/matrix/matrix_integration_test.go +86 -5
- 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/src/app/App.tsx +11 -2
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- 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 +48 -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 +21 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package deadline_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"database/sql"
|
|
8
|
+
"errors"
|
|
9
|
+
"testing"
|
|
10
|
+
|
|
11
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
|
|
12
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
|
|
13
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
14
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// TestGateAssertOpenInTx runs the authoritative check inside a real write
|
|
19
|
+
// transaction for NOT_OPEN, OPEN and CLOSED, for a User and an Admin.
|
|
20
|
+
func TestGateAssertOpenInTx(t *testing.T) {
|
|
21
|
+
ctx := context.Background()
|
|
22
|
+
sdb := dbtest.New(t)
|
|
23
|
+
g := deadline.Gate{Store: deadline.SQLStore{DB: sdb}}
|
|
24
|
+
user := security.Principal{Role: security.RoleUser}
|
|
25
|
+
admin := security.Principal{Role: security.RoleAdmin}
|
|
26
|
+
|
|
27
|
+
assert := func(actor security.Principal) error {
|
|
28
|
+
_, err := db.InTx(ctx, sdb, func(ctx context.Context, tx *sql.Tx) error {
|
|
29
|
+
return g.AssertOpenInTx(ctx, tx, actor)
|
|
30
|
+
})
|
|
31
|
+
return err
|
|
32
|
+
}
|
|
33
|
+
set := func(t *testing.T, expr string) {
|
|
34
|
+
t.Helper()
|
|
35
|
+
if _, err := sdb.ExecContext(ctx, "UPDATE contest_setting SET deadline = "+expr+" WHERE id = 1"); err != nil {
|
|
36
|
+
t.Fatal(err)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
code := func(err error) web.ErrorCode {
|
|
40
|
+
var e *web.Error
|
|
41
|
+
if errors.As(err, &e) {
|
|
42
|
+
return e.Code
|
|
43
|
+
}
|
|
44
|
+
return ""
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if got := code(assert(user)); got != web.CodeSubmissionNotOpen {
|
|
48
|
+
t.Fatalf("NOT_OPEN user: %q", got)
|
|
49
|
+
}
|
|
50
|
+
if err := assert(admin); err != nil {
|
|
51
|
+
t.Fatalf("NOT_OPEN admin: %v", err)
|
|
52
|
+
}
|
|
53
|
+
set(t, "NOW() + INTERVAL 1 HOUR")
|
|
54
|
+
if err := assert(user); err != nil {
|
|
55
|
+
t.Fatalf("OPEN user: %v", err)
|
|
56
|
+
}
|
|
57
|
+
set(t, "NOW() - INTERVAL 1 HOUR")
|
|
58
|
+
if got := code(assert(user)); got != web.CodeSubmissionClosed {
|
|
59
|
+
t.Fatalf("CLOSED user: %q", got)
|
|
60
|
+
}
|
|
61
|
+
if err := assert(admin); err != nil {
|
|
62
|
+
t.Fatalf("CLOSED admin: %v", err)
|
|
63
|
+
}
|
|
64
|
+
if got := code(g.Precheck(ctx, user)); got != web.CodeSubmissionClosed {
|
|
65
|
+
t.Fatalf("CLOSED precheck: %q", got)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
package deadline_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
|
|
10
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
11
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestGatePrecheck(t *testing.T) {
|
|
15
|
+
now := time.Date(2026, 11, 8, 12, 0, 0, 0, time.UTC)
|
|
16
|
+
future, past := now.Add(time.Hour), now.Add(-time.Hour)
|
|
17
|
+
user := security.Principal{Role: security.RoleUser}
|
|
18
|
+
admin := security.Principal{Role: security.RoleAdmin}
|
|
19
|
+
|
|
20
|
+
tests := []struct {
|
|
21
|
+
name string
|
|
22
|
+
d *time.Time
|
|
23
|
+
actor security.Principal
|
|
24
|
+
want web.ErrorCode
|
|
25
|
+
}{
|
|
26
|
+
{"user NOT_OPEN", nil, user, web.CodeSubmissionNotOpen},
|
|
27
|
+
{"user OPEN", &future, user, ""},
|
|
28
|
+
{"user CLOSED", &past, user, web.CodeSubmissionClosed},
|
|
29
|
+
{"user at the deadline instant is CLOSED", &now, user, web.CodeSubmissionClosed},
|
|
30
|
+
{"admin NOT_OPEN", nil, admin, ""},
|
|
31
|
+
{"admin CLOSED", &past, admin, ""},
|
|
32
|
+
}
|
|
33
|
+
for _, tc := range tests {
|
|
34
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
35
|
+
g := deadline.Gate{Store: fakeStore{snap: deadline.Snapshot{Deadline: tc.d, Now: now}}}
|
|
36
|
+
err := g.Precheck(context.Background(), tc.actor)
|
|
37
|
+
if tc.want == "" {
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatalf("got %v", err)
|
|
40
|
+
}
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
var e *web.Error
|
|
44
|
+
if !errors.As(err, &e) || e.Code != tc.want || e.Status != 403 {
|
|
45
|
+
t.Fatalf("got %v, want %s/403", err, tc.want)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func TestGatePrecheckStoreError(t *testing.T) {
|
|
52
|
+
boom := errors.New("db down")
|
|
53
|
+
g := deadline.Gate{Store: fakeStore{err: boom}}
|
|
54
|
+
if err := g.Precheck(context.Background(), security.Principal{Role: security.RoleUser}); !errors.Is(err, boom) {
|
|
55
|
+
t.Fatalf("got %v", err)
|
|
56
|
+
}
|
|
57
|
+
// Admin is exempt, so the store is not even read.
|
|
58
|
+
if err := g.Precheck(context.Background(), security.Principal{Role: security.RoleAdmin}); err != nil {
|
|
59
|
+
t.Fatalf("admin: %v", err)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
package idea
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"encoding/hex"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"errors"
|
|
9
|
+
"fmt"
|
|
10
|
+
"io"
|
|
11
|
+
"net/http"
|
|
12
|
+
"net/netip"
|
|
13
|
+
"slices"
|
|
14
|
+
|
|
15
|
+
"github.com/google/uuid"
|
|
16
|
+
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
18
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea/internal/store"
|
|
19
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
|
|
20
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
21
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/text"
|
|
22
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// Text limits in code points (PRD 6.6).
|
|
26
|
+
const (
|
|
27
|
+
maxIdeaName = 75
|
|
28
|
+
maxSummary = 300
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// Field names as they appear in errors[]; the order is the form order.
|
|
32
|
+
const (
|
|
33
|
+
fieldCategory = "category"
|
|
34
|
+
fieldOrg = "org"
|
|
35
|
+
fieldDirection = "direction"
|
|
36
|
+
fieldName = "name"
|
|
37
|
+
fieldSummary = "summary"
|
|
38
|
+
fieldSlides = "slides"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
// Audit reasons of a refused create (PRD 6.11: role and state are DENIED).
|
|
42
|
+
const (
|
|
43
|
+
auditReasonRole = "ROLE"
|
|
44
|
+
auditReasonState = "STATE"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
// contentTypes are the stored object types per accepted extension.
|
|
48
|
+
var contentTypes = map[string]string{
|
|
49
|
+
"pdf": "application/pdf",
|
|
50
|
+
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// SlideFile is the spooled slides part as the storage rules need it.
|
|
54
|
+
type SlideFile struct {
|
|
55
|
+
Filename string // as sent
|
|
56
|
+
Size int64
|
|
57
|
+
SHA256 [32]byte
|
|
58
|
+
Data io.ReaderAt // random access for the validator
|
|
59
|
+
Open func() io.Reader // a fresh reader from the start, for the upload
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// SlideRules is the storage-side slide logic (storage.ExpectedFilenameBase,
|
|
63
|
+
// CheckFilenameLength, Validate). It is injected by main because idea must not
|
|
64
|
+
// import storage (D29); every function returns *web.Error for client faults.
|
|
65
|
+
type SlideRules struct {
|
|
66
|
+
ExpectedBase func(category, org, leaderName, leaderEmpNo, ideaName string) (string, error)
|
|
67
|
+
CheckLength func(base string) error
|
|
68
|
+
// Validate runs PRD 6.8 steps 2–5 and returns the extension ("pdf"/"pptx").
|
|
69
|
+
Validate func(f SlideFile, expectedBase string) (ext string, err error)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ObjectStore is the part of the MinIO port create needs (storage.SlideStore).
|
|
73
|
+
type ObjectStore interface {
|
|
74
|
+
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// UploadAborter compensates an uploaded object whose transaction did not
|
|
78
|
+
// commit (storage.PendingDeletion.AbortUpload, Sequence.md §2): delete it now
|
|
79
|
+
// after a known rollback, keep it and record UPLOAD_ABORTED after an unknown
|
|
80
|
+
// outcome. It is best effort and never fails the request.
|
|
81
|
+
type UploadAborter interface {
|
|
82
|
+
AbortUpload(ctx context.Context, key string, outcome db.Outcome)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// CreateCommand is the raw, unvalidated form. The Leader is the caller.
|
|
86
|
+
type CreateCommand struct {
|
|
87
|
+
Category, Org, Direction, Name, Summary string
|
|
88
|
+
Slides SlideFile
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Request is the transport facts the audit row needs.
|
|
92
|
+
type Request struct {
|
|
93
|
+
Method, Route, UserAgent string
|
|
94
|
+
IP netip.Addr
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// validText is the cleaned, validated text of a create.
|
|
98
|
+
type validText struct {
|
|
99
|
+
category, org, direction, name, summary string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Create runs POST /ideas after the role check and Precheck (both done by the
|
|
103
|
+
// handler before the body is read). Order (PRD 6.1, 6.8, Sequence.md §3):
|
|
104
|
+
// text 422 (incl. FILENAME_TOO_LONG) → file 413/422/415/422 → duplicate
|
|
105
|
+
// pre-check 409 → upload (503) → transaction: AssertOpenInTx, INSERT,
|
|
106
|
+
// IDEA_CREATE. The returned Idea is read back inside the same transaction.
|
|
107
|
+
// A transaction that does not commit is compensated by its outcome (B3-09);
|
|
108
|
+
// an unknown outcome answers 503 SERVICE_UNAVAILABLE.
|
|
109
|
+
func (s *Service) Create(ctx context.Context, caller security.Principal, cmd CreateCommand, req Request) (Idea, error) {
|
|
110
|
+
leaderName, err := store.LeaderName(ctx, s.DB, caller.UserID)
|
|
111
|
+
if err != nil {
|
|
112
|
+
return Idea{}, err
|
|
113
|
+
}
|
|
114
|
+
v, base, err := s.validateText(cmd, leaderName, caller.EmpNo)
|
|
115
|
+
if err != nil {
|
|
116
|
+
return Idea{}, err
|
|
117
|
+
}
|
|
118
|
+
ext, err := s.Slides.Validate(cmd.Slides, base)
|
|
119
|
+
if err != nil {
|
|
120
|
+
return Idea{}, err
|
|
121
|
+
}
|
|
122
|
+
// 422 wins over 409 (PRD 6.1), so the pre-check runs after every validation.
|
|
123
|
+
taken, err := store.NameTaken(ctx, s.DB, caller.UserID, v.name)
|
|
124
|
+
if err != nil {
|
|
125
|
+
return Idea{}, err
|
|
126
|
+
}
|
|
127
|
+
if taken {
|
|
128
|
+
return Idea{}, duplicateErr(nil)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
n := store.NewIdea{
|
|
132
|
+
ID: uuid.New(), LeaderID: caller.UserID,
|
|
133
|
+
Category: v.category, Org: v.org, Direction: v.direction, Name: v.name, Summary: v.summary,
|
|
134
|
+
}
|
|
135
|
+
n.Slides = store.Slides{
|
|
136
|
+
ObjectKey: "slides/" + n.ID.String() + "/" + uuid.NewString() + "." + ext,
|
|
137
|
+
Ext: ext,
|
|
138
|
+
Size: cmd.Slides.Size,
|
|
139
|
+
SHA256: hex.EncodeToString(cmd.Slides.SHA256[:]),
|
|
140
|
+
}
|
|
141
|
+
// A failed upload leaves nothing: no row exists and no later step runs.
|
|
142
|
+
if err := s.Objects.Put(ctx, n.Slides.ObjectKey, cmd.Slides.Open(), n.Slides.Size, contentTypes[ext]); err != nil {
|
|
143
|
+
return Idea{}, err
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
after, err := createAfter(n)
|
|
147
|
+
if err != nil {
|
|
148
|
+
return Idea{}, err
|
|
149
|
+
}
|
|
150
|
+
var got Idea
|
|
151
|
+
out, err := db.InTx(ctx, s.DB, func(ctx context.Context, tx *sql.Tx) error {
|
|
152
|
+
if err := s.Gate.AssertOpenInTx(ctx, tx, caller); err != nil {
|
|
153
|
+
return denied(err, auditReasonState)
|
|
154
|
+
}
|
|
155
|
+
if err := store.InsertIdea(ctx, tx, n); err != nil {
|
|
156
|
+
return err
|
|
157
|
+
}
|
|
158
|
+
if err := s.Audit.Record(ctx, tx, audit.Event{
|
|
159
|
+
Action: audit.IdeaCreate, Outcome: audit.Success,
|
|
160
|
+
TargetType: audit.TargetIdea, TargetID: n.ID,
|
|
161
|
+
Method: req.Method, Route: req.Route, Status: http.StatusCreated,
|
|
162
|
+
IP: req.IP, UserAgent: req.UserAgent,
|
|
163
|
+
After: after,
|
|
164
|
+
}); err != nil {
|
|
165
|
+
return err
|
|
166
|
+
}
|
|
167
|
+
got, err = store.GetVisible(ctx, tx, n.ID, caller.UserID, caller.IsAdmin())
|
|
168
|
+
return err
|
|
169
|
+
})
|
|
170
|
+
if out == db.Committed {
|
|
171
|
+
return got, nil
|
|
172
|
+
}
|
|
173
|
+
// Known rollback: the object is deleted now. Unknown: the COMMIT may have
|
|
174
|
+
// been applied, so the object stays and the clean-up job keeps it if an
|
|
175
|
+
// idea row references the key.
|
|
176
|
+
s.Aborter.AbortUpload(ctx, n.Slides.ObjectKey, out)
|
|
177
|
+
if out == db.Unknown {
|
|
178
|
+
e := web.NewError(web.CodeServiceUnavailable, "The result of the request is unknown; check your Ideas before retrying.")
|
|
179
|
+
e.Cause = err
|
|
180
|
+
return Idea{}, e
|
|
181
|
+
}
|
|
182
|
+
if _, dup := db.DuplicateKey(err); dup {
|
|
183
|
+
return Idea{}, duplicateErr(err)
|
|
184
|
+
}
|
|
185
|
+
return Idea{}, err
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// validateText cleans and checks every text field, collecting each field's
|
|
189
|
+
// first error in form order (PRD 6.2). When category, org and name are valid,
|
|
190
|
+
// the expected filename is built and its length checked; FILENAME_TOO_LONG is
|
|
191
|
+
// reported in the name slot. On success it returns the expected base.
|
|
192
|
+
func (s *Service) validateText(cmd CreateCommand, leaderName, leaderEmpNo string) (validText, string, error) {
|
|
193
|
+
var (
|
|
194
|
+
v validText
|
|
195
|
+
fields []web.FieldError
|
|
196
|
+
)
|
|
197
|
+
add := func(field string, code web.ErrorCode) {
|
|
198
|
+
fields = append(fields, web.FieldError{Field: field, Code: code})
|
|
199
|
+
}
|
|
200
|
+
enum := func(field, raw string, allowed []string, dst *string) {
|
|
201
|
+
// Codes are compared exactly, never cleaned up into a match; only
|
|
202
|
+
// "empty after clean-up" is REQUIRED (PRD 6.1).
|
|
203
|
+
switch {
|
|
204
|
+
case text.Trim(raw) == "":
|
|
205
|
+
add(field, web.CodeRequired)
|
|
206
|
+
case !slices.Contains(allowed, raw):
|
|
207
|
+
add(field, web.CodeInvalidEnum)
|
|
208
|
+
default:
|
|
209
|
+
*dst = raw
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
enum(fieldCategory, cmd.Category, categories, &v.category)
|
|
213
|
+
enum(fieldOrg, cmd.Org, orgs, &v.org)
|
|
214
|
+
enum(fieldDirection, cmd.Direction, directions, &v.direction)
|
|
215
|
+
|
|
216
|
+
var base string
|
|
217
|
+
name, p := text.CheckName(cmd.Name, maxIdeaName)
|
|
218
|
+
switch {
|
|
219
|
+
case p != text.None:
|
|
220
|
+
add(fieldName, web.ErrorCode(p))
|
|
221
|
+
case v.category != "" && v.org != "":
|
|
222
|
+
b, err := s.Slides.ExpectedBase(v.category, v.org, leaderName, leaderEmpNo, name)
|
|
223
|
+
if err != nil {
|
|
224
|
+
return validText{}, "", err
|
|
225
|
+
}
|
|
226
|
+
if err := s.Slides.CheckLength(b); err != nil {
|
|
227
|
+
var e *web.Error
|
|
228
|
+
if !errors.As(err, &e) {
|
|
229
|
+
return validText{}, "", err
|
|
230
|
+
}
|
|
231
|
+
add(fieldName, e.Code)
|
|
232
|
+
} else {
|
|
233
|
+
base = b
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
v.name = name
|
|
237
|
+
|
|
238
|
+
summary, p := text.CheckSummary(cmd.Summary, maxSummary)
|
|
239
|
+
if p != text.None {
|
|
240
|
+
add(fieldSummary, web.ErrorCode(p))
|
|
241
|
+
}
|
|
242
|
+
v.summary = summary
|
|
243
|
+
|
|
244
|
+
if len(fields) > 0 {
|
|
245
|
+
e := web.NewError(topCode(fields), "the request has invalid fields")
|
|
246
|
+
e.Fields = fields
|
|
247
|
+
return validText{}, "", e
|
|
248
|
+
}
|
|
249
|
+
return v, base, nil
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// topCode is the response code of a 422: the first field error's code when it
|
|
253
|
+
// is a specific registered code, else VALIDATION_FAILED (error-codes.md §2).
|
|
254
|
+
func topCode(fields []web.FieldError) web.ErrorCode {
|
|
255
|
+
switch c := fields[0].Code; c {
|
|
256
|
+
case web.CodeInvalidNameChars, web.CodeFilenameTooLong:
|
|
257
|
+
return c
|
|
258
|
+
}
|
|
259
|
+
return web.CodeValidationFailed
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// createAfter is the IDEA_CREATE after document: every stored field, with the
|
|
263
|
+
// slide metadata but never the content (PRD 6.11).
|
|
264
|
+
func createAfter(n store.NewIdea) (json.RawMessage, error) {
|
|
265
|
+
b, err := json.Marshal(map[string]any{
|
|
266
|
+
fieldCategory: n.Category, fieldOrg: n.Org, fieldDirection: n.Direction,
|
|
267
|
+
fieldName: n.Name, fieldSummary: n.Summary,
|
|
268
|
+
fieldSlides: map[string]any{"ext": n.Slides.Ext, "size": n.Slides.Size, "sha256": n.Slides.SHA256},
|
|
269
|
+
})
|
|
270
|
+
if err != nil {
|
|
271
|
+
return nil, fmt.Errorf("marshal IDEA_CREATE after: %w", err)
|
|
272
|
+
}
|
|
273
|
+
return b, nil
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func duplicateErr(cause error) *web.Error {
|
|
277
|
+
e := web.NewError(web.CodeIdeaDuplicate, "You already lead an Idea with this name.")
|
|
278
|
+
e.Cause = cause
|
|
279
|
+
return e
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// denied marks a role or state refusal of a create for the DENIED audit row
|
|
283
|
+
// (track B, written by the central error adapter). Other errors pass through.
|
|
284
|
+
func denied(err error, reason string) error {
|
|
285
|
+
var e *web.Error
|
|
286
|
+
if errors.As(err, &e) && (e.Code == web.CodeForbidden ||
|
|
287
|
+
e.Code == web.CodeSubmissionNotOpen || e.Code == web.CodeSubmissionClosed) {
|
|
288
|
+
e.AuditAction = string(audit.IdeaCreate)
|
|
289
|
+
e.AuditReason = reason
|
|
290
|
+
}
|
|
291
|
+
return err
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// maxCreateBody is the application's body limit for a create: one 5 MiB file
|
|
295
|
+
// plus 64 KiB for the text parts and multipart framing (PRD 6.8). The exact
|
|
296
|
+
// 5 MiB file boundary is enforced by the validator, not by this limit.
|
|
297
|
+
const maxCreateBody = 5*1024*1024 + 64*1024
|
|
298
|
+
|
|
299
|
+
// createSpec is the one multipart shape of POST /ideas. Any other part (for
|
|
300
|
+
// example a client-sent leader) is drained and ignored (PRD 6.6).
|
|
301
|
+
var createSpec = web.MultipartSpec{
|
|
302
|
+
FileField: fieldSlides,
|
|
303
|
+
TextFields: []string{fieldCategory, fieldOrg, fieldDirection, fieldName, fieldSummary},
|
|
304
|
+
TooLarge: web.CodeFileTooLarge,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// CreateIdea serves POST /ideas: 201 + Location + IdeaDetail. Role 403 and the
|
|
308
|
+
// state pre-check run before the body is read (Backend.md §3).
|
|
309
|
+
func (h Handler) CreateIdea(w http.ResponseWriter, r *http.Request) {
|
|
310
|
+
p, ok := security.PrincipalFrom(r.Context())
|
|
311
|
+
if !ok {
|
|
312
|
+
h.Fail(w, r, web.NewError(web.CodeUnauthenticated, "authentication required"))
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
if p.IsAdmin() {
|
|
316
|
+
h.Fail(w, r, denied(web.NewError(web.CodeForbidden, "Admins cannot create Ideas."), auditReasonRole))
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
if err := h.Service.Gate.Precheck(r.Context(), p); err != nil {
|
|
320
|
+
h.Fail(w, r, denied(err, auditReasonState))
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
if r.ContentLength > maxCreateBody {
|
|
324
|
+
h.Fail(w, r, web.NewError(web.CodeFileTooLarge, "Request body too large."))
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody)
|
|
328
|
+
form, err := web.ReadMultipart(r, createSpec)
|
|
329
|
+
if err != nil {
|
|
330
|
+
h.Fail(w, r, err)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
defer func() { _ = form.Close() }()
|
|
334
|
+
|
|
335
|
+
f := form.File
|
|
336
|
+
got, err := h.Service.Create(r.Context(), p, CreateCommand{
|
|
337
|
+
Category: form.Fields[fieldCategory], Org: form.Fields[fieldOrg], Direction: form.Fields[fieldDirection],
|
|
338
|
+
Name: form.Fields[fieldName], Summary: form.Fields[fieldSummary],
|
|
339
|
+
Slides: SlideFile{Filename: f.Filename, Size: f.Size, SHA256: f.SHA256, Data: f.ReaderAt(), Open: f.Open},
|
|
340
|
+
}, Request{Method: r.Method, Route: web.RoutePattern(r), UserAgent: r.UserAgent(), IP: web.ClientIP(r)})
|
|
341
|
+
if err != nil {
|
|
342
|
+
h.Fail(w, r, err)
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
body, err := h.toDetail(got, p)
|
|
346
|
+
if err != nil {
|
|
347
|
+
h.Fail(w, r, err)
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
w.Header().Set("Location", "/api/v1/ideas/"+got.ID.String())
|
|
351
|
+
web.WriteJSON(w, http.StatusCreated, body)
|
|
352
|
+
}
|