<!-- 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 --> [](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45236) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
399 lines
12 KiB
Go
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
|
|
}
|