feat: base UI shell with themes, auth pages, profile, and role navigation (phase 4)
- embedded Go templates + vanilla ES-module JS + hand-written CSS, no build step
- exact §10 beige light / dark design tokens, square edges (radius 2px),
system font stack, visible focus rings
- theme toggle persisted to localStorage and the user profile
- login/register/change-password pages wired to the auth API
- profile page: avatar upload (image-sniffed, old file cleanup), bio,
contacts, arbitrary extra key/value fields, optimistic-concurrency 409
- role-based top navigation with placeholders for later-phase areas
- GET /files/{id} with scope-based access (session) or signed token (§5.1)
- security headers incl. CSP without unsafe-inline scripts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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"`
|
||||
Notifications *domain.NotificationPrefs `json:"notifications"`
|
||||
} `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 theme != "light" && theme != "dark" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_theme", "theme must be light or dark")
|
||||
return
|
||||
}
|
||||
set["settings.theme"] = theme
|
||||
}
|
||||
if req.Settings.Notifications != nil {
|
||||
set["settings.notifications"] = *req.Settings.Notifications
|
||||
}
|
||||
}
|
||||
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
|
||||
default:
|
||||
allowed = meta.OwnerID == user.ID || user.Roles.Admin ||
|
||||
user.Roles.Consultant // scoped tighter as chat/task features land
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user