// The use cases of the branding module: read the organization's brand, change it, replace a logo, // and read a logo back. Everything a person may type is checked here, on the way in, because what is // stored is later written into a stylesheet and an image tag for every member of the organization. import { type LogoRendition, logoRenditions } from "@conusai-kit/brand/logo" import { checkLogo, checkLogoUpload } from "@conusai-kit/brand/logo/check" import { conflict, notFound, type TenantScope, validationFailed } from "@conusai-kit/core" import type { DataSource, ObjectStore } from "@conusai-kit/data" import { brandingSchema, organizations } from "@conusai-kit/tenancy" import type { z } from "zod" import type { BrandView, LogoSize, LogoVariant } from "../contract/index.js" export { type BrandChange, getBrand, setBrand } from "./brand-data.js" export interface BrandingDeps { readonly data: DataSource readonly store: ObjectStore } export type OrgScope = TenantScope type StoredBrand = z.output const viewOf = (branding: StoredBrand | null): BrandView => ({ displayName: branding?.displayName ?? null, primary: branding?.primary ?? null, accent: branding?.accent ?? null, logoLight: branding?.logoLight ?? null, logoDark: branding?.logoDark ?? null, }) const stored = (branding: StoredBrand): StoredBrand | null => (Object.keys(branding).length === 0 ? null : branding) /** The 16-hex-character tag in a logo's path, which is also the middle of its storage keys. */ const TAG = /[?&]v=([0-9a-f]{16})$/ /** * A raster logo is stored as its two renditions, `1x` and `2x` (PNGs, `@conusai-kit/brand/logo`), and never as it was * uploaded; an SVG is stored as uploaded, under `svg`, because the SVG rule refuses and never rewrites. */ type Kind = "1x" | "2x" | "svg" const KINDS: readonly Kind[] = ["1x", "2x", "svg"] const keyOf = (variant: LogoVariant, tag: string, kind: Kind): string => `branding/logo-${variant}-${tag}-${kind}` const fieldOf = (variant: LogoVariant): "logoLight" | "logoDark" => (variant === "light" ? "logoLight" : "logoDark") const kindOf = (rendition: LogoRendition): Kind => (rendition.scale === 1 ? "1x" : "2x") async function tagOf(bytes: Uint8Array): Promise { const copy = new Uint8Array(bytes.length) copy.set(bytes) const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", copy)) return Array.from(digest.slice(0, 8), (b) => b.toString(16).padStart(2, "0")).join("") } /** Deletes the stored files behind a logo path, whichever of them exist. A failure leaves an unreferenced file at worst, so it does not fail the change. */ async function removeLogoFiles( deps: BrandingDeps, scope: OrgScope, variant: LogoVariant, path: string | undefined, ): Promise { const tag = path === undefined ? undefined : TAG.exec(path)?.[1] if (tag === undefined) return for (const kind of KINDS) await deps.store.delete(scope, keyOf(variant, tag, kind)).catch(() => undefined) } /** Removes a logo: the row stops naming it, and then its stored files go (a leftover file is unreferenced, never served). */ export async function clearLogo(deps: BrandingDeps, scope: OrgScope, variant: LogoVariant): Promise { const field = fieldOf(variant) const result = await deps.data.unitOfWork.run(scope, async (tx) => { const org = await tx.global(organizations).get(scope.orgId) if (!org) throw notFound("no such organization") const { [field]: previous, ...rest } = org.branding ?? {} const branding = stored(brandingSchema.parse(rest)) const updated = await tx.global(organizations).update(scope.orgId, { branding }, org.version) return { view: viewOf(updated.branding), previous } }) await removeLogoFiles(deps, scope, variant, result.previous) return result.view } /** * Replaces a logo. The upload is checked by the kit's rules (`@conusai-kit/brand/logo`): a PNG or WebP that is one, up * to 2 MiB, or an SVG with nothing in it that can run or fetch, up to 256 KiB. A raster file is resized and re-encoded * into the 1x and 2x PNGs that are stored and served (each is checked again as a stored logo before it is put); an * SVG is stored as it came. A file the checks refuse, or the decoder cannot draw, is a 422 that names the rule. */ export async function setLogo( deps: BrandingDeps, scope: OrgScope, input: { variant: LogoVariant; contentType: string; bytes: Uint8Array }, ): Promise { // The reason names a rule, never a part of the file: a rejected upload is somebody's document. const refused = (reason: string): never => { throw validationFailed([{ path: "body", message: reason }], "this logo cannot be used") } const upload = checkLogoUpload(input.contentType, input.bytes) if (!upload.ok) return refused(upload.reason) const files: Array<{ kind: Kind; contentType: string; bytes: Uint8Array }> = [] if (upload.kind === "svg") files.push({ kind: "svg", contentType: "image/svg+xml", bytes: input.bytes }) else { const made = await logoRenditions(input.contentType, input.bytes) if (!made.ok) return refused(made.reason) for (const rendition of made.renditions) { const verdict = checkLogo("image/png", rendition.png) if (!verdict.ok) return refused(verdict.reason) files.push({ kind: kindOf(rendition), contentType: "image/png", bytes: rendition.png }) } } // The tag is the content of what is stored (the largest file): the same image uploaded twice is the same logo. const tag = await tagOf((files[files.length - 1] as { bytes: Uint8Array }).bytes) const org = await deps.data.global(organizations).get(scope.orgId) if (!org) throw notFound("no such organization") for (const file of files) { await deps.store.put(scope, { key: keyOf(input.variant, tag, file.kind), body: file.bytes, contentType: file.contentType, }) } const field = fieldOf(input.variant) const path = `/${org.slug}/brand/logo/${input.variant}?v=${tag}` // The same image uploaded again produces the files the row already names: those are in use, and // a failed update below must not remove them. const inUse = TAG.exec(org.branding?.[field] ?? "")?.[1] === tag try { const result = await deps.data.unitOfWork.run(scope, async (tx) => { const current = await tx.global(organizations).get(scope.orgId) if (!current) throw notFound("no such organization") const branding = stored(brandingSchema.parse({ ...(current.branding ?? {}), [field]: path })) const updated = await tx.global(organizations).update(scope.orgId, { branding }, current.version) return { view: viewOf(updated.branding), previous: current.branding?.[field] } }) // The replaced files go once the row no longer names them (and not when the same image was uploaded again). if (result.previous !== path) await removeLogoFiles(deps, scope, input.variant, result.previous) return result.view } catch (error) { // The row never named the new files (a version conflict, a lost connection): they are // unreferenced the moment they were put, so they go now rather than staying as orphans. if (!inUse) { for (const file of files) { await deps.store.delete(scope, keyOf(input.variant, tag, file.kind)).catch(() => undefined) } } throw error } } /** * A stored logo's bytes: the size asked for (2x by default), or the SVG when that is what the organization uploaded * (an SVG has one size, so either size answers with it). */ export async function readLogo( deps: BrandingDeps, scope: OrgScope, variant: LogoVariant, size: LogoSize = "2x", ): Promise<{ contentType: string; bytes: Uint8Array }> { const org = await deps.data.global(organizations).get(scope.orgId) if (!org) throw notFound("no such organization") const path = org.branding?.[fieldOf(variant)] const tag = path === undefined ? undefined : TAG.exec(path)?.[1] if (tag === undefined) throw notFound("this organization has no such logo") for (const kind of [size, "svg"] as const) { const object = await deps.store.get(scope, keyOf(variant, tag, kind)) if (!object) continue const chunks: Uint8Array[] = [] const reader = object.body.getReader() for (;;) { const { done, value } = await reader.read() if (done) break if (value) chunks.push(value) } const bytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0)) let at = 0 for (const chunk of chunks) { bytes.set(chunk, at) at += chunk.length } return { contentType: object.contentType, bytes } } throw conflict("the logo is recorded but its file is missing") }