@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.
Files changed (58) hide show
  1. package/backend/api/openapi.yaml +36 -0
  2. package/backend/cmd/server/main.go +1 -0
  3. package/backend/internal/account/internal/passwords/NOTICE +36 -0
  4. package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
  5. package/backend/internal/account/internal/passwords/passwords.go +37 -0
  6. package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
  7. package/backend/internal/account/password.go +8 -2
  8. package/backend/internal/account/sequence.go +54 -0
  9. package/backend/internal/account/sequence_test.go +38 -0
  10. package/backend/internal/api/api.gen.go +38 -0
  11. package/backend/internal/idea/create.go +5 -1
  12. package/backend/internal/idea/create_integration_test.go +33 -3
  13. package/backend/internal/idea/download.go +193 -0
  14. package/backend/internal/idea/download_integration_test.go +311 -0
  15. package/backend/internal/idea/download_test.go +25 -0
  16. package/backend/internal/idea/handler.go +3 -0
  17. package/backend/internal/idea/service.go +2 -1
  18. package/backend/internal/server/api.go +5 -0
  19. package/backend/internal/server/binding.go +4 -0
  20. package/backend/internal/server/server.go +20 -1
  21. package/backend/internal/server/slides_test.go +82 -0
  22. package/backend/internal/shared/web/transport.go +4 -3
  23. package/backend/test/arch/arch_test.go +5 -1
  24. package/backend/test/matrix/matrix_integration_test.go +6 -0
  25. package/docs/Architecture/Architecture.md +1 -1
  26. package/docs/Architecture/Backend.md +2 -1
  27. package/frontend/Dockerfile +2 -0
  28. package/frontend/package-lock.json +1 -0
  29. package/frontend/package.json +1 -0
  30. package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
  31. package/frontend/src/app/pages/ideas.tsx +23 -9
  32. package/frontend/src/app/pages/login.test.tsx +38 -1
  33. package/frontend/src/app/pages/register.test.tsx +32 -0
  34. package/frontend/src/features/auth/LoginForm.tsx +21 -3
  35. package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
  36. package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
  37. package/frontend/src/features/auth/RegisterForm.tsx +11 -2
  38. package/frontend/src/features/auth/common-passwords.ts +31 -0
  39. package/frontend/src/features/auth/password-policy.test.ts +64 -0
  40. package/frontend/src/features/auth/password-policy.ts +168 -0
  41. package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
  42. package/frontend/src/features/auth/useCountdown.ts +28 -0
  43. package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
  44. package/frontend/src/features/idea/create.ts +123 -0
  45. package/frontend/src/shared/api/types.generated.ts +319 -1
  46. package/frontend/src/shared/i18n/locales/en.ts +45 -4
  47. package/frontend/src/shared/i18n/locales/zh-TW.ts +45 -4
  48. package/frontend/src/shared/slides/index.ts +74 -0
  49. package/frontend/src/shared/slides/slides.test.ts +51 -0
  50. package/frontend/src/shared/ui/copy-button.tsx +52 -0
  51. package/frontend/src/shared/ui/native-select.tsx +27 -0
  52. package/frontend/src/shared/ui/textarea.tsx +19 -0
  53. package/frontend/src/test-setup.ts +37 -2
  54. package/frontend/vite.config.ts +3 -1
  55. package/package.json +1 -1
  56. package/testdata/password.json +164 -7
  57. package/testdata/permission-matrix.json +23 -0
  58. 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 the rules implemented so far, in table order.
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 create needs (storage.SlideStore).
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
- return nil, 0, storage.ErrObjectNotFound
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
+ }