-
Notifications
You must be signed in to change notification settings - Fork 25
Expand file tree
/
Copy pathserver.go
More file actions
315 lines (277 loc) · 15.4 KB
/
server.go
File metadata and controls
315 lines (277 loc) · 15.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
package httpapi
import (
"context"
"embed"
"io"
"io/fs"
"log"
"net/http"
"strings"
"time"
"scrumboy/internal/httpapi/ratelimit"
"scrumboy/internal/store"
"scrumboy/internal/version"
)
type Options struct {
Logger *log.Logger
MaxRequestBody int64
ScrumboyMode string // "full" or "anonymous"
AuthRateLimit *ratelimit.Limiter
// EncryptionKey is the HMAC secret for password reset tokens. Required for admin password reset.
// Set from SCRUMBOY_ENCRYPTION_KEY (base64). If unset, password reset endpoints return 503.
EncryptionKey []byte
}
type Server struct {
store storeAPI
logger *log.Logger
maxBody int64
mode string // "full" or "anonymous"
hub *Hub
sink EventSink
authRateLimit *ratelimit.Limiter
encryptionKey []byte // for password reset tokens; nil if not configured
passwordResetAdminLimiter *ratelimit.Limiter // 10 resets/min per admin
webFS fs.FS
fileSrv http.Handler
indexHTML []byte
landingHTML []byte
swJS []byte // Service worker with version injected
}
type storeAPI interface {
Health(ctx context.Context) error
CountUsers(ctx context.Context) (int, error)
GetUser(ctx context.Context, userID int64) (store.User, error)
GetUserPasswordHash(ctx context.Context, userID int64) (string, error)
UpdateUserImage(ctx context.Context, userID int64, image *string) error
UpdateUserPassword(ctx context.Context, userID int64, newPassword string) error
BootstrapUser(ctx context.Context, email, password, name string) (store.User, error)
AuthenticateUser(ctx context.Context, email, password string) (store.User, error)
CreateUser(ctx context.Context, email, password, name string) (store.User, error)
ListUsers(ctx context.Context, requesterID int64) ([]store.User, error)
UpdateUserRole(ctx context.Context, requesterID, targetUserID int64, newRole store.SystemRole) error
DeleteUser(ctx context.Context, requesterID, targetUserID int64) error
AssignUnownedDurableProjectsToUser(ctx context.Context, userID int64) error
ClaimTemporaryBoard(ctx context.Context, projectID, userID int64) error
CreateSession(ctx context.Context, userID int64, ttl time.Duration) (token string, expiresAt time.Time, err error)
DeleteSession(ctx context.Context, token string) error
DeleteSessionsByUserID(ctx context.Context, userID int64) error
GetUserBySessionToken(ctx context.Context, token string) (store.User, error)
ListProjects(ctx context.Context) ([]store.ProjectListEntry, error)
GetProject(ctx context.Context, projectID int64) (store.Project, error)
GetProjectBySlug(ctx context.Context, slug string) (store.Project, error)
GetProjectContextBySlug(ctx context.Context, slug string, mode store.Mode) (store.ProjectContext, error)
GetProjectContextForRead(ctx context.Context, projectID int64, mode store.Mode) (store.ProjectContext, error)
CreateProject(ctx context.Context, name string) (store.Project, error)
CreateProjectWithWorkflow(ctx context.Context, name string, workflow []store.WorkflowColumn) (store.Project, error)
DeleteProject(ctx context.Context, projectID int64, userID int64) error
UpdateProjectImage(ctx context.Context, projectID int64, userID int64, image *string, dominantColor string) error
UpdateProjectName(ctx context.Context, projectID int64, userID int64, name string) error
UpdateProjectDefaultSprintWeeks(ctx context.Context, projectID int64, userID int64, weeks int) error
AddWorkflowColumn(ctx context.Context, projectID int64, name string) (store.WorkflowColumn, error)
DeleteWorkflowColumn(ctx context.Context, projectID int64, key string) error
UpdateWorkflowColumnName(ctx context.Context, projectID int64, key, newName string) error
GetProjectRole(ctx context.Context, projectID int64, userID int64) (store.ProjectRole, error)
CheckProjectRole(ctx context.Context, projectID int64, userID int64, requiredRole store.ProjectRole) error
ListProjectMembers(ctx context.Context, projectID int64, userID int64) ([]store.ProjectMember, error)
AddProjectMember(ctx context.Context, requesterID, projectID, targetUserID int64, role store.ProjectRole) error
RemoveProjectMember(ctx context.Context, requesterID, projectID, targetUserID int64) error
UpdateProjectMemberRole(ctx context.Context, requesterID, projectID, targetUserID int64, role store.ProjectRole) error
ListAvailableUsersForProject(ctx context.Context, requesterID, projectID int64) ([]store.User, error)
GetBoard(ctx context.Context, pc *store.ProjectContext, tagFilter string, searchFilter string, sprintFilter store.SprintFilter) (store.Project, []store.TagCount, []store.WorkflowColumn, map[string][]store.Todo, error)
GetBoardPaged(ctx context.Context, pc *store.ProjectContext, tagFilter string, searchFilter string, sprintFilter store.SprintFilter, limitPerLane int) (store.Project, []store.TagCount, []store.WorkflowColumn, map[string][]store.Todo, map[string]store.LaneMeta, error)
ListTagCounts(ctx context.Context, pc *store.ProjectContext) ([]store.TagCount, error)
ListTodosForBoardLane(ctx context.Context, projectID int64, columnKey string, limit int, afterRank, afterID int64, tagFilter, searchFilter string, sprintFilter store.SprintFilter) ([]store.Todo, string, bool, error)
GetDashboardSummary(ctx context.Context, userID int64, timezone string) (store.DashboardSummary, error)
ListDashboardTodos(ctx context.Context, userID int64, limit int, cursor *string) ([]store.DashboardTodo, *string, error)
GetBacklogSize(ctx context.Context, projectID int64, mode store.Mode) ([]store.BurndownPoint, error)
GetRealBurndown(ctx context.Context, projectID int64, mode store.Mode) ([]store.RealBurndownPoint, error)
GetRealBurndownForSprint(ctx context.Context, projectID, sprintID int64, mode store.Mode) ([]store.RealBurndownPoint, error)
ListTags(ctx context.Context, projectID int64, mode store.Mode) ([]store.TagWithColor, error)
ListUserTags(ctx context.Context, userID int64) ([]store.TagWithColor, error)
ListUserTagsForProject(ctx context.Context, userID int64, projectID int64) ([]store.TagWithColor, error)
ListBoardTagsForProject(ctx context.Context, projectID int64) ([]store.TagWithColor, error)
GetTagIDByName(ctx context.Context, userID int64, tagName string) (int64, error)
GetAnyTagIDByName(ctx context.Context, tagName string) (int64, error)
GetBoardScopedTagIDByName(ctx context.Context, projectID int64, tagName string) (int64, error)
ResolveTagForColorUpdate(ctx context.Context, projectID int64, viewerUserID *int64, tagName string, isAnonymousBoard bool) (int64, error)
UpdateTagColor(ctx context.Context, viewerUserID *int64, tagID int64, color *string) error
UpdateTagColorForProject(ctx context.Context, projectID int64, viewerUserID *int64, tagName string, color *string, isAnonymousBoard bool) error
GetTagColor(ctx context.Context, userID int64, tagID int64) (*string, error)
DeleteTag(ctx context.Context, userID int64, tagID int64, isAnonymousBoard bool) error
CreateTodo(ctx context.Context, projectID int64, in store.CreateTodoInput, mode store.Mode) (store.Todo, error)
CreateSprint(ctx context.Context, projectID int64, name string, plannedStartAt, plannedEndAt time.Time) (store.Sprint, error)
ListSprints(ctx context.Context, projectID int64) ([]store.Sprint, error)
HasSprints(ctx context.Context, projectID int64) (bool, error)
ListSprintsWithTodoCount(ctx context.Context, projectID int64) ([]store.SprintWithTodoCount, error)
CountUnscheduledTodos(ctx context.Context, projectID int64) (int64, error)
GetSprintByID(ctx context.Context, sprintID int64) (store.Sprint, error)
GetSprintByProjectNumber(ctx context.Context, projectID, number int64) (store.Sprint, error)
GetActiveSprintByProjectID(ctx context.Context, projectID int64) (*store.Sprint, error)
ActivateSprint(ctx context.Context, projectID, sprintID int64) error
CloseSprint(ctx context.Context, sprintID int64) error
UpdateSprint(ctx context.Context, sprintID int64, in store.UpdateSprintInput) error
DeleteSprint(ctx context.Context, projectID, sprintID int64) error
UpdateTodo(ctx context.Context, todoID int64, in store.UpdateTodoInput, mode store.Mode) (store.Todo, error)
DeleteTodo(ctx context.Context, todoID int64, mode store.Mode) error
GetProjectIDForTodo(ctx context.Context, todoID int64) (int64, error)
MoveTodo(ctx context.Context, todoID int64, toColumnKey string, afterID, beforeID *int64, mode store.Mode) (store.Todo, error)
UpdateTodoByLocalID(ctx context.Context, projectID, localID int64, in store.UpdateTodoInput, mode store.Mode) (store.Todo, error)
GetTodoByLocalID(ctx context.Context, projectID, localID int64, mode store.Mode) (store.Todo, error)
DeleteTodoByLocalID(ctx context.Context, projectID, localID int64, mode store.Mode) error
MoveTodoByLocalID(ctx context.Context, projectID, localID int64, toColumnKey string, afterLocalID, beforeLocalID *int64, mode store.Mode) (store.Todo, error)
AddLink(ctx context.Context, projectID, fromLocalID, toLocalID int64, linkType string, mode store.Mode) error
RemoveLink(ctx context.Context, projectID, fromLocalID, toLocalID int64, mode store.Mode) error
ListLinksForTodo(ctx context.Context, projectID, localID int64, mode store.Mode) ([]store.TodoLinkTarget, error)
ListBacklinksForTodo(ctx context.Context, projectID, localID int64, mode store.Mode) ([]store.TodoLinkTarget, error)
SearchTodosForLinkPicker(ctx context.Context, projectID int64, q string, limit int, excludeLocalIDs []int64, mode store.Mode) ([]store.TodoLinkTarget, error)
CreateAnonymousBoard(ctx context.Context) (store.Project, error)
ExportAllProjects(ctx context.Context, mode store.Mode) (*store.ExportData, error)
ImportProjects(ctx context.Context, data *store.ExportData, mode store.Mode, importMode string) (*store.ImportResult, error)
ImportProjectsWithTarget(ctx context.Context, data *store.ExportData, mode store.Mode, importMode string, targetSlug string) (*store.ImportResult, error)
PreviewImport(ctx context.Context, data *store.ExportData, mode store.Mode, importMode string) (*store.PreviewResult, error)
GetUserPreference(ctx context.Context, userID int64, key string) (string, error)
SetUserPreference(ctx context.Context, userID int64, key, value string) error
// 2FA
CreateLogin2FAPending(ctx context.Context, userID int64, ttl time.Duration) (token string, expiresAt time.Time, err error)
GetUserByLogin2FAPendingToken(ctx context.Context, token string) (store.User, int, error)
IncrementLogin2FAPendingAttempt(ctx context.Context, token string) error
DeleteLogin2FAPendingToken(ctx context.Context, token string) error
CreateTwoFactorEnrollment(ctx context.Context, userID int64, secretEnc string, ttl time.Duration) (setupToken string, expiresAt time.Time, err error)
GetTwoFactorEnrollmentByToken(ctx context.Context, token string) (userID int64, secretEnc string, err error)
IncrementEnrollmentAttempt(ctx context.Context, token string) error
DeleteTwoFactorEnrollmentByToken(ctx context.Context, token string) error
GetUserTwoFactorSecret(ctx context.Context, userID int64) (string, error)
SetUserTwoFactor(ctx context.Context, userID int64, encryptedSecret string) error
ClearUserTwoFactor(ctx context.Context, userID int64) error
AddRecoveryCodes(ctx context.Context, userID int64, codes []string) error
ConsumeRecoveryCode(ctx context.Context, userID int64, code string) (bool, error)
DeleteRecoveryCodesByUser(ctx context.Context, userID int64) error
EncryptTOTPSecret(plaintext []byte) (string, error)
DecryptTOTPSecret(encrypted string) ([]byte, error)
}
//go:embed web/**
//go:embed web/vendor/**
var embeddedWeb embed.FS
func NewServer(st storeAPI, opts Options) *Server {
logger := opts.Logger
if logger == nil {
logger = log.New(io.Discard, "", 0)
}
webFS, err := fs.Sub(embeddedWeb, "web")
if err != nil {
panic(err)
}
indexHTML, err := fs.ReadFile(webFS, "index.html")
if err != nil {
panic(err)
}
// Inject version into index.html
indexHTML = []byte(strings.ReplaceAll(string(indexHTML), "{{VERSION}}", version.Version))
landingHTML, err := fs.ReadFile(webFS, "landing.html")
if err != nil {
panic(err)
}
// Inject version into landing.html
landingHTML = []byte(strings.ReplaceAll(string(landingHTML), "{{VERSION}}", version.Version))
swJS, err := fs.ReadFile(webFS, "sw.js")
if err != nil {
panic(err)
}
swJS = []byte(strings.ReplaceAll(string(swJS), "{{VERSION}}", version.Version))
maxBody := opts.MaxRequestBody
if maxBody <= 0 {
maxBody = 1 << 20
}
mode := opts.ScrumboyMode
if mode != "full" && mode != "anonymous" {
mode = "full" // Default to full if invalid
}
// IMPORTANT: Anonymous mode disables all authentication, including valid session cookies.
// When mode == "anonymous", requestContext() ignores session cookies and all requests
// are treated as unauthenticated. This ensures anonymous temp boards have creator_user_id = NULL
// and never appear in user listings. See requestContext() for implementation.
authRateLimit := opts.AuthRateLimit
if authRateLimit == nil {
authRateLimit = ratelimit.New(10, time.Minute)
}
hub := NewHub(defaultSubscriberBuffer)
passwordResetAdminLimiter := ratelimit.New(10, time.Minute)
var encKey []byte
if opts.EncryptionKey != nil {
encKey = opts.EncryptionKey
}
return &Server{
store: st,
logger: logger,
maxBody: maxBody,
mode: mode,
hub: hub,
sink: hub,
authRateLimit: authRateLimit,
encryptionKey: encKey,
passwordResetAdminLimiter: passwordResetAdminLimiter,
webFS: webFS,
fileSrv: http.FileServer(http.FS(webFS)),
indexHTML: indexHTML,
landingHTML: landingHTML,
swJS: swJS,
}
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Apply proxy-aware scheme and host so cookies and redirects use the client-facing URL.
if isSecureRequest(r) {
if r.URL.Scheme != "https" {
r.URL.Scheme = "https"
}
if h := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); h != "" {
r.URL.Host = h
}
}
start := time.Now()
// Log immediately to catch requests that hang before completion
if r.URL.Path == "/api/backup/import" {
s.logger.Printf("INCOMING: %s %s (Content-Length: %s)", r.Method, r.URL.Path, r.Header.Get("Content-Length"))
}
defer func() {
s.logger.Printf("%s %s %dms", r.Method, r.URL.Path, time.Since(start).Milliseconds())
}()
if r.URL.Path == "/healthz" {
s.handleHealthz(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/api/") {
s.handleAPI(w, r)
return
}
s.handleSPA(w, r)
}
func (s *Server) requestContext(r *http.Request) context.Context {
ctx := r.Context()
// CRITICAL: Anonymous mode is an authentication boundary.
// In anonymous mode, session cookies are ignored and requests are treated as unauthenticated.
// This ensures anonymous temp boards have creator_user_id = NULL and never appear in user listings.
if s.mode == "anonymous" {
return ctx // Do not extract user from session cookie
}
// Best-effort: attach authenticated principal to request context (if session cookie is valid).
// Do not error/redirect here; auth is enforced by API handlers/store authorization.
c, err := r.Cookie("scrumboy_session")
if err != nil || c == nil || c.Value == "" {
return ctx
}
u, err := s.store.GetUserBySessionToken(ctx, c.Value)
if err != nil {
return ctx
}
ctx = store.WithUserID(ctx, u.ID)
ctx = store.WithUserEmail(ctx, u.Email)
ctx = store.WithUserName(ctx, u.Name)
return ctx
}
func (s *Server) storeMode() store.Mode {
mode, _ := store.ParseMode(s.mode)
if mode == "" {
return store.ModeFull // Default
}
return mode
}