Files
Nico 0c45575b82 Custom org logo fixes: delete for external URLs + gitops switch from path to URL (#45236)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #45230, Resolves #45213

# Checklist for submitter

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

#### For 45213

Did a gitops run to apply external URLs as logos:

<img width="395" height="122" alt="Screenshot 2026-05-12 at 10 52 25 AM"
src="https://github.com/user-attachments/assets/a1fea9ce-7a3d-419b-8c56-68568dcc704e"
/>

Command: `./build/fleetctl gitops -f
/Users/nico/dev/gitops-output-test/default.yml` (**gitops-output-test**
is where I usually have my gitops outputs).

Then I deleted both from the UI:



https://github.com/user-attachments/assets/03899795-7cda-485d-b87e-25f829b928b7

#### For 45230

- Uploaded logos using **org_logo_path_dark_mode** and
**org_logo_path_light_mode** in the first GitOps run.
- In the second GitOps run, set **org_logo_url_dark_mode:
"https://placehold.co/100"** and **org_logo_url_light_mode:
"https://placehold.co/100"**.



https://github.com/user-attachments/assets/4dfd0440-9a17-44e1-aa7d-395afd9c7d7a



For unreleased bug fixes in a release candidate, one of:

- [x] Confirmed that the fix is not expected to adversely impact load
test results



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Organization logo deletion is idempotent and no longer errors on
repeated deletes.
* Orphaned hosted logo blobs are cleaned up after config changes;
deletion or activity-emission failures are logged and do not abort
requests.
  * Conflicting/contradictory URL updates now produce validation errors.

* **Improvements**
* GitOps/logo sync strips gitops-only path keys and only performs
uploads in non-dry-run flows.
* Logo URL handling simplified: deprecated alias fields are mirrored and
path keys are stripped to avoid unintended deletions.

* **Tests**
* Added lifecycle tests for upload/delete, activity emissions, and
in-memory PNG generation.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45236)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-13 14:47:01 +02:00

399 lines
12 KiB
Go

