Files

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")
}