package org import ( "encoding/json" "errors" "io" "net/http" "github.com/go-chi/chi/v5" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Handler serves the branding surface. Reads are open to every role because the shell renders the mark // for everyone in the organization. Writes are gated on org.settings.write, which the capability matrix // grants to the owner alone. type Handler struct{ svc *Service } func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } func (h *Handler) Routes(r chi.Router) { r.With(security.RequireCapability(security.CapCandidateRead)).Get("/org/branding", h.branding) r.With(security.RequireCapability(security.CapCandidateRead)).Get("/org/logo", h.getLogo) r.With(security.RequireCapability(security.CapOrgSettings)).Put("/org/logo", h.putLogo) r.With(security.RequireCapability(security.CapOrgSettings)).Delete("/org/logo", h.deleteLogo) r.With(security.RequireCapability(security.CapOrgSettings)).Patch("/org/branding", h.patchBranding) } func (h *Handler) branding(w http.ResponseWriter, r *http.Request) { b, err := h.svc.Branding(r.Context()) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, b) } // getLogo serves the stored bytes. Every header here is load bearing. // // The Content-Type is the MIME the SERVER derived at upload time by decoding the file, never anything a // caller supplied. nosniff stops a browser second guessing that and interpreting the body as something // executable. The restrictive Content-Security-Policy means that even if a future format slipped through // validation, the response could not load a script, a frame or a remote resource. Cache-Control is // private because a logo is tenant data and must never land in a shared cache. func (h *Handler) getLogo(w http.ResponseWriter, r *http.Request) { logo, err := h.svc.Logo(r.Context()) if err != nil { h.fail(w, err) return } w.Header().Set("Content-Type", logo.MIME) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") w.Header().Set("Cache-Control", "private, max-age=300, must-revalidate") w.Header().Set("ETag", logo.ETag) // A conditional request that still matches costs no body. The ETag is a hash of the bytes, so it // changes the moment the logo does and never goes stale. if match := r.Header.Get("If-None-Match"); match != "" && match == logo.ETag { w.WriteHeader(http.StatusNotModified) return } w.WriteHeader(http.StatusOK) _, _ = w.Write(logo.Bytes) } // putLogo accepts a raw image body. The request is capped one byte above the limit so an oversized // upload is refused by the reader rather than buffered in full and then measured. func (h *Handler) putLogo(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, MaxLogoBytes+1) raw, err := io.ReadAll(r.Body) if err != nil { writeErr(w, http.StatusRequestEntityTooLarge, "logo exceeds the maximum size") return } b, err := h.svc.SetLogo(r.Context(), raw) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, b) } func (h *Handler) deleteLogo(w http.ResponseWriter, r *http.Request) { b, err := h.svc.ClearLogo(r.Context()) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, b) } func (h *Handler) patchBranding(w http.ResponseWriter, r *http.Request) { var in struct { DisplayName string `json:"display_name"` } dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)) dec.DisallowUnknownFields() if err := dec.Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body") return } b, err := h.svc.SetDisplayName(r.Context(), in.DisplayName) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, b) } // fail maps a service error onto a status. The wrapped error can carry table and column names, so it is // never returned to the caller. func (h *Handler) fail(w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrNoLogo): writeErr(w, http.StatusNotFound, "no logo set") case errors.Is(err, ErrLogoTooLarge): writeErr(w, http.StatusRequestEntityTooLarge, "logo exceeds the maximum size") case errors.Is(err, ErrLogoFormat): writeErr(w, http.StatusUnsupportedMediaType, "logo must be a PNG or JPEG image") case errors.Is(err, ErrLogoTooBig): writeErr(w, http.StatusUnprocessableEntity, "logo dimensions exceed the maximum") case errors.Is(err, ErrDisplayName): writeErr(w, http.StatusBadRequest, "display name must be between 1 and 120 characters") case errors.Is(err, tenant.ErrNoTenant): writeErr(w, http.StatusForbidden, "no tenant context") case errors.Is(err, ErrNoDatabase): writeErr(w, http.StatusServiceUnavailable, "database unavailable") default: writeErr(w, http.StatusInternalServerError, "internal error") } } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } func writeErr(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg}) }