All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m28s
CI / Build container images (pull_request) Successful in 2m7s
CI / Auth e2e pack (pull_request) Successful in 9m37s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s
CD / Build and push images (push) Successful in 23s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m47s
CD / Promote to Int (push) Successful in 16s
CI / Lint, typecheck, test (push) Successful in 7m25s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m41s
CI / Import/export fidelity gate (push) Successful in 1m12s
An instance had no way to look like itself: the top bar said "Dorfteich" whatever the operator called their instance, `instance.name` was never rendered in the running app at all, and there was no favicon anywhere — `index.html` had no `<link rel="icon">` and `public/` held only fonts and theme-init.js. Where the line is drawn, and why: - **The api never decodes an image.** Cropping, scaling and the conversion to PNG happen on a canvas in the browser; the api checks the PNG signature, reads the IHDR dimensions at their fixed offsets and enforces the caps. An image library would put a decoder in front of attacker-supplied bytes AND would have to be carried through the `--network none` offline build. Reading two big-endian integers is not decoding. - **SVG is refused**, with its own error message rather than a generic "not a PNG": it can carry script, and serving it from our own origin would be a cross-site-scripting vector. An operator who tried one should learn that it is deliberate. - **The crop is driven by number inputs, not by dragging.** A drag-only cropper excludes keyboard and switch users outright; a number input is arrow-key operable and screen-reader readable without any custom aria. The resulting pixel size is stated in text, not only drawn as a frame. - **The variant is chosen by CSS, not JavaScript.** `theme-init.js` has already resolved `data-theme` before first paint, so the correct logo is the one painted rather than the one that appears after a flash. Without a dark variant the LIGHT logo carries both themes — the operator's own asset shown unchanged beats one they did not choose (the rule #307 extends to ponds). The settings screen warns; it never blocks. - **The favicon link is static, its resource dynamic.** index.html stays a static file and the api answers with the uploaded icon or a shipped default — that route must never 404, or the browser keeps its generic icon for good. The default is generated by a script from Node's own zlib (`gen-default-favicon.mjs`), for the same offline-build reason. - Both favicon sizes are uploaded together: one source, one crop, so the tab icon and the home-screen icon can never disagree. - Branding is served WITHOUT a session, because the login screen carries it and the browser fetches the favicon before anyone signs in. The admin screen says so — an operator may not expect their logo to be public. - The metadata is not writable through the settings endpoint: it describes bytes on disk, and hand-writing it would claim an asset that is not there. `./data/branding` follows the three-step rule #303 paid for: env default + `data-dirs.ts` entry, compose volume (repo AND the stages on ONE), and the `mkdir`/`chown` line in the api Dockerfile. `data-dirs.test.ts` is new and closes the hole that made #303's variant invisible: the nightly archive skips a missing directory WORDLESSLY, so the fence now demands that every `*_DIR` the backup env declares actually travels in the archive. Verified against the real defect — removing the line fails it by name. Audit catalogue v1.7 (`branding.changed`), carrying `scope` from the start so #307 is the same event with a different scope, not a second id. Verified: api suite 103 files green (a lone `public-api` ECONNRESET under local parallel load, green in isolation — the documented local flake); branding suite 12 tests against a real directory; crop arithmetic unit tests; a11y pack 11/11 in both schemes; /admin measured at 320px with the new section (overflow 0); and the whole flow walked in the browser: upload → crop 780×180 → stored as 512×118 → logo in the sidebar linking home with the instance name as its accessible name → topbar wordmark following `instance.name` → light logo still shown under `data-theme="dark"`.
174 lines
6.0 KiB
TypeScript
174 lines
6.0 KiB
TypeScript
import { BRANDING_SOURCE_TYPES } from '@dorfteich/shared';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { Field } from '../components/forms';
|
|
import { CropRect, clampCrop, drawCrop, initialCrop, loadImage, outputSize } from './crop';
|
|
|
|
/**
|
|
* Pick an image, crop it, see the result (issue #306).
|
|
*
|
|
* The crop is driven by NUMBER INPUTS, not by dragging. A drag-only cropper
|
|
* excludes keyboard and switch users outright, and a number input is
|
|
* arrow-key operable, screen-reader readable and announces its value without
|
|
* any custom aria plumbing — the accessible option is also the simpler one.
|
|
* The preview canvas is a picture of the result, never the control.
|
|
*
|
|
* The resulting pixel dimensions are stated in TEXT next to it, so the outcome
|
|
* does not depend on seeing the frame.
|
|
*/
|
|
export function CropField({
|
|
idPrefix,
|
|
square,
|
|
maxEdge,
|
|
onChange,
|
|
}: {
|
|
idPrefix: string;
|
|
/** Favicons are square by construction; a logo keeps its own proportions. */
|
|
square: boolean;
|
|
maxEdge: number;
|
|
/** Called with the rendering canvas whenever the crop changes, so the
|
|
* parent can encode PNGs from it on submit. Null = nothing selected. */
|
|
onChange: (canvas: HTMLCanvasElement | null) => void;
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation('branding');
|
|
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
|
const [crop, setCrop] = useState<CropRect | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
|
|
const out = image && crop ? outputSize(crop, maxEdge) : null;
|
|
|
|
// Held in a ref so the redraw depends on the crop alone: callers pass an
|
|
// inline arrow, whose identity changes every render and would otherwise
|
|
// repaint the canvas on every keystroke in the surrounding form.
|
|
const notifyRef = useRef(onChange);
|
|
notifyRef.current = onChange;
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas || !image || !crop) {
|
|
notifyRef.current(null);
|
|
return;
|
|
}
|
|
// Derived inside the effect: `outputSize` returns a fresh object every
|
|
// render, so as a dependency it would never compare equal.
|
|
drawCrop(image, crop, outputSize(crop, maxEdge), canvas);
|
|
notifyRef.current(canvas);
|
|
}, [image, crop, maxEdge]);
|
|
|
|
async function choose(file: File | undefined): Promise<void> {
|
|
setError(null);
|
|
if (!file) {
|
|
setImage(null);
|
|
setCrop(null);
|
|
return;
|
|
}
|
|
if (!(BRANDING_SOURCE_TYPES as readonly string[]).includes(file.type)) {
|
|
setImage(null);
|
|
setCrop(null);
|
|
// SVG is the one an operator is most likely to try, and it is refused
|
|
// on purpose (it can carry script) — say which types work instead.
|
|
setError(file.type === 'image/svg+xml' ? 'branding_svg_rejected' : 'branding_not_an_image');
|
|
return;
|
|
}
|
|
try {
|
|
const loaded = await loadImage(file);
|
|
setImage(loaded);
|
|
setCrop(initialCrop(loaded, square));
|
|
} catch {
|
|
setError('branding_not_an_image');
|
|
}
|
|
}
|
|
|
|
function update(patch: Partial<CropRect>): void {
|
|
if (!image || !crop) return;
|
|
const next = { ...crop, ...patch };
|
|
// A square crop has one size, so width and height move together.
|
|
if (square && patch.width !== undefined) next.height = patch.width;
|
|
setCrop(clampCrop(next, image));
|
|
}
|
|
|
|
return (
|
|
<div className="crop-field">
|
|
<Field label={t('crop.file')} hint={t('crop.fileHint')} error={error ?? undefined}>
|
|
<input
|
|
type="file"
|
|
accept={BRANDING_SOURCE_TYPES.join(',')}
|
|
onChange={(event) => void choose(event.target.files?.[0])}
|
|
/>
|
|
</Field>
|
|
|
|
{image && crop && out && (
|
|
<>
|
|
<div className="crop-field__controls">
|
|
<Field label={t('crop.x')}>
|
|
<input
|
|
type="number"
|
|
id={`${idPrefix}-x`}
|
|
min={0}
|
|
max={image.width - crop.width}
|
|
value={crop.x}
|
|
onChange={(event) => update({ x: Number(event.target.value) })}
|
|
/>
|
|
</Field>
|
|
<Field label={t('crop.y')}>
|
|
<input
|
|
type="number"
|
|
id={`${idPrefix}-y`}
|
|
min={0}
|
|
max={image.height - crop.height}
|
|
value={crop.y}
|
|
onChange={(event) => update({ y: Number(event.target.value) })}
|
|
/>
|
|
</Field>
|
|
<Field label={square ? t('crop.size') : t('crop.width')}>
|
|
<input
|
|
type="number"
|
|
id={`${idPrefix}-w`}
|
|
min={1}
|
|
max={square ? Math.min(image.width, image.height) : image.width}
|
|
value={crop.width}
|
|
onChange={(event) => update({ width: Number(event.target.value) })}
|
|
/>
|
|
</Field>
|
|
{!square && (
|
|
<Field label={t('crop.height')}>
|
|
<input
|
|
type="number"
|
|
id={`${idPrefix}-h`}
|
|
min={1}
|
|
max={image.height}
|
|
value={crop.height}
|
|
onChange={(event) => update({ height: Number(event.target.value) })}
|
|
/>
|
|
</Field>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="linklike"
|
|
onClick={() => setCrop(initialCrop(image, square))}
|
|
>
|
|
{t('crop.reset')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="crop-field__preview">
|
|
<canvas ref={canvasRef} className="crop-field__canvas" />
|
|
{/* The outcome in words: the frame alone would leave a
|
|
keyboard-only or screen-reader user guessing. */}
|
|
<p className="crop-field__result" role="status">
|
|
{t('crop.result', {
|
|
width: out.width,
|
|
height: out.height,
|
|
sourceWidth: image.width,
|
|
sourceHeight: image.height,
|
|
})}
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|