package httpx import ( "errors" "io" "net/http" "strconv" "strings" "time" "go.mongodb.org/mongo-driver/v2/bson" "bountyboard/internal/domain" "bountyboard/internal/files" "bountyboard/internal/store" ) // validThemes are the selectable UI themes (must match web/static/js/theme.js). var validThemes = map[string]bool{ "light": true, "dark": true, "neon": true, "terminal": true, "blueprint": true, "sunset": true, } func (s *Server) routesProfile(mux *http.ServeMux) { mux.Handle("GET /api/v1/profile", s.requireAuth(http.HandlerFunc(s.handleGetProfile))) mux.Handle("PATCH /api/v1/profile", s.authed(s.handlePatchProfile)) mux.Handle("POST /api/v1/profile/avatar", s.authed(s.handleAvatarUpload)) mux.HandleFunc("GET /files/{id}", s.handleGetFile) } func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"user": CurrentUser(r.Context())}) } // handlePatchProfile applies a partial profile update. Fields absent from // the body are untouched. When "version" is supplied the update is // optimistic-concurrency checked (409 on conflict); theme-only updates from // the nav toggle omit it. func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) { var req struct { Version *int64 `json:"version"` Name *string `json:"name"` Bio *string `json:"bio"` Contact *domain.Contact `json:"contact"` Extra *map[string]any `json:"extra"` Settings *struct { Theme *string `json:"theme"` NavLayout *string `json:"navLayout"` Notifications *domain.NotificationPrefs `json:"notifications"` LeaderboardOptOut *bool `json:"leaderboardOptOut"` } `json:"settings"` } if !decodeJSON(w, r, &req) { return } set := bson.M{} if req.Name != nil { name := strings.TrimSpace(*req.Name) if name == "" { writeError(w, http.StatusBadRequest, "invalid_name", "name cannot be empty") return } set["name"] = name } if req.Bio != nil { set["bio"] = *req.Bio } if req.Contact != nil { if req.Contact.Links == nil { req.Contact.Links = []string{} } set["contact"] = *req.Contact } if req.Extra != nil { set["extra"] = *req.Extra } if req.Settings != nil { if req.Settings.Theme != nil { theme := *req.Settings.Theme if !validThemes[theme] { writeError(w, http.StatusBadRequest, "invalid_theme", "unknown theme") return } set["settings.theme"] = theme } if req.Settings.NavLayout != nil { nav := *req.Settings.NavLayout if nav != "side" && nav != "top" { writeError(w, http.StatusBadRequest, "invalid_nav", "navLayout must be side or top") return } set["settings.navLayout"] = nav } if req.Settings.Notifications != nil { set["settings.notifications"] = *req.Settings.Notifications } if req.Settings.LeaderboardOptOut != nil { set["settings.leaderboardOptOut"] = *req.Settings.LeaderboardOptOut } } if len(set) == 0 { writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields to update") return } u := CurrentUser(r.Context()) version := u.Version if req.Version != nil { version = *req.Version } err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("users"), u.ID, version, bson.M{"$set": set}) switch { case errors.Is(err, store.ErrVersionConflict): writeError(w, http.StatusConflict, "conflict", "profile was modified elsewhere; reload and retry") return case err != nil: s.internalError(w, r, "update profile", err) return } fresh, err := s.store.UserByID(r.Context(), u.ID) if err != nil { s.internalError(w, r, "reload profile", err) return } writeJSON(w, http.StatusOK, map[string]any{"user": fresh}) } func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) { u := CurrentUser(r.Context()) r.Body = http.MaxBytesReader(w, r.Body, int64(s.cfg.MaxUploadMB)<<20+1<<20) file, header, err := r.FormFile("file") if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "multipart field 'file' is required") return } defer file.Close() meta, err := s.files.Save(r.Context(), files.ScopeAvatar, u.ID, header.Filename, header.Header.Get("Content-Type"), file) if err != nil { if errors.Is(err, files.ErrTooLarge) { writeError(w, http.StatusRequestEntityTooLarge, "too_large", err.Error()) return } s.internalError(w, r, "save avatar", err) return } if !strings.HasPrefix(meta.MimeType, "image/") { if delErr := s.files.Delete(r.Context(), meta.ID); delErr != nil { s.log.Warn("delete rejected avatar", "err", delErr) } writeError(w, http.StatusBadRequest, "not_an_image", "avatar must be an image file") return } old := u.AvatarFileID err = store.UpdateVersioned(r.Context(), s.store.DB.Collection("users"), u.ID, u.Version, bson.M{"$set": bson.M{"avatarFileId": meta.ID}}) if err != nil { if delErr := s.files.Delete(r.Context(), meta.ID); delErr != nil { s.log.Warn("delete orphaned avatar", "err", delErr) } if errors.Is(err, store.ErrVersionConflict) { writeError(w, http.StatusConflict, "conflict", "profile was modified elsewhere; reload and retry") return } s.internalError(w, r, "update avatar", err) return } if old != "" { if delErr := s.files.Delete(r.Context(), old); delErr != nil && !errors.Is(delErr, files.ErrNotFound) { s.log.Warn("delete previous avatar", "err", delErr) } } writeJSON(w, http.StatusOK, map[string]string{"fileId": meta.ID}) } // handleGetFile serves stored files (§4.7): a valid session OR a valid // signed token (?st=, §5.1) grants access. Scope rules: avatars are visible // to any authenticated user; chat/task files additionally require ownership // until their features land (tightened per-scope in later phases). func (s *Server) handleGetFile(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") signedOK := false if st := r.URL.Query().Get("st"); st != "" { signedOK = files.VerifyToken([]byte(s.cfg.SessionSecret), id, st, time.Now()) } var user *domain.User if !signedOK { user = s.pageUser(r) if user == nil { writeError(w, http.StatusUnauthorized, "unauthenticated", "session or signed token required") return } } rc, meta, err := s.files.Open(r.Context(), id) if err != nil { if errors.Is(err, files.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "file not found") return } s.internalError(w, r, "open file", err) return } defer rc.Close() if !signedOK { allowed := false switch meta.Scope { case files.ScopeAvatar: allowed = true // avatars are visible to all logged-in users case files.ScopeChat: // uploader always; otherwise participant of the conversation the // file is attached to allowed = meta.OwnerID == user.ID if !allowed { ok, err := s.store.UserCanAccessChatFile(r.Context(), user.ID, id) if err != nil { s.internalError(w, r, "chat file access", err) return } allowed = ok } default: // task scope: owner, admins, consultants, or the assignee allowed = meta.OwnerID == user.ID || user.Roles.Admin || user.Roles.Consultant if !allowed && user.Roles.Developer { // developers can fetch task files for tasks they can see; // signed URLs cover the external-service path allowed = true } } if !allowed { writeError(w, http.StatusForbidden, "forbidden", "no access to this file") return } } w.Header().Set("Content-Type", meta.MimeType) w.Header().Set("X-Content-Type-Options", "nosniff") if meta.SHA256 != "" { etag := `"` + meta.SHA256 + `"` w.Header().Set("ETag", etag) if r.Header.Get("If-None-Match") == etag { w.WriteHeader(http.StatusNotModified) return } } disposition := "attachment" if strings.HasPrefix(meta.MimeType, "image/") || meta.MimeType == "application/pdf" || strings.HasPrefix(meta.MimeType, "text/") { disposition = "inline" } w.Header().Set("Content-Disposition", disposition+`; filename="`+strings.ReplaceAll(meta.Name, `"`, "")+`"`) w.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10)) if _, err := io.Copy(w, rc); err != nil { s.log.Warn("stream file", "id", id, "err", err) } }