e8beb7d4f3
- 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>
262 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|