186 lines
8.5 KiB
TypeScript
186 lines
8.5 KiB
TypeScript
// 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<typeof brandingSchema>
|
|
|
|
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<string> {
|
|
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<void> {
|
|
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<BrandView> {
|
|
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<BrandView> {
|
|
// 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")
|
|
}
|