@johnnyboy11234/esg-idea-contest 0.3.1 → 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.
- package/backend/api/openapi.yaml +36 -0
- package/backend/cmd/server/main.go +1 -0
- package/backend/internal/account/internal/passwords/NOTICE +36 -0
- package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
- package/backend/internal/account/internal/passwords/passwords.go +37 -0
- package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
- package/backend/internal/account/password.go +8 -2
- package/backend/internal/account/sequence.go +54 -0
- package/backend/internal/account/sequence_test.go +38 -0
- package/backend/internal/api/api.gen.go +38 -0
- package/backend/internal/idea/create.go +5 -1
- package/backend/internal/idea/create_integration_test.go +33 -3
- package/backend/internal/idea/download.go +193 -0
- package/backend/internal/idea/download_integration_test.go +311 -0
- package/backend/internal/idea/download_test.go +25 -0
- package/backend/internal/idea/handler.go +3 -0
- package/backend/internal/idea/service.go +2 -1
- package/backend/internal/server/api.go +5 -0
- package/backend/internal/server/binding.go +4 -0
- package/backend/internal/server/server.go +20 -1
- package/backend/internal/server/slides_test.go +82 -0
- package/backend/internal/shared/web/transport.go +4 -3
- package/backend/test/arch/arch_test.go +5 -1
- package/backend/test/matrix/matrix_integration_test.go +6 -0
- package/docs/Architecture/Architecture.md +1 -1
- package/docs/Architecture/Backend.md +2 -1
- package/frontend/Dockerfile +2 -0
- package/frontend/package-lock.json +1 -0
- package/frontend/package.json +1 -0
- package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
- package/frontend/src/app/pages/ideas.tsx +23 -9
- package/frontend/src/app/pages/login.test.tsx +38 -1
- package/frontend/src/app/pages/register.test.tsx +32 -0
- package/frontend/src/features/auth/LoginForm.tsx +21 -3
- package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
- package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
- package/frontend/src/features/auth/RegisterForm.tsx +11 -2
- package/frontend/src/features/auth/common-passwords.ts +31 -0
- package/frontend/src/features/auth/password-policy.test.ts +64 -0
- package/frontend/src/features/auth/password-policy.ts +168 -0
- package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
- package/frontend/src/features/auth/useCountdown.ts +28 -0
- package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
- package/frontend/src/features/idea/create.ts +123 -0
- package/frontend/src/shared/api/types.generated.ts +319 -1
- package/frontend/src/shared/i18n/locales/en.ts +45 -4
- package/frontend/src/shared/i18n/locales/zh-TW.ts +45 -4
- package/frontend/src/shared/slides/index.ts +74 -0
- package/frontend/src/shared/slides/slides.test.ts +51 -0
- package/frontend/src/shared/ui/copy-button.tsx +52 -0
- package/frontend/src/shared/ui/native-select.tsx +27 -0
- package/frontend/src/shared/ui/textarea.tsx +19 -0
- package/frontend/src/test-setup.ts +37 -2
- package/frontend/vite.config.ts +3 -1
- package/package.json +1 -1
- package/testdata/password.json +164 -7
- package/testdata/permission-matrix.json +23 -0
- package/testdata/schema/permission-matrix.schema.json +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Package passwords holds the bundled common-password blocklist (PRD §6.4,
|
|
2
|
+
// rule COMMON_PASSWORD). Source and licence of the list: NOTICE in this
|
|
3
|
+
// directory. The frontend lazy-loads the same file for its live checklist.
|
|
4
|
+
package passwords
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
_ "embed"
|
|
8
|
+
"strings"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
//go:embed common-passwords.txt
|
|
12
|
+
var commonList string
|
|
13
|
+
|
|
14
|
+
// common holds every list entry folded to lower case. The list is printable
|
|
15
|
+
// ASCII, so strings.ToLower is an exact ASCII case fold here.
|
|
16
|
+
var common = func() map[string]struct{} {
|
|
17
|
+
lines := strings.Split(commonList, "\n")
|
|
18
|
+
m := make(map[string]struct{}, len(lines))
|
|
19
|
+
for _, l := range lines {
|
|
20
|
+
l = strings.TrimSuffix(l, "\r")
|
|
21
|
+
if l != "" {
|
|
22
|
+
m[strings.ToLower(l)] = struct{}{}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return m
|
|
26
|
+
}()
|
|
27
|
+
|
|
28
|
+
// IsCommon reports whether pw equals a list entry, ignoring case. The whole
|
|
29
|
+
// password is compared as submitted: nothing is trimmed, and a list entry
|
|
30
|
+
// inside a longer password does not match.
|
|
31
|
+
func IsCommon(pw string) bool {
|
|
32
|
+
_, ok := common[strings.ToLower(pw)]
|
|
33
|
+
return ok
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Len is the number of distinct entries (after case folding).
|
|
37
|
+
func Len() int { return len(common) }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package passwords
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestIsCommon(t *testing.T) {
|
|
6
|
+
for _, tc := range []struct {
|
|
7
|
+
pw string
|
|
8
|
+
want bool
|
|
9
|
+
}{
|
|
10
|
+
{"password", true},
|
|
11
|
+
{"PASSWORD", true},
|
|
12
|
+
{"PassWord", true},
|
|
13
|
+
{"123456", true},
|
|
14
|
+
{"films+pic+galeries", true},
|
|
15
|
+
{"Films+Pic+Galeries", true},
|
|
16
|
+
{"eyphed", true}, // last line of the file
|
|
17
|
+
{"", false},
|
|
18
|
+
{" password", false}, // nothing is trimmed
|
|
19
|
+
{"password ", false},
|
|
20
|
+
{"password1!", false}, // not on the list
|
|
21
|
+
{"xpasswordx", false}, // an entry inside a longer password does not match
|
|
22
|
+
{"Correct-Horse-1", false},
|
|
23
|
+
} {
|
|
24
|
+
if got := IsCommon(tc.pw); got != tc.want {
|
|
25
|
+
t.Errorf("IsCommon(%q) = %v, want %v", tc.pw, got, tc.want)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func TestListIsLoaded(t *testing.T) {
|
|
31
|
+
// 10,001 lines, no case-folded duplicates (NOTICE).
|
|
32
|
+
if got := Len(); got != 10001 {
|
|
33
|
+
t.Errorf("Len() = %d, want 10001", got)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -4,6 +4,7 @@ import (
|
|
|
4
4
|
"strings"
|
|
5
5
|
"unicode/utf8"
|
|
6
6
|
|
|
7
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/account/internal/passwords"
|
|
7
8
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
8
9
|
)
|
|
9
10
|
|
|
@@ -38,14 +39,15 @@ type Rule struct {
|
|
|
38
39
|
// (testdata/password.json): later PRs add their rule at its table position.
|
|
39
40
|
type Policy struct{ Rules []Rule }
|
|
40
41
|
|
|
41
|
-
// DefaultPolicy returns
|
|
42
|
-
// SEQUENCE and COMMON_PASSWORD arrive with B1b-03 and later.
|
|
42
|
+
// DefaultPolicy returns all eight rules of the PRD §6.4 table, in table order.
|
|
43
43
|
func DefaultPolicy() *Policy {
|
|
44
44
|
return &Policy{Rules: []Rule{
|
|
45
45
|
{RuleLength, violatesLength},
|
|
46
46
|
{RuleCharset, violatesCharset},
|
|
47
47
|
{RuleComplexity, violatesComplexity},
|
|
48
|
+
{RuleCommon, violatesCommon},
|
|
48
49
|
{RuleKeyboardRun, violatesKeyboardRun},
|
|
50
|
+
{RuleSequence, violatesSequence},
|
|
49
51
|
{RuleRepeat, violatesRepeat},
|
|
50
52
|
{RuleContainsEmpNo, violatesEmpNo},
|
|
51
53
|
}}
|
|
@@ -103,6 +105,10 @@ func violatesComplexity(pw, _ string) bool {
|
|
|
103
105
|
return n < minPassClasses
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
// violatesCommon: the whole password equals an entry of the bundled Top-10,000
|
|
109
|
+
// list, ignoring case (internal/passwords/NOTICE for the source).
|
|
110
|
+
func violatesCommon(pw, _ string) bool { return passwords.IsCommon(pw) }
|
|
111
|
+
|
|
106
112
|
// violatesRepeat: the same character three or more times in a row, compared
|
|
107
113
|
// exactly (case-sensitive: "aAa" is fine).
|
|
108
114
|
func violatesRepeat(pw, _ string) bool {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
package account
|
|
2
|
+
|
|
3
|
+
const minSequence = 4
|
|
4
|
+
|
|
5
|
+
// seqKey places a character on one of two alphabets: letters a–z (case
|
|
6
|
+
// folded) and digits 0–9. ok is false for anything else, which breaks a
|
|
7
|
+
// sequence. Letters and digits never join each other ("89ab" is two runs of 2),
|
|
8
|
+
// and neighbours in ASCII outside an alphabet do not count ("xyz{" is 3).
|
|
9
|
+
func seqKey(r rune) (alpha int8, pos int8, ok bool) {
|
|
10
|
+
switch {
|
|
11
|
+
case r >= 'a' && r <= 'z':
|
|
12
|
+
return 0, int8(r - 'a'), true
|
|
13
|
+
case r >= 'A' && r <= 'Z':
|
|
14
|
+
return 0, int8(r - 'A'), true
|
|
15
|
+
case r >= '0' && r <= '9':
|
|
16
|
+
return 1, int8(r - '0'), true
|
|
17
|
+
}
|
|
18
|
+
return 0, 0, false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// violatesSequence: four or more consecutive letters or digits, ascending or
|
|
22
|
+
// descending, case-insensitive ("abcd", "DcBa", "4321"). Sequences do not wrap
|
|
23
|
+
// ("xyza", "9012" are fine), the same character repeated is not a sequence
|
|
24
|
+
// (that is REPEAT), and a run must keep one direction ("abcba" is fine).
|
|
25
|
+
// Mirrors violatesKeyboardRun.
|
|
26
|
+
func violatesSequence(pw, _ string) bool {
|
|
27
|
+
var prevAlpha, prevPos int8
|
|
28
|
+
have, run, dir := false, 0, int8(0)
|
|
29
|
+
for _, r := range pw {
|
|
30
|
+
a, p, ok := seqKey(r)
|
|
31
|
+
if !ok {
|
|
32
|
+
have, run, dir = false, 0, 0
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
step := int8(0)
|
|
36
|
+
if have && a == prevAlpha {
|
|
37
|
+
step = p - prevPos
|
|
38
|
+
}
|
|
39
|
+
if step == 1 || step == -1 {
|
|
40
|
+
if run >= 2 && step == dir {
|
|
41
|
+
run++
|
|
42
|
+
} else {
|
|
43
|
+
run, dir = 2, step
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
run, dir = 1, 0
|
|
47
|
+
}
|
|
48
|
+
prevAlpha, prevPos, have = a, p, true
|
|
49
|
+
if run >= minSequence {
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
package account
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestViolatesSequence(t *testing.T) {
|
|
6
|
+
for _, tc := range []struct {
|
|
7
|
+
pw string
|
|
8
|
+
want bool
|
|
9
|
+
}{
|
|
10
|
+
{"", false},
|
|
11
|
+
{"abc", false},
|
|
12
|
+
{"abcd", true},
|
|
13
|
+
{"dcba", true},
|
|
14
|
+
{"aBcD", true},
|
|
15
|
+
{"WXYZ", true},
|
|
16
|
+
{"0123", true},
|
|
17
|
+
{"9876", true},
|
|
18
|
+
{"3210", true},
|
|
19
|
+
{"x-abcdefg-y", true}, // longer run, inside other text
|
|
20
|
+
{"xyza", false}, // no wrap
|
|
21
|
+
{"9012", false}, // no wrap
|
|
22
|
+
{"zyxw", true},
|
|
23
|
+
{"abcba", false}, // direction change restarts the count
|
|
24
|
+
{"abbc", false}, // a repeat breaks the run
|
|
25
|
+
{"aaaa", false}, // repeat is not a sequence
|
|
26
|
+
{"89ab", false}, // letters and digits do not join
|
|
27
|
+
{"xyz{", false}, // ASCII neighbour outside the alphabet
|
|
28
|
+
{"/012", false}, // ASCII neighbour outside the alphabet
|
|
29
|
+
{"ab-cd", false}, // a separator breaks the run
|
|
30
|
+
{"abécd", false}, // non-ASCII breaks the run
|
|
31
|
+
{"ab12cd", false}, // alphabets switching break the run
|
|
32
|
+
{"abc0123", true},
|
|
33
|
+
} {
|
|
34
|
+
if got := violatesSequence(tc.pw, ""); got != tc.want {
|
|
35
|
+
t.Errorf("violatesSequence(%q) = %v, want %v", tc.pw, got, tc.want)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -336,6 +336,9 @@ type ServerInterface interface {
|
|
|
336
336
|
// GetIdea Idea detail
|
|
337
337
|
// (GET /ideas/{id})
|
|
338
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)
|
|
339
342
|
}
|
|
340
343
|
|
|
341
344
|
// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint.
|
|
@@ -396,6 +399,12 @@ func (_ Unimplemented) GetIdea(w http.ResponseWriter, r *http.Request, id IdeaId
|
|
|
396
399
|
w.WriteHeader(http.StatusNotImplemented)
|
|
397
400
|
}
|
|
398
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
|
+
|
|
399
408
|
// ServerInterfaceWrapper converts contexts to parameters.
|
|
400
409
|
type ServerInterfaceWrapper struct {
|
|
401
410
|
Handler ServerInterface
|
|
@@ -800,6 +809,32 @@ func (siw *ServerInterfaceWrapper) GetIdea(w http.ResponseWriter, r *http.Reques
|
|
|
800
809
|
handler.ServeHTTP(w, r)
|
|
801
810
|
}
|
|
802
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
|
+
|
|
803
838
|
type UnescapedCookieParamError struct {
|
|
804
839
|
ParamName string
|
|
805
840
|
Err error
|
|
@@ -940,6 +975,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
|
|
940
975
|
r.Group(func(r chi.Router) {
|
|
941
976
|
r.Get(options.BaseURL+"/ideas/{id}", wrapper.GetIdea)
|
|
942
977
|
})
|
|
978
|
+
r.Group(func(r chi.Router) {
|
|
979
|
+
r.Get(options.BaseURL+"/ideas/{id}/slides", wrapper.DownloadSlides)
|
|
980
|
+
})
|
|
943
981
|
|
|
944
982
|
return r
|
|
945
983
|
}
|
|
@@ -69,9 +69,13 @@ type SlideRules struct {
|
|
|
69
69
|
Validate func(f SlideFile, expectedBase string) (ext string, err error)
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
// ObjectStore is the part of the MinIO port
|
|
72
|
+
// ObjectStore is the part of the MinIO port idea needs (storage.SlideStore):
|
|
73
|
+
// Put for create, Get for download. Outages are *web.Error
|
|
74
|
+
// STORAGE_UNAVAILABLE; any other Get error (a missing object) answers 500.
|
|
73
75
|
type ObjectStore interface {
|
|
74
76
|
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
|
77
|
+
// Get opens key; the caller closes the reader. size is the object length.
|
|
78
|
+
Get(ctx context.Context, key string) (rc io.ReadCloser, size int64, err error)
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
// UploadAborter compensates an uploaded object whose transaction did not
|
|
@@ -34,7 +34,10 @@ import (
|
|
|
34
34
|
|
|
35
35
|
// fakeObjects is an in-memory storage.SlideStore. before runs inside Put, so a
|
|
36
36
|
// test can change the database between the pre-checks and the transaction.
|
|
37
|
-
// deleteErr makes Delete fail (MinIO down after the upload).
|
|
37
|
+
// deleteErr makes Delete fail (MinIO down after the upload). For Get: onGet
|
|
38
|
+
// runs first, getErr fails the open (MinIO down before the first byte), and
|
|
39
|
+
// a positive failAfter makes the stream fail with streamErr after that many
|
|
40
|
+
// bytes (MinIO down mid-stream).
|
|
38
41
|
type fakeObjects struct {
|
|
39
42
|
mu sync.Mutex
|
|
40
43
|
puts map[string][]byte
|
|
@@ -44,6 +47,12 @@ type fakeObjects struct {
|
|
|
44
47
|
deleted []string
|
|
45
48
|
last string // key of the last successful Put
|
|
46
49
|
before func()
|
|
50
|
+
|
|
51
|
+
onGet func()
|
|
52
|
+
getErr error
|
|
53
|
+
failAfter int64
|
|
54
|
+
streamErr error
|
|
55
|
+
gets int
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
func newFakeObjects() *fakeObjects {
|
|
@@ -71,10 +80,31 @@ func (f *fakeObjects) Put(_ context.Context, key string, r io.Reader, size int64
|
|
|
71
80
|
return nil
|
|
72
81
|
}
|
|
73
82
|
|
|
74
|
-
func (f *fakeObjects) Get(context.Context, string) (io.ReadCloser, int64, error) {
|
|
75
|
-
|
|
83
|
+
func (f *fakeObjects) Get(_ context.Context, key string) (io.ReadCloser, int64, error) {
|
|
84
|
+
if f.onGet != nil {
|
|
85
|
+
f.onGet()
|
|
86
|
+
}
|
|
87
|
+
f.mu.Lock()
|
|
88
|
+
defer f.mu.Unlock()
|
|
89
|
+
f.gets++
|
|
90
|
+
if f.getErr != nil {
|
|
91
|
+
return nil, 0, f.getErr
|
|
92
|
+
}
|
|
93
|
+
b, ok := f.puts[key]
|
|
94
|
+
if !ok {
|
|
95
|
+
return nil, 0, storage.ErrObjectNotFound
|
|
96
|
+
}
|
|
97
|
+
var r io.Reader = bytes.NewReader(b)
|
|
98
|
+
if f.failAfter > 0 {
|
|
99
|
+
r = io.MultiReader(io.LimitReader(r, f.failAfter), failingReader{f.streamErr})
|
|
100
|
+
}
|
|
101
|
+
return io.NopCloser(r), int64(len(b)), nil
|
|
76
102
|
}
|
|
77
103
|
|
|
104
|
+
type failingReader struct{ err error }
|
|
105
|
+
|
|
106
|
+
func (r failingReader) Read([]byte) (int, error) { return 0, r.err }
|
|
107
|
+
|
|
78
108
|
func (f *fakeObjects) Delete(_ context.Context, key string) error {
|
|
79
109
|
f.mu.Lock()
|
|
80
110
|
defer f.mu.Unlock()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
package idea
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"io"
|
|
9
|
+
"log/slog"
|
|
10
|
+
"net/http"
|
|
11
|
+
"strconv"
|
|
12
|
+
"strings"
|
|
13
|
+
|
|
14
|
+
"github.com/google/uuid"
|
|
15
|
+
|
|
16
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
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/web"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// Download is an opened slide file. Body yields exactly Idea.Slides.Size bytes
|
|
25
|
+
// unless storage fails mid-stream; the caller must Close it.
|
|
26
|
+
type Download struct {
|
|
27
|
+
Idea Idea
|
|
28
|
+
Body io.ReadCloser
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Download runs GET /ideas/{id}/slides (Sequence.md §6): the visibility query
|
|
32
|
+
// and the SLIDES_DOWNLOAD row share one transaction, and the object is opened
|
|
33
|
+
// only after that transaction has committed, so no byte is served without a
|
|
34
|
+
// durable audit row (fail-closed, PRD 6.11). A missing and a hidden Idea both
|
|
35
|
+
// return NOT_FOUND. An unknown commit outcome serves nothing (503), because
|
|
36
|
+
// the audit row may not exist. Downloads are never state-limited.
|
|
37
|
+
//
|
|
38
|
+
// If storage fails after the commit, the audit row stays for a download that
|
|
39
|
+
// answered 503: accepted, the intent was real (Sequence.md §6).
|
|
40
|
+
func (s *Service) Download(ctx context.Context, id uuid.UUID, caller security.Principal, req Request) (Download, error) {
|
|
41
|
+
var got Idea
|
|
42
|
+
out, err := db.InTx(ctx, s.DB, func(ctx context.Context, tx *sql.Tx) error {
|
|
43
|
+
var err error
|
|
44
|
+
if got, err = store.GetVisible(ctx, tx, id, caller.UserID, caller.IsAdmin()); err != nil {
|
|
45
|
+
return err
|
|
46
|
+
}
|
|
47
|
+
return s.Audit.Record(ctx, tx, audit.Event{
|
|
48
|
+
Action: audit.SlidesDownload, Outcome: audit.Success,
|
|
49
|
+
TargetType: audit.TargetIdea, TargetID: id,
|
|
50
|
+
Method: req.Method, Route: req.Route, Status: http.StatusOK,
|
|
51
|
+
IP: req.IP, UserAgent: req.UserAgent,
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
switch {
|
|
55
|
+
case out == db.Unknown:
|
|
56
|
+
e := web.NewError(web.CodeServiceUnavailable, "The download could not be recorded; please retry.")
|
|
57
|
+
e.Cause = err
|
|
58
|
+
return Download{}, e
|
|
59
|
+
case errors.Is(err, store.ErrNotFound):
|
|
60
|
+
return Download{}, web.NewError(web.CodeNotFound, "")
|
|
61
|
+
case err != nil:
|
|
62
|
+
return Download{}, err
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Storage outages are *web.Error STORAGE_UNAVAILABLE (503); a missing
|
|
66
|
+
// object is a consistency fault and answers 500.
|
|
67
|
+
body, size, err := s.Objects.Get(ctx, got.Slides.ObjectKey)
|
|
68
|
+
if err != nil {
|
|
69
|
+
return Download{}, fmt.Errorf("open slides of idea %s: %w", id, err)
|
|
70
|
+
}
|
|
71
|
+
// Content-Length is the stored slides_size; an object of another length
|
|
72
|
+
// would make every client see a truncated or overlong body.
|
|
73
|
+
if size != got.Slides.Size {
|
|
74
|
+
_ = body.Close()
|
|
75
|
+
return Download{}, fmt.Errorf("slides of idea %s: object is %d bytes, row says %d", id, size, got.Slides.Size)
|
|
76
|
+
}
|
|
77
|
+
return Download{Idea: got, Body: body}, nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// DownloadSlides serves GET /ideas/{id}/slides: 200 with the file named by the
|
|
81
|
+
// current expected filename (PRD 6.6, 6.8). HEAD never reaches this handler
|
|
82
|
+
// (explicit 405 route, D47/D59).
|
|
83
|
+
func (h Handler) DownloadSlides(w http.ResponseWriter, r *http.Request, id api.IdeaId) {
|
|
84
|
+
p, ok := security.PrincipalFrom(r.Context())
|
|
85
|
+
if !ok {
|
|
86
|
+
h.Fail(w, r, web.NewError(web.CodeUnauthenticated, "authentication required"))
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
d, err := h.Service.Download(r.Context(), uuid.UUID(id), p,
|
|
90
|
+
Request{Method: r.Method, Route: web.RoutePattern(r), UserAgent: r.UserAgent(), IP: web.ClientIP(r)})
|
|
91
|
+
if err != nil {
|
|
92
|
+
var e *web.Error
|
|
93
|
+
switch {
|
|
94
|
+
case errors.As(err, &e) && e.Code == web.CodeNotFound:
|
|
95
|
+
e.AuditAction = string(audit.SlidesDownload)
|
|
96
|
+
e.AuditReason = auditReasonHidden
|
|
97
|
+
e.AuditTargetType = string(audit.TargetIdea)
|
|
98
|
+
e.AuditTargetID = uuid.UUID(id)
|
|
99
|
+
case e == nil:
|
|
100
|
+
h.log().ErrorContext(r.Context(), "slides download failed", "ideaId", uuid.UUID(id).String(), "error", err)
|
|
101
|
+
}
|
|
102
|
+
h.Fail(w, r, err)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
defer func() { _ = d.Body.Close() }()
|
|
106
|
+
|
|
107
|
+
i := d.Idea
|
|
108
|
+
base, err := h.ExpectedFilename(i.Category, i.Org, i.LeaderName, i.LeaderEmpNo, i.Name)
|
|
109
|
+
if err != nil {
|
|
110
|
+
h.Fail(w, r, err)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
hd := w.Header()
|
|
114
|
+
hd.Set("Content-Type", contentTypes[i.Slides.Ext])
|
|
115
|
+
hd.Set("Content-Length", strconv.FormatInt(i.Slides.Size, 10))
|
|
116
|
+
hd.Set("Content-Disposition", ContentDisposition(base+"."+i.Slides.Ext))
|
|
117
|
+
hd.Set("Cache-Control", "no-store")
|
|
118
|
+
hd.Set("X-Content-Type-Options", "nosniff")
|
|
119
|
+
w.WriteHeader(http.StatusOK)
|
|
120
|
+
|
|
121
|
+
src := &readErrRecorder{r: d.Body}
|
|
122
|
+
n, err := io.CopyN(w, src, i.Slides.Size)
|
|
123
|
+
if err == nil {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if src.err != nil || errors.Is(err, io.EOF) {
|
|
127
|
+
// Headers are out, so no error body can follow. Abort the connection
|
|
128
|
+
// (HTTP/1.1 close, HTTP/2 RST_STREAM): with Content-Length set the
|
|
129
|
+
// client sees an incomplete body instead of a silently short file.
|
|
130
|
+
h.log().ErrorContext(r.Context(), "slides download aborted: storage failed mid-stream",
|
|
131
|
+
"ideaId", i.ID.String(), "sent", n, "size", i.Slides.Size, "error", err)
|
|
132
|
+
panic(http.ErrAbortHandler)
|
|
133
|
+
}
|
|
134
|
+
// The client went away; nothing left to tell it.
|
|
135
|
+
h.log().InfoContext(r.Context(), "slides download interrupted by client",
|
|
136
|
+
"ideaId", i.ID.String(), "sent", n, "size", i.Slides.Size, "error", err)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func (h Handler) log() *slog.Logger {
|
|
140
|
+
if h.Log == nil {
|
|
141
|
+
return slog.New(slog.DiscardHandler)
|
|
142
|
+
}
|
|
143
|
+
return h.Log
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// readErrRecorder remembers the source's first non-EOF error, so a failed copy
|
|
147
|
+
// can be blamed on storage (abort) or on the client (just stop).
|
|
148
|
+
type readErrRecorder struct {
|
|
149
|
+
r io.Reader
|
|
150
|
+
err error
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func (x *readErrRecorder) Read(p []byte) (int, error) {
|
|
154
|
+
n, err := x.r.Read(p)
|
|
155
|
+
if err != nil && !errors.Is(err, io.EOF) && x.err == nil {
|
|
156
|
+
x.err = err
|
|
157
|
+
}
|
|
158
|
+
return n, err
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ContentDisposition is the RFC 6266 attachment header for name: an ASCII
|
|
162
|
+
// fallback filename (non-ASCII, quotes, backslashes and controls become "_")
|
|
163
|
+
// and the exact UTF-8 name as filename* (RFC 8187 percent-encoding).
|
|
164
|
+
func ContentDisposition(name string) string {
|
|
165
|
+
var fallback, ext strings.Builder
|
|
166
|
+
for _, r := range name {
|
|
167
|
+
if r < 0x20 || r > 0x7e || r == '"' || r == '\\' {
|
|
168
|
+
fallback.WriteByte('_')
|
|
169
|
+
} else {
|
|
170
|
+
fallback.WriteRune(r)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const hex = "0123456789ABCDEF"
|
|
174
|
+
for _, b := range []byte(name) {
|
|
175
|
+
if isAttrChar(b) {
|
|
176
|
+
ext.WriteByte(b)
|
|
177
|
+
} else {
|
|
178
|
+
ext.WriteByte('%')
|
|
179
|
+
ext.WriteByte(hex[b>>4])
|
|
180
|
+
ext.WriteByte(hex[b&0x0f])
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return `attachment; filename="` + fallback.String() + `"; filename*=UTF-8''` + ext.String()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// isAttrChar is RFC 8187 attr-char: ALPHA / DIGIT / "!#$&+-.^_`|~".
|
|
187
|
+
func isAttrChar(b byte) bool {
|
|
188
|
+
switch {
|
|
189
|
+
case b >= 'a' && b <= 'z', b >= 'A' && b <= 'Z', b >= '0' && b <= '9':
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
return strings.IndexByte("!#$&+-.^_`|~", b) >= 0
|
|
193
|
+
}
|