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>
413 lines
15 KiB
TypeScript
413 lines
15 KiB
TypeScript
/**
|
||
* The dialog behind "+ → Texture / Font / Sound": everything the server holds of one kind, with
|
||
* what the project already calls it. Offering the server's files rather than the machine's is the
|
||
* point – uploading the same file twice is wasteful, and nothing collects the files a project
|
||
* stopped referencing. Uploading is still one button away.
|
||
*
|
||
* The list differs per kind (tiles for pictures, rows with a sample line for typefaces, rows with a
|
||
* ▶ for sounds); the dialog around it does not.
|
||
*/
|
||
import type { Container, FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||
import type { AssetType } from '../core/model/AssetTypes';
|
||
import type { Project } from '../core/model/Project';
|
||
import type { StoredFile } from '../core/store/FileStore';
|
||
import {
|
||
Button,
|
||
drawControlBox,
|
||
type HStack,
|
||
formatBytes,
|
||
Label,
|
||
Popup,
|
||
pressCount,
|
||
ScrollContainer,
|
||
Theme,
|
||
textStyle,
|
||
Widget,
|
||
crispText,
|
||
} from '../ui';
|
||
import { dialogButtons } from './FormDialog';
|
||
|
||
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
|
||
export const PICKER_WIDTH = 560;
|
||
const PICKER_HEIGHT = 340;
|
||
|
||
/** One offered file, with the asset that already wears it (if any). */
|
||
export type PickerItem<S extends StoredFile = StoredFile> = S & { assetName: string | null };
|
||
|
||
/** Pairs what the server lists with what the project calls it – the asset name is the caption worth showing. */
|
||
export function withAssetNames<S extends StoredFile>(
|
||
stored: S[],
|
||
project: Project,
|
||
type: AssetType,
|
||
): PickerItem<S>[] {
|
||
const named = new Map<string, string>();
|
||
for (const asset of project.assets) {
|
||
if (asset.type === type && 'file' in asset) named.set(asset.file, asset.name);
|
||
}
|
||
return stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null }));
|
||
}
|
||
|
||
/** Whose a file is (#344), as the line under the list says it. */
|
||
export function ownerText(owner: string | null): string {
|
||
return owner === null ? 'no owner' : owner === '' ? 'owner deleted' : `owner ${owner}`;
|
||
}
|
||
|
||
/** The line under the list for a picked file: its upload name and size, whose it is, and who wears it. */
|
||
function describeStored(item: PickerItem): string {
|
||
const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
|
||
const owned = `${size} – ${ownerText(item.owner)}`;
|
||
return item.assetName ? `${owned} – already in the project as "${item.assetName}"` : owned;
|
||
}
|
||
|
||
export interface PickerRow {
|
||
/** A Container holding the rest, not a Graphics with children: Pixi v8 only lets containers have them. */
|
||
box: Container;
|
||
background: Graphics;
|
||
caption: Text;
|
||
}
|
||
|
||
/**
|
||
* The selectable part of the dialog: one entry per file, one of them picked, a double press
|
||
* confirms. How entries are built, placed and drawn is the subclass's.
|
||
*/
|
||
export abstract class PickerList<Item extends PickerItem, Row extends PickerRow> extends Widget {
|
||
protected items: Item[] = [];
|
||
protected rows: Row[] = [];
|
||
private selectedFile: string | null = null;
|
||
private readonly empty = crispText({ text: '', style: textStyle('muted') });
|
||
private readonly unwatch: (() => void)[] = [];
|
||
onPick?: (file: string) => void;
|
||
onActivate?: (file: string) => void;
|
||
emptyText = '';
|
||
|
||
constructor() {
|
||
super();
|
||
this.addChild(this.empty);
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
|
||
}
|
||
|
||
/** Redraws whenever a library reports an arrival, until the list is destroyed. */
|
||
protected watch(library: { onChange(fn: () => void): () => void }, onArrive: () => void): void {
|
||
this.unwatch.push(library.onChange(onArrive));
|
||
}
|
||
|
||
setItems(items: Item[]): void {
|
||
this.items = items;
|
||
for (const row of this.rows) row.box.destroy({ children: true });
|
||
this.rows = items.map(() => {
|
||
const row = this.buildRow();
|
||
this.addChild(row.box);
|
||
return row;
|
||
});
|
||
if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null;
|
||
this.layout();
|
||
this.redraw();
|
||
}
|
||
|
||
get selected(): Item | null {
|
||
return this.items.find((item) => item.file === this.selectedFile) ?? null;
|
||
}
|
||
|
||
get fileList(): string[] {
|
||
return this.items.map((item) => item.file);
|
||
}
|
||
|
||
get usedFiles(): string[] {
|
||
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
|
||
}
|
||
|
||
/** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */
|
||
get captions(): string[] {
|
||
return this.rows.map((row) => row.caption.text);
|
||
}
|
||
|
||
select(file: string | null): void {
|
||
if (file === this.selectedFile) return;
|
||
this.selectedFile = file;
|
||
this.redraw();
|
||
if (file) this.onPick?.(file);
|
||
}
|
||
|
||
/** The row of a file, with its index; null when the file is not offered. */
|
||
protected rowOf(file: string): { row: Row; index: number } | null {
|
||
const index = this.items.findIndex((item) => item.file === file);
|
||
const row = this.rows[index];
|
||
return row ? { row, index } : null;
|
||
}
|
||
|
||
override redraw(): void {
|
||
this.empty.text = this.items.length === 0 ? this.emptyText : '';
|
||
this.empty.position.set(0, 0);
|
||
this.rows.forEach((row, index) => {
|
||
const item = this.items[index];
|
||
if (item) this.redrawRow(row, item, item.file === this.selectedFile);
|
||
});
|
||
}
|
||
|
||
override destroy(): void {
|
||
for (const stop of this.unwatch) stop();
|
||
super.destroy({ children: true });
|
||
}
|
||
|
||
protected drawBox(row: Row, w: number, h: number, selected: boolean): void {
|
||
row.background.clear();
|
||
drawControlBox(row.background, w, h, {
|
||
fill: selected ? Theme.colors.selection : Theme.colors.control,
|
||
border: selected ? Theme.colors.accent : Theme.colors.border,
|
||
});
|
||
}
|
||
|
||
protected onPress(e: FederatedPointerEvent): void {
|
||
const local = this.toLocal(e.global);
|
||
const item = this.items[this.indexAt(local.x, local.y)];
|
||
if (!item) return;
|
||
this.select(item.file);
|
||
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
|
||
}
|
||
|
||
protected abstract buildRow(): Row;
|
||
/** Which entry a local point is on; any index without an item means none. */
|
||
protected abstract indexAt(x: number, y: number): number;
|
||
protected abstract redrawRow(row: Row, item: Item, selected: boolean): void;
|
||
}
|
||
|
||
/** A list of equally tall rows, one under the other. */
|
||
export abstract class RowList<Item extends PickerItem, Row extends PickerRow> extends PickerList<Item, Row> {
|
||
protected abstract readonly rowHeight: number;
|
||
protected readonly rowGap = 4;
|
||
|
||
/** Global centre of a row (test hook); `fromX` keeps clear of a column that does something else. */
|
||
rowGlobal(file: string, fromX = 0): { x: number; y: number } | null {
|
||
const found = this.rowOf(file);
|
||
if (!found) return null;
|
||
const p = found.row.box.getGlobalPosition();
|
||
return { x: p.x + fromX + (this.w - fromX) / 2, y: p.y + this.rowHeight / 2 };
|
||
}
|
||
|
||
heightOfRows(count: number): number {
|
||
return count === 0 ? 0 : count * this.rowHeight + (count - 1) * this.rowGap;
|
||
}
|
||
|
||
override measure(): { w: number; h: number } {
|
||
return { w: this.minWidth, h: Math.max(this.minHeight, this.heightOfRows(this.items.length)) };
|
||
}
|
||
|
||
override layout(): void {
|
||
this.rows.forEach((row, index) => row.box.position.set(0, index * (this.rowHeight + this.rowGap)));
|
||
}
|
||
|
||
protected override indexAt(x: number, y: number): number {
|
||
return x >= 0 && x <= this.w ? Math.floor(y / (this.rowHeight + this.rowGap)) : -1;
|
||
}
|
||
}
|
||
|
||
/** What the Delete button says while it waits for the second press that really deletes. */
|
||
const ARMED_LABEL = 'Delete for good';
|
||
|
||
export interface PickerWords {
|
||
title: string;
|
||
loading: string;
|
||
/** Under the list while nothing is picked; a hint for what the rows can do. */
|
||
idle?: string;
|
||
empty: string;
|
||
use: string;
|
||
/** "image", "font", "sound" – for "3 fonts on the server". */
|
||
noun: string;
|
||
}
|
||
|
||
let current: FilePicker<PickerItem> | null = null;
|
||
|
||
/**
|
||
* Opens the dialog centred, closing whichever picker was open before it, and starts the listing.
|
||
* The listing starts here and not in the constructor: it asks the subclass, and a subclass's own
|
||
* fields are not assigned until its constructor has run past `super()`.
|
||
*/
|
||
export function openPicker<P extends FilePicker<PickerItem>>(picker: P): P {
|
||
current?.close();
|
||
current = picker;
|
||
picker.openCentered();
|
||
void picker.load();
|
||
return picker;
|
||
}
|
||
|
||
/** The open dialog if it is one of `kind`, for the test hooks. */
|
||
export function currentPicker<P extends FilePicker<PickerItem>>(
|
||
kind: abstract new (...args: never[]) => P,
|
||
): P | null {
|
||
return current instanceof kind ? current : null;
|
||
}
|
||
|
||
export abstract class FilePicker<Item extends PickerItem> extends Popup {
|
||
protected readonly scroll = new ScrollContainer();
|
||
private readonly status: Label;
|
||
private readonly detail: Label;
|
||
private readonly use: Button;
|
||
private readonly remove: Button;
|
||
private readonly buttons: HStack;
|
||
/** The file the Delete button was pressed for once; the second press deletes it. */
|
||
private armed: string | null = null;
|
||
|
||
constructor(
|
||
protected readonly list: PickerList<Item, PickerRow>,
|
||
private readonly words: PickerWords,
|
||
upload: () => void,
|
||
private readonly deleteFile: (file: string) => Promise<void>,
|
||
) {
|
||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||
this.content.add(new Label(words.title, { style: 'bodyBold' }));
|
||
this.status = new Label(words.loading, { style: 'muted' });
|
||
this.content.add(this.status);
|
||
|
||
list.minWidth = PICKER_WIDTH;
|
||
list.emptyText = words.empty;
|
||
list.onPick = () => this.refreshSelection();
|
||
list.onActivate = () => this.confirm();
|
||
this.scroll.minWidth = PICKER_WIDTH;
|
||
this.scroll.minHeight = PICKER_HEIGHT;
|
||
this.scroll.setContent(list);
|
||
this.content.add(this.scroll);
|
||
|
||
this.detail = new Label(words.idle ?? '', { style: 'muted' });
|
||
this.content.add(this.detail);
|
||
|
||
const uploadButton = new Button('Upload…');
|
||
uploadButton.onClick = this.closeThen(upload);
|
||
this.use = new Button(words.use, { variant: 'primary' });
|
||
this.use.onClick = () => this.confirm();
|
||
this.use.setEnabled(false);
|
||
this.remove = new Button('Delete', { variant: 'danger' });
|
||
this.remove.onClick = () => void this.pressDelete();
|
||
this.remove.setEnabled(false);
|
||
this.buttons = dialogButtons(this.closeThen(), this.use, [uploadButton, this.remove]);
|
||
this.content.add(this.buttons);
|
||
}
|
||
|
||
/**
|
||
* What the dialog offers, which of it the project already wears, what is picked, the line under
|
||
* the list and the Delete button with its centre (test hook).
|
||
*/
|
||
debugInfo(): {
|
||
files: string[];
|
||
used: string[];
|
||
captions: string[];
|
||
selected: string | null;
|
||
detail: string;
|
||
deleteButton: { enabled: boolean; label: string; shown: string; x: number; y: number };
|
||
} {
|
||
const at = this.remove.getGlobalPosition();
|
||
return {
|
||
files: this.list.fileList,
|
||
used: this.list.usedFiles,
|
||
captions: this.list.captions,
|
||
selected: this.list.selected?.file ?? null,
|
||
detail: this.detail.text,
|
||
deleteButton: {
|
||
enabled: this.remove.enabled,
|
||
label: this.remove.text,
|
||
shown: this.remove.shownText,
|
||
x: at.x + this.remove.w / 2,
|
||
y: at.y + this.remove.h / 2,
|
||
},
|
||
};
|
||
}
|
||
|
||
/** The scrolled part of the dialog – what tells an entry in view from one past its edge (test hook). */
|
||
get listView(): ScrollContainer {
|
||
return this.scroll;
|
||
}
|
||
|
||
override close(): void {
|
||
super.close();
|
||
if (current === this) current = null;
|
||
// Escape, Cancel and a confirmed pick all land here, and `Popup.close` tolerates a second
|
||
// call; the list's listeners must be dropped exactly once.
|
||
if (!this.list.destroyed) this.list.destroy();
|
||
}
|
||
|
||
async load(): Promise<void> {
|
||
try {
|
||
const items = await this.fetchItems();
|
||
this.list.setItems(items);
|
||
// Only as tall as the entries need, up to the cap: a handful of files should not leave half
|
||
// a dialog of empty box under them.
|
||
const { unit, total } = this.contentHeight(items.length);
|
||
this.scroll.minHeight = Math.min(this.roomFor(this.scroll, unit), Math.max(unit, total));
|
||
const { noun } = this.words;
|
||
this.status.setText(
|
||
items.length === 1 ? `1 ${noun} on the server` : `${items.length} ${noun}s on the server`,
|
||
);
|
||
} catch (error) {
|
||
this.status.setText(
|
||
`Could not list the ${this.words.noun}s: ${error instanceof Error ? error.message : String(error)}`,
|
||
);
|
||
}
|
||
this.refreshSelection();
|
||
}
|
||
|
||
private refreshSelection(): void {
|
||
const item = this.list.selected;
|
||
this.armed = null;
|
||
this.setDeleteLabel('Delete');
|
||
this.use.setEnabled(item !== null && this.canUse(item));
|
||
this.remove.setEnabled(item?.deletable ?? false);
|
||
this.detail.setText(item ? this.describe(item) : (this.words.idle ?? ''));
|
||
this.refreshLayout();
|
||
}
|
||
|
||
/** A button does not measure itself again on a new text; the row around it has to (see `Button.setText`). */
|
||
private setDeleteLabel(label: string): void {
|
||
this.remove.setText(label);
|
||
this.buttons.layout();
|
||
}
|
||
|
||
/**
|
||
* Deleting a file cannot be taken back, so the first press only asks; the second deletes. The
|
||
* server refuses a file a project of any account still uses, and its reason is shown as it is.
|
||
*/
|
||
private async pressDelete(): Promise<void> {
|
||
const item = this.list.selected;
|
||
if (!item?.deletable) return;
|
||
if (this.armed !== item.file) {
|
||
this.armed = item.file;
|
||
this.setDeleteLabel(ARMED_LABEL);
|
||
this.detail.setText(`Press again to delete ${item.name || `this ${this.words.noun}`} from the server.`);
|
||
this.refreshLayout();
|
||
return;
|
||
}
|
||
this.remove.setEnabled(false);
|
||
try {
|
||
await this.deleteFile(item.file);
|
||
} catch (error) {
|
||
this.refreshSelection();
|
||
this.detail.setText(`Not deleted: ${error instanceof Error ? error.message : String(error)}`);
|
||
this.refreshLayout();
|
||
return;
|
||
}
|
||
if (this.list.destroyed) return;
|
||
await this.load();
|
||
this.detail.setText(`Deleted ${item.name || `the ${this.words.noun}`}.`);
|
||
this.refreshLayout();
|
||
}
|
||
|
||
private confirm(): void {
|
||
const item = this.list.selected;
|
||
if (!item || !this.canUse(item)) return;
|
||
this.close();
|
||
this.adopt(item);
|
||
}
|
||
|
||
/** The server's list, paired with the project's names (`withAssetNames`). */
|
||
protected abstract fetchItems(): Promise<Item[]>;
|
||
/** How tall the list is for `count` entries, and the height of one – the least the scroll shows. */
|
||
protected abstract contentHeight(count: number): { unit: number; total: number };
|
||
protected abstract adopt(item: Item): void;
|
||
|
||
protected canUse(_item: Item): boolean {
|
||
return true;
|
||
}
|
||
|
||
protected describe(item: Item): string {
|
||
return describeStored(item);
|
||
}
|
||
}
|