Files
BountyBoard/internal/http/profile.go
etalon e8beb7d4f3 feat: sidebar nav (default), group member management, per-customer ticketing identity mapping
- Navigation: new per-user setting (default "side") renders the page links as a
  left sidebar while keeping theme/bell/avatar/logout in the top-right; falls
  back to a top bar under 760px or when set to "top". Profile selector added.
- Group conversations: record a creatorId; the creator gets a "Manage members"
  button to add/remove participants (new GET/POST/DELETE members endpoints,
  creator-only). Existing groups backfilled.
- Customer ticketing: admin can map each assigned consultant to their username
  in that customer's ticketing system. Per-customer mapping takes precedence
  over the consultant's global ticketingIdentities during sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:05:33 +02:00

262 lines
8.1 KiB
Go

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)
}
}