package service
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
"github.com/gorilla/mux"
)
const orgLogoMaxFileSize = fleet.OrgLogoMaxFileSize
// PUT /api/v1/fleet/logo
type putOrgLogoRequest struct {
Mode fleet.OrgLogoMode
Body []byte
}
type putOrgLogoResponse struct {
Err error `json:"error,omitempty"`
}
func (r putOrgLogoResponse) Error() error { return r.Err }
func (putOrgLogoRequest) DecodeRequest(_ context.Context, r *http.Request) (any, error) {
mode, err := parseLogoModeQuery(r.URL.Query().Get("mode"))
if err != nil {
return nil, err
}
if err := r.ParseMultipartForm(platform_http.MaxMultipartFormSize); err != nil {
return nil, &fleet.BadRequestError{
Message: "failed to parse multipart form",
InternalErr: err,
}
}
files := r.MultipartForm.File["logo"]
if len(files) == 0 {
return nil, &fleet.BadRequestError{Message: "missing 'logo' file in multipart form"}
}
f, err := files[0].Open()
if err != nil {
return nil, &fleet.BadRequestError{Message: "failed to open uploaded logo", InternalErr: err}
}
defer f.Close()
body, err := io.ReadAll(io.LimitReader(f, orgLogoMaxFileSize+1))
if err != nil {
return nil, &fleet.BadRequestError{Message: "failed to read uploaded logo", InternalErr: err}
}
return putOrgLogoRequest{Mode: mode, Body: body}, nil
}
func putOrgLogoEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(putOrgLogoRequest)
if err := svc.UploadOrgLogo(ctx, req.Mode, bytes.NewReader(req.Body)); err != nil {
return putOrgLogoResponse{Err: err}, nil
}
return putOrgLogoResponse{}, nil
}
// DELETE /api/v1/fleet/logo
type deleteOrgLogoRequest struct {
Mode fleet.OrgLogoMode
}
type deleteOrgLogoResponse struct {
Err error `json:"error,omitempty"`
}
func (r deleteOrgLogoResponse) Error() error { return r.Err }
func (deleteOrgLogoRequest) DecodeRequest(_ context.Context, r *http.Request) (any, error) {
mode, err := parseLogoModeQuery(r.URL.Query().Get("mode"))
if err != nil {
return nil, err
}
return deleteOrgLogoRequest{Mode: mode}, nil
}
func deleteOrgLogoEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(deleteOrgLogoRequest)
if err := svc.DeleteOrgLogo(ctx, req.Mode); err != nil {
return deleteOrgLogoResponse{Err: err}, nil
}
return deleteOrgLogoResponse{}, nil
}
// GET /api/latest/fleet/logo
type getOrgLogoRequest struct {
Mode fleet.OrgLogoMode
}
type getOrgLogoResponse struct {
Err error
Body []byte
}
func (r getOrgLogoResponse) Error() error { return r.Err }
func (r getOrgLogoResponse) HijackRender(_ context.Context, w http.ResponseWriter) {
if r.Err != nil {
return
}
contentType := fleet.ContentTypeForOrgLogo(r.Body)
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(r.Body)))
w.Header().Set("Cache-Control", "no-store")
// nosniff: stops the browser from MIME-sniffing the body as HTML
// (XSS vector) if upstream Content-Type ever drifts.
w.Header().Set("X-Content-Type-Options", "nosniff")
if contentType == "image/svg+xml" {
// CSP keeps the direct-URL view inert (where SVG loads as a
// document, not <img>). 'unsafe-inline' allows the inline
// <style> blocks most SVGs include.
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
}
_, _ = w.Write(r.Body)
}
func (getOrgLogoRequest) DecodeRequest(_ context.Context, r *http.Request) (any, error) {
raw := mux.Vars(r)["mode"]
if raw == "" {
raw = r.URL.Query().Get("mode")
}
if raw == "" {
return nil, &fleet.BadRequestError{Message: "mode query parameter is required (light or dark)"}
}
m := fleet.OrgLogoMode(raw)
// GET serves a single stored file. OrgLogoModeAll is rejected even
// though it's a recognized request mode for PUT/DELETE.
if !m.IsStorable() {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("invalid mode %q: must be 'light' or 'dark'", raw),
}
}
return getOrgLogoRequest{Mode: m}, nil
}
func getOrgLogoEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(getOrgLogoRequest)
body, _, err := svc.GetOrgLogo(ctx, req.Mode)
if err != nil {
return getOrgLogoResponse{Err: err}, nil
}
return getOrgLogoResponse{Body: body}, nil
}
// parseLogoModeQuery interprets the `mode` query string for the PUT and
// DELETE endpoints. Empty defaults to OrgLogoModeAll. All three values
// (light / dark / all) are valid here; the GET endpoint has its own
// stricter parser since "all" doesn't make sense when serving bytes.
func parseLogoModeQuery(raw string) (fleet.OrgLogoMode, error) {
if raw == "" {
return fleet.OrgLogoModeAll, nil
}
m := fleet.OrgLogoMode(raw)
if !m.IsValid() {
return "", &fleet.BadRequestError{
Message: fmt.Sprintf("invalid mode %q: must be 'light', 'dark', or 'all'", raw),
}
}
return m, nil
}
// Service implementation
func (svc *Service) UploadOrgLogo(ctx context.Context, mode fleet.OrgLogoMode, content io.ReadSeeker) error {
if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionWrite); err != nil {
return err
}
if !mode.IsValid() {
return &fleet.BadRequestError{Message: fmt.Sprintf("invalid mode %q", mode)}
}
if svc.orgLogoStore == nil {
return ctxerr.New(ctx, "org logo store not configured")
}
// Buffer once so each Put gets its own reader without re-Seeking the
// underlying source — and so a mid-loop failure can roll back.
body, err := io.ReadAll(io.LimitReader(content, orgLogoMaxFileSize+1))
if err != nil {
return ctxerr.Wrap(ctx, err, "buffering logo content")
}
if err := fleet.ValidateOrgLogoBytes(body); err != nil {
return err
}
modes := mode.Modes()
var stored []fleet.OrgLogoMode
for _, m := range modes {
if err := svc.orgLogoStore.Put(ctx, m, bytes.NewReader(body)); err != nil {
// Best-effort rollback so the store doesn't end up with a
// half-written set (e.g. light stored, dark failed). We
// don't fail the request on rollback errors — the original
// Put error is what the caller cares about.
for _, sm := range stored {
_ = svc.orgLogoStore.Delete(ctx, sm)
}
return ctxerr.Wrapf(ctx, err, "storing org logo (%s)", m)
}
stored = append(stored, m)
}
if err := svc.updateOrgLogoURLs(ctx, modes, true); err != nil {
return err
}
if vc, ok := viewer.FromContext(ctx); ok {
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeChangedOrgLogo{
Mode: string(mode),
}); err != nil {
return ctxerr.Wrap(ctx, err, "create changed_org_logo activity")
}
}
return nil
}
func (svc *Service) DeleteOrgLogo(ctx context.Context, mode fleet.OrgLogoMode) error {
if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionWrite); err != nil {
return err
}
if !mode.IsValid() {
return &fleet.BadRequestError{Message: fmt.Sprintf("invalid mode %q", mode)}
}
if svc.orgLogoStore == nil {
return ctxerr.New(ctx, "org logo store not configured")
}
ctx = ctxdb.BypassCachedMysql(ctx, true)
ac, err := svc.ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "loading app config")
}
modes := mode.Modes()
// AppConfig URL fields that were non-empty before this call.
var clearedURL bool
// Modes whose logo is Fleet-hosted (a blob lives in the store).
var modesWithBlob []fleet.OrgLogoMode
for _, m := range modes {
if getOrgLogoURL(&ac.OrgInfo, m) != "" {
clearOrgLogoURL(&ac.OrgInfo, m)
clearedURL = true
}
exists, err := svc.orgLogoStore.Exists(ctx, m)
if err != nil {
return ctxerr.Wrapf(ctx, err, "checking org logo exists (%s)", m)
}
if exists {
modesWithBlob = append(modesWithBlob, m)
}
}
if !clearedURL && len(modesWithBlob) == 0 {
return nil
}
// Save before deleting blobs: a Delete failure then leaves a clean retry path.
if clearedURL {
if err := svc.ds.SaveAppConfig(ctx, ac); err != nil {
return ctxerr.Wrap(ctx, err, "saving app config after logo delete")
}
}
// Collect errors so one failing mode doesn't leave the others untouched.
var errs []error
for _, m := range modesWithBlob {
if err := svc.orgLogoStore.Delete(ctx, m); err != nil {
errs = append(errs, ctxerr.Wrapf(ctx, err, "deleting org logo (%s)", m))
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
if vc, ok := viewer.FromContext(ctx); ok {
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedOrgLogo{
Mode: string(mode),
}); err != nil {
return ctxerr.Wrap(ctx, err, "create deleted_org_logo activity")
}
}
return nil
}
// getOrgLogoURL returns the URL for the given mode, preferring the new
// mode-aware field and falling back to the deprecated alias.
func getOrgLogoURL(o *fleet.OrgInfo, mode fleet.OrgLogoMode) string {
switch mode {
case fleet.OrgLogoModeLight:
if o.OrgLogoURLLightMode != "" {
return o.OrgLogoURLLightMode
}
return o.OrgLogoURLLightBackground
case fleet.OrgLogoModeDark:
if o.OrgLogoURLDarkMode != "" {
return o.OrgLogoURLDarkMode
}
return o.OrgLogoURL
}
return ""
}
// clearOrgLogoURL zeroes both the new and deprecated URL fields for the
// given mode so NormalizeLogoFields can't restore the value from the alias.
func clearOrgLogoURL(o *fleet.OrgInfo, mode fleet.OrgLogoMode) {
switch mode {
case fleet.OrgLogoModeLight:
o.OrgLogoURLLightMode = ""
o.OrgLogoURLLightBackground = ""
case fleet.OrgLogoModeDark:
o.OrgLogoURLDarkMode = ""
o.OrgLogoURL = ""
}
}
func (svc *Service) GetOrgLogo(ctx context.Context, mode fleet.OrgLogoMode) ([]byte, int64, error) {
svc.authz.SkipAuthorization(ctx)
if svc.orgLogoStore == nil {
return nil, 0, ctxerr.New(ctx, "org logo store not configured")
}
if !mode.IsStorable() {
return nil, 0, &fleet.BadRequestError{Message: fmt.Sprintf("invalid mode %q: must be 'light' or 'dark'", mode)}
}
// Discard the metadata size: we cap reads at orgLogoMaxFileSize and
// derive the response size from the actual bytes read so an oversized
// or mid-flight-changed object can't desync Content-Length from body.
r, _, err := svc.orgLogoStore.Get(ctx, mode)
if err != nil {
return nil, 0, ctxerr.Wrap(ctx, err, "fetching org logo")
}
defer r.Close()
// Read one byte over the limit so we can detect (and reject) anything
// larger than the upload validator should ever have allowed in.
body, err := io.ReadAll(io.LimitReader(r, orgLogoMaxFileSize+1))
if err != nil {
return nil, 0, ctxerr.Wrap(ctx, err, "reading org logo bytes")
}
if int64(len(body)) > orgLogoMaxFileSize {
return nil, 0, ctxerr.New(ctx, "stored org logo exceeds max size")
}
// Re-validate on read so a blob planted directly in the object store
// (bypassing the upload API) is still rejected.
if err := fleet.ValidateOrgLogoBytes(body); err != nil {
return nil, 0, ctxerr.Wrap(ctx, err, "stored org logo failed validation")
}
return body, int64(len(body)), nil
}
// orgLogoServingURL builds the URL persisted in AppConfig after an upload. The `v` param is a cache-buster (ignored server-side; only `mode` is read).
func orgLogoServingURL(mode fleet.OrgLogoMode) string {
return fmt.Sprintf("/api/latest/fleet/logo?mode=%s&v=%d", mode, time.Now().UnixNano())
}
// updateOrgLogoURLs sets (uploaded=true) or clears (uploaded=false) the
// AppConfig URL fields for the given modes. The dual-write between the
// new and deprecated fields is handled by NormalizeLogoFields.
func (svc *Service) updateOrgLogoURLs(ctx context.Context, modes []fleet.OrgLogoMode, uploaded bool) error {
// Bypass the AppConfig cache so this read-modify-write picks up any
// concurrent writes to other fields (e.g. a settings PATCH that
// landed between the upload start and now). Otherwise we'd save a
// stale snapshot and clobber that other write.
ctx = ctxdb.BypassCachedMysql(ctx, true)
ac, err := svc.ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "loading app config")
}
for _, m := range modes {
var url string
if uploaded {
url = orgLogoServingURL(m)
}
switch m {
case fleet.OrgLogoModeLight:
ac.OrgInfo.OrgLogoURLLightMode = url
ac.OrgInfo.OrgLogoURLLightBackground = url
case fleet.OrgLogoModeDark:
ac.OrgInfo.OrgLogoURLDarkMode = url
ac.OrgInfo.OrgLogoURL = url
}
}
if err := svc.ds.SaveAppConfig(ctx, ac); err != nil {
return ctxerr.Wrap(ctx, err, "saving app config after logo update")
}
return nil
}