Files
fleet/server/service/client_appconfig.go
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

298 lines
10 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
"github.com/fleetdm/fleet/v4/server/version"
)
// ApplyAppConfig sends the application config to be applied to the Fleet instance.
func (c *Client) ApplyAppConfig(payload interface{}, opts fleet.ApplySpecOptions) error {
verb, path := "PATCH", "/api/latest/fleet/config"
var responseBody appConfigResponse
data, err := json.Marshal(payload)
if err != nil {
return err
}
data, err = endpointer.RewriteOldToNewKeys(data, endpointer.ExtractAliasRules(fleet.AppConfig{}))
if err != nil {
return err
}
return c.authenticatedRequestWithQuery(data, verb, path, &responseBody, opts.RawQuery())
}
// ApplyNoTeamProfiles sends the list of profiles to be applied for the hosts
// in no team.
func (c *Client) ApplyNoTeamProfiles(profiles []fleet.MDMProfileBatchPayload, opts fleet.ApplySpecOptions, assumeEnabled bool) error {
verb, path := "POST", "/api/latest/fleet/mdm/profiles/batch"
query := opts.RawQuery()
if assumeEnabled {
if query != "" {
query += "&"
}
query += "assume_enabled=true"
}
return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, query)
}
// GetAppConfig fetches the application config from the server API
func (c *Client) GetAppConfig() (*fleet.EnrichedAppConfig, error) {
verb, path := "GET", "/api/latest/fleet/config"
var responseBody fleet.EnrichedAppConfig
err := c.authenticatedRequest(nil, verb, path, &responseBody)
return &responseBody, err
}
// GetEnrollSecretSpec fetches the enroll secrets stored on the server
func (c *Client) GetEnrollSecretSpec() (*fleet.EnrollSecretSpec, error) {
verb, path := "GET", "/api/latest/fleet/spec/enroll_secret"
var responseBody getEnrollSecretSpecResponse
err := c.authenticatedRequest(nil, verb, path, &responseBody)
return responseBody.Spec, err
}
// ApplyEnrollSecretSpec applies the enroll secrets.
func (c *Client) ApplyEnrollSecretSpec(spec *fleet.EnrollSecretSpec, opts fleet.ApplySpecOptions) error {
req := applyEnrollSecretSpecRequest{Spec: spec}
verb, path := "POST", "/api/latest/fleet/spec/enroll_secret"
var responseBody applyEnrollSecretSpecResponse
return c.authenticatedRequestWithQuery(req, verb, path, &responseBody, opts.RawQuery())
}
func (c *Client) Version() (*version.Info, error) {
verb, path := "GET", "/api/latest/fleet/version"
var responseBody versionResponse
err := c.authenticatedRequest(nil, verb, path, &responseBody)
return responseBody.Info, err
}
type orgLogoAction struct {
mode fleet.OrgLogoMode
uploadPath string
}
// planAndStripOrgLogos plans the PUT /logo uploads that run after the
// AppConfig PATCH and strips the gitops-only `path` keys from the PATCH
// payload (they have no matching field on fleet.OrgInfo). When a `path`
// is given, the URL keys for that mode are stripped too so the follow-up
// PUT is the sole writer.
//
// URL changes (set or clear) ride on the PATCH. The server deletes any
// orphan blob whose URL was just overwritten with an external or empty
// value, so the client doesn't need to orchestrate a separate DELETE.
func (c *Client) planAndStripOrgLogos(
orgSettings map[string]any,
baseDir string,
dryRun bool,
logFn func(format string, args ...any),
) ([]orgLogoAction, error) {
orgInfo, _ := orgSettings["org_info"].(map[string]any)
if orgInfo == nil {
return nil, nil
}
type modeSpec struct {
mode fleet.OrgLogoMode
pathKey string
urlKey string
deprecatedURLKey string
}
specs := []modeSpec{
{fleet.OrgLogoModeLight, "org_logo_path_light_mode", "org_logo_url_light_mode", "org_logo_url_light_background"},
{fleet.OrgLogoModeDark, "org_logo_path_dark_mode", "org_logo_url_dark_mode", "org_logo_url"},
}
var actions []orgLogoAction
for _, s := range specs {
_, pathPresent := orgInfo[s.pathKey]
_, urlPresent := orgInfo[s.urlKey]
if !pathPresent && !urlPresent {
continue
}
yamlPath, _ := orgInfo[s.pathKey].(string)
yamlURL, _ := orgInfo[s.urlKey].(string)
if yamlPath != "" && yamlURL != "" {
return nil, fmt.Errorf(
"org_settings.org_info: cannot specify both '%s' and '%s' for %s mode",
s.pathKey, s.urlKey, s.mode,
)
}
switch {
case yamlPath != "":
absPath := resolveApplyRelativePath(baseDir, yamlPath)
if err := validateOrgLogoFile(absPath); err != nil {
return nil, fmt.Errorf("org logo (%s): %w", s.mode, err)
}
actions = append(actions, orgLogoAction{mode: s.mode, uploadPath: absPath})
// Strip every URL key for this mode: PUT will set the served URL
// (and its deprecated alias) after the PATCH, so we must keep
// PATCH from writing anything to either URL field.
delete(orgInfo, s.pathKey)
delete(orgInfo, s.urlKey)
delete(orgInfo, s.deprecatedURLKey)
if dryRun {
logFn("[+] would upload org logo (%s) from %s\n", s.mode, yamlPath)
}
case yamlURL != "":
// Mirror the new key into the deprecated alias: PATCH merges,
// and the server rejects the request if the two keys end up
// disagreeing after the merge. If the user explicitly set the
// deprecated alias to a different value, surface the
// conflict instead of silently overwriting it.
if dep, ok := orgInfo[s.deprecatedURLKey].(string); ok && dep != yamlURL {
return nil, fmt.Errorf(
"org_settings.org_info: '%s' (%q) conflicts with '%s' (%q) for %s mode",
s.urlKey, yamlURL, s.deprecatedURLKey, dep, s.mode,
)
}
delete(orgInfo, s.pathKey)
orgInfo[s.deprecatedURLKey] = yamlURL
default:
// Clearing this mode: surface any contradictory deprecated
// alias value rather than silently overwriting it.
if dep, ok := orgInfo[s.deprecatedURLKey].(string); ok && dep != "" {
return nil, fmt.Errorf(
"org_settings.org_info: '%s' is being cleared but '%s' is set to %q for %s mode",
s.urlKey, s.deprecatedURLKey, dep, s.mode,
)
}
delete(orgInfo, s.pathKey)
// Send both URL keys as "". If only the deprecated alias is
// sent, NormalizeLogoFields mirrors the still-populated new
// key back into it after merge, leaving the URL unchanged.
orgInfo[s.urlKey] = ""
orgInfo[s.deprecatedURLKey] = ""
}
}
return actions, nil
}
// doGitOpsOrgLogos executes the upload actions planned by
// planAndStripOrgLogos. Runs after the AppConfig PATCH so a PATCH failure
// leaves the logo store untouched.
func (c *Client) doGitOpsOrgLogos(
actions []orgLogoAction, dryRun bool, logFn func(format string, args ...any),
) error {
for _, a := range actions {
if dryRun {
continue // already logged at planning time
}
if err := c.UploadOrgLogo(a.mode, a.uploadPath); err != nil {
return fmt.Errorf("uploading org logo (%s): %w", a.mode, err)
}
logFn("[+] applied org logo (%s) from %s\n", a.mode, a.uploadPath)
}
return nil
}
// validateOrgLogoFile reads the file at path and runs the canonical
// fleet.ValidateOrgLogoBytes check on its contents, so a YAML referencing
// an invalid image fails fast at gitops apply time rather than mid-PATCH.
// The LimitReader caps the read at the file-size cap so a mis-pointed
// huge file doesn't get slurped into memory before being rejected.
func validateOrgLogoFile(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening logo file %q: %w", path, err)
}
defer f.Close()
body, err := io.ReadAll(io.LimitReader(f, fleet.OrgLogoMaxFileSize+1))
if err != nil {
return fmt.Errorf("reading logo file %q: %w", path, err)
}
if err := fleet.ValidateOrgLogoBytes(body); err != nil {
return fmt.Errorf("logo file at %q: %w", path, err)
}
return nil
}
// UploadOrgLogo uploads the file at logoPath as the org logo for the given
// mode (light or dark) via PUT /api/latest/fleet/logo. The endpoint is
// multipart/form-data with a single "logo" field. Server-side validation
// rejects files larger than 100KB or that aren't PNG/JPEG/WebP.
func (c *Client) UploadOrgLogo(mode fleet.OrgLogoMode, logoPath string) error {
verb, path := "PUT", "/api/latest/fleet/logo"
var b bytes.Buffer
w := multipart.NewWriter(&b)
fw, err := w.CreateFormFile("logo", filepath.Base(logoPath))
if err != nil {
return err
}
file, err := os.Open(logoPath)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(fw, file); err != nil {
return err
}
if err := w.Close(); err != nil {
return fmt.Errorf("closing writer: %w", err)
}
resp, err := c.doContextWithBodyAndHeaders(context.Background(), verb, path,
fmt.Sprintf("mode=%s", mode),
b.Bytes(),
map[string]string{
"Content-Type": w.FormDataContentType(),
"Accept": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", c.token),
})
if err != nil {
return fmt.Errorf("do multipart request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("uploading org logo (%s): %s: %s", mode, resp.Status, string(body))
}
return nil
}
// DeleteOrgLogo clears the stored org logo for the given mode (light, dark, or
// all) via DELETE /api/latest/fleet/logo. The endpoint is idempotent — deleting
// an absent logo is a no-op server-side.
func (c *Client) DeleteOrgLogo(mode fleet.OrgLogoMode) error {
verb, path := "DELETE", "/api/latest/fleet/logo"
var responseBody deleteOrgLogoResponse
return c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, fmt.Sprintf("mode=%s", mode))
}
// GetOrgLogoContent fetches the stored org logo bytes for the given mode
// (light or dark) via GET /api/latest/fleet/logo. Returns the bytes and the
// detected Content-Type. The endpoint returns 404 when no logo is stored for
// the requested mode; callers should treat that as "no logo present".
func (c *Client) GetOrgLogoContent(mode fleet.OrgLogoMode) (body []byte, contentType string, err error) {
verb, path := "GET", "/api/latest/fleet/logo"
resp, err := c.AuthenticatedDo(verb, path, fmt.Sprintf("mode=%s", mode), nil)
if err != nil {
return nil, "", fmt.Errorf("fetching org logo (%s): %w", mode, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, "", &notFoundErr{Msg: fmt.Sprintf("no org logo stored for %s mode", mode)}
}
if resp.StatusCode >= 400 {
errBody, _ := io.ReadAll(resp.Body)
return nil, "", fmt.Errorf("fetching org logo (%s): %s: %s", mode, resp.Status, string(errBody))
}
body, err = io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("reading org logo (%s) body: %w", mode, err)
}
return body, resp.Header.Get("Content-Type"), nil
}