Files
feuerwerk/src/core/model/generated.ts
T
adminandClaude Opus 5.5 6b49a0bea7 Give each account its own folder of uploaded files to delete (#344)
Uploaded images, fonts and sounds belonged to nobody. Every account
saw every file in the pickers, and only an administrator could
delete one, through the API alone. Now every account uploads into a
folder of its own, sees its own files and nobody's, and can delete a
file of its own that no project uses.

- Files are stored as data/<kind>/<folder>/<sha256>.<ext>, and the
  folder is part of asset.file. A name without a folder belongs to
  nobody.
- The folder is a random 12-character name kept in users.json and
  given with the first upload. Renaming an account moves no file, and
  a released game's URLs carry no account name.
- The list shows the caller's own files and nobody's. An
  administrator sees all, each with owner and deletable.
- DELETE takes the name with its folder. It answers 403 unless the
  file is the caller's or the caller is an administrator, and 409
  while a project, snapshot or release of any account names it
  (FileUsage). The 409 names the caller's own projects only.
- The pickers have a Delete button. The first press asks, the second
  deletes, and a refusal shows the server's reason.
- A deleted account's folder stays and only administrators see it.
- tools/migrate-file-owners.php moved every used file into the folder
  of the account using it (a copy each for two accounts) and rewrote
  projects, snapshots and releases. A project's revision goes up, so
  an open editor gets a conflict instead of saving old names back.
- Font families are named after the hash alone, so a folder never
  ends up in a CSS family name.

Run on stein2 on 2026-10-07, backup in
/root/config-backup-20261007/data-before-344: 25 files used,
27 copies, 8 projects, 53 snapshots and 1 release rewritten, 30
unused files stay nobody's. Afterwards all 600 references resolve to
a file in their owner's folder.

Found while building: the Delete button cut its new label to "Del…"
because a button does not measure itself again on a new text. The
picker now lays its button row out again.

Tests:
- PHP files.test.php (new): 46 checks, counter-check 3 red
- Unit core + editor: 220 files, 3403 tests
- e2e 34 (melektaus-atx): ALL PASSED - 67 checks (2 new)
- Gate (melektaus-atx): CHECK EXIT 0, 256 files, 3977 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:25:13 +02:00

91 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Pictures that are drawn rather than uploaded – vector art (#129), the pixel editor (#130).
*
* A picture is named by `TextureRef.file` everywhere: the sprite, the tile, the tilemap mesh, the
* particle source and the picker all pass that string around. A generated picture therefore gets a
* key of its own, `gen:<assetId>@<revision>`, which no uploaded file can look like (those are
* `<folder>/<sha256>.<ext>`, see `isTextureFile`). Whoever draws it is asked for it under that key.
*
* The revision is the other half of the contract: it changes whenever the drawing changes, so a
* changed picture is a new key. Nothing has to ask "is this still valid" – the library rasterises
* the key it is given and lets the old one go. A counter and a fingerprint of the content both
* work; a fingerprint cannot be forgotten in a command, which is why the token is a string.
*/
import type { Asset } from './Asset';
import type { Project } from './Project';
const PREFIX = 'gen:';
/** Anything but `@`, which separates the two halves, and nothing empty. */
const TOKEN = /^[^@]+$/;
/** What an asset that draws its own picture tells the picture path. */
export interface GeneratedPicture {
/** Canvas size in pixels; the sprite takes its size from it, as it does from an image. */
width: number;
height: number;
/** Changes with the drawing – a counter or a fingerprint of the content. */
revision: string | number;
}
/**
* Reads the canvas of one asset type; null when this asset draws nothing (yet). The project is there
* for a drawing that shows another asset – a vector shape filled with a texture (#186).
*/
export type PictureSource = (asset: Asset, project?: Project) => GeneratedPicture | null;
/**
* Keyed by asset type as a plain string, not by `AssetType`: a plugin may bring an asset type of
* its own (#126), and the picture path has no reason to know the closed list.
*/
const sources = new Map<string, PictureSource>();
/** Registers the reader for an asset type; the returned function takes it back out again. */
export function registerPictureSource(type: string, read: PictureSource): () => void {
sources.set(type, read);
return () => {
if (sources.get(type) === read) sources.delete(type);
};
}
export function generatesPictures(type: string): boolean {
return sources.has(type);
}
/** The canvas this asset draws, or null when its type draws none. */
export function generatedPictureOf(asset: Asset, project?: Project): GeneratedPicture | null {
const picture = sources.get(asset.type)?.(asset, project) ?? null;
if (!picture) return null;
return picture.width > 0 && picture.height > 0 ? picture : null;
}
/** FNV-1a over the text, in base 36: short, stable across sessions, and free of `@`. */
export function fingerprint(text: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(36);
}
export function generatedKey(assetId: string, revision: string | number): string {
return `${PREFIX}${assetId}@${revision}`;
}
export function isGeneratedFile(file: string): boolean {
return parseGeneratedKey(file) !== null;
}
/** The two halves of a key, or null for anything that is not one – an uploaded file included. */
export function parseGeneratedKey(file: string): { assetId: string; revision: string } | null {
if (!file.startsWith(PREFIX)) return null;
const rest = file.slice(PREFIX.length);
const at = rest.lastIndexOf('@');
if (at <= 0) return null;
const assetId = rest.slice(0, at);
const revision = rest.slice(at + 1);
if (!TOKEN.test(assetId) || !TOKEN.test(revision)) return null;
return { assetId, revision };
}