@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.
- package/backend/api/openapi.yaml +119 -0
- package/backend/cmd/server/main.go +36 -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/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/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 +131 -0
- package/backend/internal/account/sequence.go +54 -0
- package/backend/internal/account/sequence_test.go +38 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +262 -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 +356 -0
- package/backend/internal/idea/create_integration_test.go +680 -0
- 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 +151 -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 +54 -0
- package/backend/internal/server/api.go +22 -0
- package/backend/internal/server/binding.go +4 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/server.go +20 -1
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/server/slides_test.go +82 -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/transport.go +4 -3
- 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/arch/arch_test.go +5 -1
- 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 +92 -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/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/App.tsx +11 -2
- 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/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -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/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/api/types.generated.ts +319 -1
- package/frontend/src/shared/i18n/locales/en.ts +46 -4
- package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -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/expected-filename.json +93 -0
- package/testdata/password.json +582 -0
- package/testdata/permission-matrix.json +71 -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 +22 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package idea_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"bytes"
|
|
7
|
+
"context"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"errors"
|
|
10
|
+
"io"
|
|
11
|
+
"log/slog"
|
|
12
|
+
"net/http"
|
|
13
|
+
"net/http/httptest"
|
|
14
|
+
"strconv"
|
|
15
|
+
"testing"
|
|
16
|
+
|
|
17
|
+
"github.com/go-chi/chi/v5"
|
|
18
|
+
"github.com/google/uuid"
|
|
19
|
+
|
|
20
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
21
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
|
|
22
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
23
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
24
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// created posts a valid create as p and returns the new Idea's ID and the key
|
|
28
|
+
// of its stored object.
|
|
29
|
+
func (e *createEnv) created(t *testing.T, p security.Principal, name string) (uuid.UUID, string) {
|
|
30
|
+
t.Helper()
|
|
31
|
+
return e.createdWith(t, p, validForm(name))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func (e *createEnv) createdWith(t *testing.T, p security.Principal, f form) (uuid.UUID, string) {
|
|
35
|
+
t.Helper()
|
|
36
|
+
rec := e.post(t, p, f)
|
|
37
|
+
if rec.Code != http.StatusCreated {
|
|
38
|
+
t.Fatalf("create: %d %s", rec.Code, rec.Body)
|
|
39
|
+
}
|
|
40
|
+
var b struct{ ID uuid.UUID }
|
|
41
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil {
|
|
42
|
+
t.Fatal(err)
|
|
43
|
+
}
|
|
44
|
+
return b.ID, e.objects.last
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (e *createEnv) downloadRouter() http.Handler {
|
|
48
|
+
r := chi.NewRouter()
|
|
49
|
+
r.Get("/ideas/{id}/slides", func(w http.ResponseWriter, req *http.Request) {
|
|
50
|
+
id, err := uuid.Parse(chi.URLParam(req, "id"))
|
|
51
|
+
if err != nil {
|
|
52
|
+
panic(err)
|
|
53
|
+
}
|
|
54
|
+
e.h.DownloadSlides(w, req, api.IdeaId(id))
|
|
55
|
+
})
|
|
56
|
+
return r
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func (e *createEnv) download(t *testing.T, p security.Principal, id uuid.UUID) *httptest.ResponseRecorder {
|
|
60
|
+
t.Helper()
|
|
61
|
+
req := httptest.NewRequest(http.MethodGet, "/ideas/"+id.String()+"/slides", nil)
|
|
62
|
+
req = req.WithContext(security.WithPrincipal(context.Background(), p))
|
|
63
|
+
rec := httptest.NewRecorder()
|
|
64
|
+
e.downloadRouter().ServeHTTP(rec, req)
|
|
65
|
+
return rec
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func (e *createEnv) gets() int {
|
|
69
|
+
e.objects.mu.Lock()
|
|
70
|
+
defer e.objects.mu.Unlock()
|
|
71
|
+
return e.objects.gets
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func TestDownloadSlides(t *testing.T) {
|
|
75
|
+
e := newCreateEnv(t)
|
|
76
|
+
leader := addUser(t, e.db, "100001", "USER")
|
|
77
|
+
other := addUser(t, e.db, "100002", "USER")
|
|
78
|
+
admin := addUser(t, e.db, "900001", "ADMIN")
|
|
79
|
+
id, key := e.created(t, leader, "Idea A")
|
|
80
|
+
successRows := func() int {
|
|
81
|
+
return e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'SLIDES_DOWNLOAD' AND outcome = 'SUCCESS'`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The audit row is committed (visible to another connection) before the
|
|
85
|
+
// object is even opened, so before the first byte.
|
|
86
|
+
rowsAtOpen := -1
|
|
87
|
+
e.objects.onGet = func() { rowsAtOpen = successRows() }
|
|
88
|
+
|
|
89
|
+
rec := e.download(t, leader, id)
|
|
90
|
+
if rec.Code != http.StatusOK {
|
|
91
|
+
t.Fatalf("leader: %d %s", rec.Code, rec.Body)
|
|
92
|
+
}
|
|
93
|
+
if rowsAtOpen != 1 {
|
|
94
|
+
t.Errorf("SLIDES_DOWNLOAD rows when the object was opened = %d, want 1", rowsAtOpen)
|
|
95
|
+
}
|
|
96
|
+
if !bytes.Equal(rec.Body.Bytes(), e.objects.puts[key]) {
|
|
97
|
+
t.Error("body differs from the stored object")
|
|
98
|
+
}
|
|
99
|
+
want := map[string]string{
|
|
100
|
+
"Content-Type": "application/pdf",
|
|
101
|
+
"Content-Length": strconv.Itoa(len(e.objects.puts[key])),
|
|
102
|
+
"Content-Disposition": idea.ContentDisposition("I_BSID_User 100001_100001_Idea A.pdf"),
|
|
103
|
+
"Cache-Control": "no-store",
|
|
104
|
+
"X-Content-Type-Options": "nosniff",
|
|
105
|
+
}
|
|
106
|
+
for k, v := range want {
|
|
107
|
+
if got := rec.Header().Get(k); got != v {
|
|
108
|
+
t.Errorf("%s = %q, want %q", k, got, v)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if rec.Header().Get("Accept-Ranges") != "" {
|
|
112
|
+
t.Error("Range must not be advertised")
|
|
113
|
+
}
|
|
114
|
+
if n := e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'SLIDES_DOWNLOAD' AND outcome = 'SUCCESS'
|
|
115
|
+
AND actor_role = 'USER' AND actor_emp_no = '100001' AND target_type = 'IDEA' AND target_id = ?
|
|
116
|
+
AND method = 'GET' AND route = '/ideas/{id}/slides' AND status = 200`, id.String()); n != 1 {
|
|
117
|
+
t.Errorf("leader SUCCESS row with full fields = %d, want 1", n)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Admin: 200 and a second SUCCESS row.
|
|
121
|
+
if rec := e.download(t, admin, id); rec.Code != http.StatusOK {
|
|
122
|
+
t.Fatalf("admin: %d %s", rec.Code, rec.Body)
|
|
123
|
+
}
|
|
124
|
+
if n := e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'SLIDES_DOWNLOAD' AND outcome = 'SUCCESS' AND actor_role = 'ADMIN'`); n != 1 {
|
|
125
|
+
t.Errorf("admin SUCCESS rows = %d, want 1", n)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Hidden and missing: identical 404 (bar the request ID), both DENIED, and
|
|
129
|
+
// storage is never touched.
|
|
130
|
+
before := e.gets()
|
|
131
|
+
hidden, missing := e.download(t, other, id), e.download(t, other, uuid.New())
|
|
132
|
+
if hidden.Code != 404 || missing.Code != 404 {
|
|
133
|
+
t.Fatalf("codes %d %d", hidden.Code, missing.Code)
|
|
134
|
+
}
|
|
135
|
+
strip := func(rr *httptest.ResponseRecorder) map[string]any {
|
|
136
|
+
var m map[string]any
|
|
137
|
+
_ = json.Unmarshal(rr.Body.Bytes(), &m)
|
|
138
|
+
delete(m, "requestId")
|
|
139
|
+
return m
|
|
140
|
+
}
|
|
141
|
+
hb, mb := strip(hidden), strip(missing)
|
|
142
|
+
if len(hb) != len(mb) || hb["code"] != "NOT_FOUND" || hb["code"] != mb["code"] || hb["detail"] != mb["detail"] {
|
|
143
|
+
t.Errorf("bodies differ: %v vs %v", hb, mb)
|
|
144
|
+
}
|
|
145
|
+
for k := range hidden.Header() {
|
|
146
|
+
if hidden.Header().Get(k) != missing.Header().Get(k) {
|
|
147
|
+
t.Errorf("header %s differs", k)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if e.gets() != before {
|
|
151
|
+
t.Error("a 404 must not open the object")
|
|
152
|
+
}
|
|
153
|
+
if n := e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'SLIDES_DOWNLOAD' AND outcome = 'DENIED'
|
|
154
|
+
AND status = 404 AND error_code = 'NOT_FOUND'`); n != 2 {
|
|
155
|
+
t.Errorf("DENIED rows = %d, want 2", n)
|
|
156
|
+
}
|
|
157
|
+
if successRows() != 2 {
|
|
158
|
+
t.Errorf("SUCCESS rows = %d, want 2", successRows())
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Not state-limited, and the name is the *current* expected filename.
|
|
162
|
+
e.setDeadline(t, "NOW(6) - INTERVAL 1 DAY")
|
|
163
|
+
if _, err := e.db.Exec(`UPDATE idea SET name = 'Renamed' WHERE id = ?`, id.String()); err != nil {
|
|
164
|
+
t.Fatal(err)
|
|
165
|
+
}
|
|
166
|
+
rec = e.download(t, leader, id)
|
|
167
|
+
if rec.Code != http.StatusOK {
|
|
168
|
+
t.Fatalf("after deadline: %d %s", rec.Code, rec.Body)
|
|
169
|
+
}
|
|
170
|
+
if got, want := rec.Header().Get("Content-Disposition"), idea.ContentDisposition("I_BSID_User 100001_100001_Renamed.pdf"); got != want {
|
|
171
|
+
t.Errorf("Content-Disposition = %q, want %q", got, want)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// A non-ASCII Idea name is served exactly through filename*.
|
|
176
|
+
func TestDownloadSlidesUnicodeFilename(t *testing.T) {
|
|
177
|
+
e := newCreateEnv(t)
|
|
178
|
+
leader := addUser(t, e.db, "100001", "USER")
|
|
179
|
+
id, _ := e.created(t, leader, "減碳 Idea")
|
|
180
|
+
rec := e.download(t, leader, id)
|
|
181
|
+
if rec.Code != http.StatusOK {
|
|
182
|
+
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
183
|
+
}
|
|
184
|
+
want := `attachment; filename="I_BSID_User 100001_100001___ Idea.pdf"; ` +
|
|
185
|
+
`filename*=UTF-8''I_BSID_User%20100001_100001_%E6%B8%9B%E7%A2%B3%20Idea.pdf`
|
|
186
|
+
if got := rec.Header().Get("Content-Disposition"); got != want {
|
|
187
|
+
t.Errorf("Content-Disposition = %q, want %q", got, want)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Failures before the first byte answer a problem; the audit row stays
|
|
192
|
+
// (Sequence.md §6: the intent to download was real).
|
|
193
|
+
func TestDownloadSlidesFailsBeforeFirstByte(t *testing.T) {
|
|
194
|
+
for _, tc := range []struct {
|
|
195
|
+
name string
|
|
196
|
+
breakIt func(e *createEnv, id uuid.UUID, key string)
|
|
197
|
+
status int
|
|
198
|
+
code string
|
|
199
|
+
}{
|
|
200
|
+
{"MinIO down", func(e *createEnv, _ uuid.UUID, _ string) {
|
|
201
|
+
e.objects.getErr = web.NewError(web.CodeStorageUnavailable, "object storage is unavailable")
|
|
202
|
+
}, 503, "STORAGE_UNAVAILABLE"},
|
|
203
|
+
{"object missing", func(e *createEnv, _ uuid.UUID, key string) {
|
|
204
|
+
delete(e.objects.puts, key)
|
|
205
|
+
}, 500, "INTERNAL_ERROR"},
|
|
206
|
+
{"object length differs from the row", func(e *createEnv, id uuid.UUID, _ string) {
|
|
207
|
+
if _, err := e.db.Exec(`UPDATE idea SET slides_size = slides_size + 1 WHERE id = ?`, id.String()); err != nil {
|
|
208
|
+
panic(err)
|
|
209
|
+
}
|
|
210
|
+
}, 500, "INTERNAL_ERROR"},
|
|
211
|
+
} {
|
|
212
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
213
|
+
e := newCreateEnv(t)
|
|
214
|
+
leader := addUser(t, e.db, "100001", "USER")
|
|
215
|
+
id, key := e.created(t, leader, "Idea A")
|
|
216
|
+
tc.breakIt(e, id, key)
|
|
217
|
+
rec := e.download(t, leader, id)
|
|
218
|
+
if rec.Code != tc.status || decodeProblem(t, rec).Code != tc.code {
|
|
219
|
+
t.Fatalf("%d %s, want %d %s", rec.Code, rec.Body, tc.status, tc.code)
|
|
220
|
+
}
|
|
221
|
+
if ct := rec.Header().Get("Content-Type"); ct != web.ProblemContentType {
|
|
222
|
+
t.Errorf("Content-Type = %q", ct)
|
|
223
|
+
}
|
|
224
|
+
if n := e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'SLIDES_DOWNLOAD' AND outcome = 'SUCCESS'`); n != 1 {
|
|
225
|
+
t.Errorf("SUCCESS rows = %d, want 1", n)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A storage failure mid-stream aborts the connection, so the client never
|
|
232
|
+
// takes a short body for the whole file. Past net/http's write buffer the 200
|
|
233
|
+
// and its Content-Length are already out and the body ends early; before it,
|
|
234
|
+
// the client gets no response at all.
|
|
235
|
+
func TestDownloadSlidesAbortsMidStream(t *testing.T) {
|
|
236
|
+
const size = 64 << 10
|
|
237
|
+
for _, tc := range []struct {
|
|
238
|
+
name string
|
|
239
|
+
failAfter int64
|
|
240
|
+
headers bool // the 200 reached the client
|
|
241
|
+
}{
|
|
242
|
+
{"after the headers were sent", 32 << 10, true},
|
|
243
|
+
{"within the write buffer", 10, false},
|
|
244
|
+
} {
|
|
245
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
246
|
+
e := newCreateEnv(t)
|
|
247
|
+
leader := addUser(t, e.db, "100001", "USER")
|
|
248
|
+
f := validForm("Idea A")
|
|
249
|
+
f.file = pdf(size)
|
|
250
|
+
id, key := e.createdWith(t, leader, f)
|
|
251
|
+
total := int64(len(e.objects.puts[key]))
|
|
252
|
+
e.objects.failAfter = tc.failAfter
|
|
253
|
+
e.objects.streamErr = errors.New("minio: connection reset")
|
|
254
|
+
|
|
255
|
+
inner := e.downloadRouter()
|
|
256
|
+
srv := httptest.NewServer(web.Recover(slog.New(slog.DiscardHandler))(
|
|
257
|
+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
258
|
+
inner.ServeHTTP(w, r.WithContext(security.WithPrincipal(r.Context(), leader)))
|
|
259
|
+
})))
|
|
260
|
+
t.Cleanup(srv.Close)
|
|
261
|
+
|
|
262
|
+
resp, err := http.Get(srv.URL + "/ideas/" + id.String() + "/slides")
|
|
263
|
+
if !tc.headers {
|
|
264
|
+
if err == nil {
|
|
265
|
+
_ = resp.Body.Close()
|
|
266
|
+
t.Fatalf("got a response (%d); want the connection aborted", resp.StatusCode)
|
|
267
|
+
}
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
if err != nil {
|
|
271
|
+
t.Fatal(err)
|
|
272
|
+
}
|
|
273
|
+
defer func() { _ = resp.Body.Close() }()
|
|
274
|
+
if resp.StatusCode != 200 || resp.ContentLength != total {
|
|
275
|
+
t.Fatalf("status %d, length %d, want 200 %d", resp.StatusCode, resp.ContentLength, total)
|
|
276
|
+
}
|
|
277
|
+
b, err := io.ReadAll(resp.Body)
|
|
278
|
+
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
279
|
+
t.Errorf("read error = %v, want unexpected EOF", err)
|
|
280
|
+
}
|
|
281
|
+
if int64(len(b)) >= total {
|
|
282
|
+
t.Errorf("read %d of %d bytes; want a short body", len(b), total)
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// An unknown commit outcome serves nothing: the audit row may not exist.
|
|
289
|
+
func TestDownloadSlidesUnknownOutcome(t *testing.T) {
|
|
290
|
+
dsn := dbtest.Start(t)
|
|
291
|
+
d := dbtest.Open(t, dsn)
|
|
292
|
+
faulty, faults := dbtest.OpenFaulty(t, dsn)
|
|
293
|
+
e := newCreateEnvOn(t, d, faulty)
|
|
294
|
+
leader := addUser(t, d, "100001", "USER")
|
|
295
|
+
id, _ := e.created(t, leader, "Idea A")
|
|
296
|
+
|
|
297
|
+
for _, fault := range []dbtest.CommitFault{dbtest.LostAckCommitted, dbtest.LostAckNotCommitted} {
|
|
298
|
+
before := e.gets()
|
|
299
|
+
faults.FailNextCommit(fault)
|
|
300
|
+
rec := e.download(t, leader, id)
|
|
301
|
+
if rec.Code != 503 || decodeProblem(t, rec).Code != "SERVICE_UNAVAILABLE" {
|
|
302
|
+
t.Fatalf("fault %v: %d %s", fault, rec.Code, rec.Body)
|
|
303
|
+
}
|
|
304
|
+
if e.gets() != before {
|
|
305
|
+
t.Errorf("fault %v: the object must not be opened", fault)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if faults.Fired() != 2 {
|
|
309
|
+
t.Errorf("faults fired = %d, want 2", faults.Fired())
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package idea_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"mime"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestContentDisposition(t *testing.T) {
|
|
11
|
+
for _, tc := range []struct{ name, want string }{
|
|
12
|
+
{"I_BSID_Amy_100001_Plan.pdf", `attachment; filename="I_BSID_Amy_100001_Plan.pdf"; filename*=UTF-8''I_BSID_Amy_100001_Plan.pdf`},
|
|
13
|
+
{"O_AAID_王小明_100002_減碳.pptx", `attachment; filename="O_AAID_____100002___.pptx"; filename*=UTF-8''O_AAID_%E7%8E%8B%E5%B0%8F%E6%98%8E_100002_%E6%B8%9B%E7%A2%B3.pptx`},
|
|
14
|
+
{`a "b"\c;d%e.pdf`, `attachment; filename="a _b__c;d%e.pdf"; filename*=UTF-8''a%20%22b%22%5Cc%3Bd%25e.pdf`},
|
|
15
|
+
} {
|
|
16
|
+
got := idea.ContentDisposition(tc.name)
|
|
17
|
+
if got != tc.want {
|
|
18
|
+
t.Errorf("%q:\n got %s\nwant %s", tc.name, got, tc.want)
|
|
19
|
+
}
|
|
20
|
+
// Go's own RFC 2231/8187 parser recovers the exact name from filename*.
|
|
21
|
+
if _, params, err := mime.ParseMediaType(got); err != nil || params["filename"] != tc.name {
|
|
22
|
+
t.Errorf("%q: parsed %q, %v", tc.name, params["filename"], err)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
package idea
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"log/slog"
|
|
8
|
+
"net/http"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
|
|
12
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
13
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
14
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea/internal/store"
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
|
|
16
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// auditReasonHidden is the one reason stored for a missing and a hidden Idea
|
|
21
|
+
// alike: the log must not tell them apart either (PRD 6.6).
|
|
22
|
+
const auditReasonHidden = "NOT_FOUND"
|
|
23
|
+
|
|
24
|
+
// View returns the Idea for GET /ideas/{id}. An Admin's successful view writes
|
|
25
|
+
// IDEA_VIEW in the same transaction as the read (fail-closed, PRD 6.11).
|
|
26
|
+
func (s *Service) View(ctx context.Context, id uuid.UUID, caller security.Principal) (Idea, error) {
|
|
27
|
+
if !caller.IsAdmin() {
|
|
28
|
+
return s.Get(ctx, id, caller)
|
|
29
|
+
}
|
|
30
|
+
var got Idea
|
|
31
|
+
_, err := db.InTx(ctx, s.DB, func(ctx context.Context, tx *sql.Tx) error {
|
|
32
|
+
var err error
|
|
33
|
+
if got, err = store.GetVisible(ctx, tx, id, caller.UserID, true); err != nil {
|
|
34
|
+
return err
|
|
35
|
+
}
|
|
36
|
+
return s.Audit.Record(ctx, tx, audit.Event{
|
|
37
|
+
Action: audit.IdeaView, Outcome: audit.Success,
|
|
38
|
+
TargetType: audit.TargetIdea, TargetID: id,
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
if errors.Is(err, store.ErrNotFound) {
|
|
42
|
+
return Idea{}, web.NewError(web.CodeNotFound, "")
|
|
43
|
+
}
|
|
44
|
+
return got, err
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Handler serves the Idea endpoints.
|
|
48
|
+
type Handler struct {
|
|
49
|
+
Service *Service
|
|
50
|
+
// ExpectedFilename builds the expected slide filename without extension
|
|
51
|
+
// (storage.ExpectedFilenameBase; injected because idea must not import
|
|
52
|
+
// storage, D29).
|
|
53
|
+
ExpectedFilename func(category, org, leaderName, leaderEmpNo, ideaName string) (string, error)
|
|
54
|
+
Fail func(w http.ResponseWriter, r *http.Request, err error)
|
|
55
|
+
// Log receives download failures the response cannot carry (nil = discard).
|
|
56
|
+
Log *slog.Logger
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type person struct {
|
|
60
|
+
EmpNo string `json:"empNo"`
|
|
61
|
+
Name string `json:"name"`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type member struct {
|
|
65
|
+
EmpNo string `json:"empNo"`
|
|
66
|
+
Name string `json:"name"`
|
|
67
|
+
JoinedAt string `json:"joinedAt"`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type pending struct {
|
|
71
|
+
EmpNo string `json:"empNo"`
|
|
72
|
+
Name string `json:"name"`
|
|
73
|
+
RequestedAt string `json:"requestedAt"`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type slides struct {
|
|
77
|
+
Ext string `json:"ext"`
|
|
78
|
+
Size int64 `json:"size"`
|
|
79
|
+
SHA256 string `json:"sha256"`
|
|
80
|
+
UpdatedAt string `json:"updatedAt"`
|
|
81
|
+
DownloadFilename string `json:"downloadFilename"`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// detail is IdeaDetail (docs/API/response-bodies.md). It is local so that
|
|
85
|
+
// timestamps use web.FormatTime (fixed milliseconds) instead of time.Time.
|
|
86
|
+
type detail struct {
|
|
87
|
+
ID uuid.UUID `json:"id"`
|
|
88
|
+
Category string `json:"category"`
|
|
89
|
+
Org string `json:"org"`
|
|
90
|
+
Direction string `json:"direction"`
|
|
91
|
+
Name string `json:"name"`
|
|
92
|
+
Summary string `json:"summary"`
|
|
93
|
+
Leader person `json:"leader"`
|
|
94
|
+
Members []member `json:"members"`
|
|
95
|
+
PendingRequests *[]pending `json:"pendingRequests"`
|
|
96
|
+
Slides slides `json:"slides"`
|
|
97
|
+
ExpectedFilenameBase string `json:"expectedFilenameBase"`
|
|
98
|
+
CreatedAt string `json:"createdAt"`
|
|
99
|
+
UpdatedAt string `json:"updatedAt"`
|
|
100
|
+
Version int64 `json:"version"`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// GetIdea serves GET /ideas/{id}.
|
|
104
|
+
func (h Handler) GetIdea(w http.ResponseWriter, r *http.Request, id api.IdeaId) {
|
|
105
|
+
p, ok := security.PrincipalFrom(r.Context())
|
|
106
|
+
if !ok {
|
|
107
|
+
h.Fail(w, r, web.NewError(web.CodeUnauthenticated, "authentication required"))
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
got, err := h.Service.View(r.Context(), uuid.UUID(id), p)
|
|
111
|
+
if err != nil {
|
|
112
|
+
var e *web.Error
|
|
113
|
+
if errors.As(err, &e) && e.Code == web.CodeNotFound {
|
|
114
|
+
e.AuditAction = string(audit.IdeaView)
|
|
115
|
+
e.AuditReason = auditReasonHidden
|
|
116
|
+
e.AuditTargetType = string(audit.TargetIdea)
|
|
117
|
+
e.AuditTargetID = uuid.UUID(id)
|
|
118
|
+
}
|
|
119
|
+
h.Fail(w, r, err)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
body, err := h.toDetail(got, p)
|
|
123
|
+
if err != nil {
|
|
124
|
+
h.Fail(w, r, err)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
web.WriteJSON(w, http.StatusOK, body)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func (h Handler) toDetail(i Idea, caller security.Principal) (detail, error) {
|
|
131
|
+
base, err := h.ExpectedFilename(i.Category, i.Org, i.LeaderName, i.LeaderEmpNo, i.Name)
|
|
132
|
+
if err != nil {
|
|
133
|
+
return detail{}, err
|
|
134
|
+
}
|
|
135
|
+
d := detail{
|
|
136
|
+
ID: i.ID, Category: i.Category, Org: i.Org, Direction: i.Direction, Name: i.Name, Summary: i.Summary,
|
|
137
|
+
Leader: person{EmpNo: i.LeaderEmpNo, Name: i.LeaderName},
|
|
138
|
+
// TODO(B5-01): approved Members and pending requests come from idea_member.
|
|
139
|
+
Members: []member{},
|
|
140
|
+
Slides: slides{Ext: i.Slides.Ext, Size: i.Slides.Size, SHA256: i.Slides.SHA256,
|
|
141
|
+
UpdatedAt: web.FormatTime(i.SlidesUpdatedAt), DownloadFilename: base + "." + i.Slides.Ext},
|
|
142
|
+
ExpectedFilenameBase: base,
|
|
143
|
+
CreatedAt: web.FormatTime(i.CreatedAt),
|
|
144
|
+
UpdatedAt: web.FormatTime(i.UpdatedAt),
|
|
145
|
+
Version: int64(i.Version),
|
|
146
|
+
}
|
|
147
|
+
if caller.IsAdmin() || caller.UserID == i.LeaderID {
|
|
148
|
+
d.PendingRequests = &[]pending{}
|
|
149
|
+
}
|
|
150
|
+
return d, nil
|
|
151
|
+
}
|