fleet/frontend/utilities/file/orgLogoFile.ts
Nico b4a207fb5a
Add ability to upload custom org logos (#44390)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #44330, Resolves #44331

# Checklist for submitter

- [x] 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. (I'd defer integration tests to a
separate PR since this one is pretty large already.)

- [x] QA'd all new/changed functionality manually. I've tested this on
both the setup flow and the organization settings page. I haven't had
the time to test this on other places where we render the logo (macOS
setup experience / MDM migration dialog).


https://github.com/user-attachments/assets/95d4eae5-3da6-40f4-98a1-8575b97d96b3

## New Fleet configuration settings

- [x] Setting(s) is/are explicitly excluded from GitOps.

Will handle GitOps in a separate PR.

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

* **New Features**
  * Organizations can upload custom logos for light and dark modes.
* Registration and Org Settings support logo file upload, preview,
per-mode replace/delete, and validation (size & image formats).
* Activity feed records logo changes/deletions; site nav displays
uploaded logos per theme.
* File uploader/preview adds a Fleet logo graphic option and improved
logo validation.
  * Config/GitOps outputs now include separate dark/light logo fields.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-05 14:42:52 +02:00

71 lines
2.2 KiB
TypeScript

export const ORG_LOGO_ACCEPT = ".png,.jpg,.jpeg,.webp";
export const ORG_LOGO_MAX_SIZE_BYTES = 100 * 1024; // 100 KB
export const ORG_LOGO_HELP_TEXT =
"Personalize Fleet with your brand. For best results, use a square image at least 150px wide.";
export const ORG_LOGO_ALLOWED_TYPES = ["png", "jpeg", "webp"] as const;
export type ImageFileType = typeof ORG_LOGO_ALLOWED_TYPES[number];
const upperAllowedTypes = ORG_LOGO_ALLOWED_TYPES.map((t) => t.toUpperCase());
const ORG_LOGO_ALLOWED_TYPES_LABEL = `${upperAllowedTypes
.slice(0, -1)
.join(", ")}, or ${upperAllowedTypes[upperAllowedTypes.length - 1]}`;
export interface IOrgLogoValidationResult {
valid: boolean;
error?: string;
}
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const detectImageType = (bytes: Uint8Array): ImageFileType | null => {
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (bytes.length >= 8 && PNG_MAGIC.every((b, i) => bytes[i] === b)) {
return "png";
}
// JPEG: FF D8 FF
if (
bytes.length >= 3 &&
bytes[0] === 0xff &&
bytes[1] === 0xd8 &&
bytes[2] === 0xff
) {
return "jpeg";
}
// WebP: "RIFF" at 0..3, "WEBP" at 8..11 (4..7 carries the file size).
if (
bytes.length >= 12 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
return "webp";
}
return null;
};
// validateOrgLogoFile sniffs the first 12 bytes of a File to verify
// it's one of the allowed image formats — the browser-reported
// file.type is based on extension, not content, so we can't trust it
// (e.g. a WebP saved with a `.png` extension).
export const validateOrgLogoFile = async (
file: File
): Promise<IOrgLogoValidationResult> => {
if (file.size > ORG_LOGO_MAX_SIZE_BYTES) {
return { valid: false, error: "Logo must be 100 KB or less." };
}
const headerBuf = await file.slice(0, 12).arrayBuffer();
const detected = detectImageType(new Uint8Array(headerBuf));
if (!detected || !ORG_LOGO_ALLOWED_TYPES.includes(detected)) {
return {
valid: false,
error: `Logo must be a ${ORG_LOGO_ALLOWED_TYPES_LABEL} file.`,
};
}
return { valid: true };
};