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>
301 lines
13 KiB
TypeScript
301 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
||
import type { TextureAsset } from '../../src/core/model/Asset';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { findPropertyUsages, findUsages } from '../../src/core/model/usages';
|
||
import {
|
||
DEFAULT_SLICE,
|
||
isTextureFile,
|
||
nextFrameNames,
|
||
resolveTexture,
|
||
sliceFrames,
|
||
textureRefIds,
|
||
uniqueFrameName,
|
||
} from '../../src/core/model/texture';
|
||
import { pictureChoices, textureRefLabel } from '../../src/core/properties/textureRef';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { makeSpriteScene, place, roundTrip } from '../helpers';
|
||
|
||
/** A syntactically valid stored file name (64 hex characters plus an extension). */
|
||
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
|
||
const OTHER_FILE = `${'0f'.repeat(32)}.webp`;
|
||
|
||
function addTexture(fixture: ReturnType<typeof makeSpriteScene>, file = FILE, name = 'Bricks') {
|
||
const command = new CreateTextureCommand(name, { file, width: 64, height: 48 });
|
||
fixture.stack.execute(command);
|
||
return command.asset;
|
||
}
|
||
|
||
describe('texture file names', () => {
|
||
it('accepts the content-addressed form the server produces', () => {
|
||
expect(isTextureFile(FILE)).toBe(true);
|
||
expect(isTextureFile(OTHER_FILE)).toBe(true);
|
||
expect(isTextureFile(`${'ab'.repeat(32)}.jpg`)).toBe(true);
|
||
});
|
||
|
||
it('takes the folder of the account that uploaded it (#344)', () => {
|
||
expect(isTextureFile(`0123456789ab/${FILE}`)).toBe(true);
|
||
expect(isTextureFile(`0123456789a/${FILE}`)).toBe(false);
|
||
expect(isTextureFile(`0123456789AB/${FILE}`)).toBe(false);
|
||
expect(isTextureFile(`0123456789ab/0123456789ab/${FILE}`)).toBe(false);
|
||
expect(isTextureFile(`0123456789ab/../${FILE}`)).toBe(false);
|
||
expect(isTextureFile('0123456789ab/')).toBe(false);
|
||
});
|
||
|
||
it('rejects anything a request must not be built from', () => {
|
||
expect(isTextureFile('')).toBe(false);
|
||
expect(isTextureFile('bricks.png')).toBe(false);
|
||
// Too short, wrong case, an unsupported extension, and the two traversal shapes.
|
||
expect(isTextureFile('abc.png')).toBe(false);
|
||
expect(isTextureFile(`${'A1'.repeat(32)}.png`)).toBe(false);
|
||
expect(isTextureFile(`${'ab'.repeat(32)}.svg`)).toBe(false);
|
||
expect(isTextureFile(`../${'ab'.repeat(32)}.png`)).toBe(false);
|
||
expect(isTextureFile(`${'ab'.repeat(32)}.png/../x`)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('resolveTexture', () => {
|
||
it('resolves a sprite property to the file and the filter', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||
sprite.props.texture = texture.id;
|
||
|
||
// A whole-texture reference carries no rectangle.
|
||
const whole = { file: FILE, rect: null, sourceWidth: 64, sourceHeight: 48, slice: null };
|
||
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: true });
|
||
texture.props.filter = 'nearest';
|
||
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: false });
|
||
});
|
||
|
||
it('resolves a frame id to the rectangle it cuts, not to the whole picture', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
texture.frames.push({ id: 'f1', name: 'Idle', x: 16, y: 8, w: 32, h: 24 });
|
||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||
|
||
sprite.props.texture = 'f1';
|
||
expect(resolveTexture(fixture.project, sprite.props)).toEqual({
|
||
file: FILE,
|
||
smooth: true,
|
||
rect: { x: 16, y: 8, w: 32, h: 24 },
|
||
sourceWidth: 64,
|
||
sourceHeight: 48,
|
||
// An unsliced frame carries no slice: what every document written before #41 says.
|
||
slice: null,
|
||
});
|
||
|
||
// Sliced, it carries the borders and the fill the renderer needs, measured in the frame.
|
||
texture.frames[0]!.border = { left: 4, top: 3, right: 4, bottom: 3 };
|
||
texture.frames[0]!.fill = 'repeat';
|
||
expect(resolveTexture(fixture.project, sprite.props)?.slice).toEqual({
|
||
border: { left: 4, top: 3, right: 4, bottom: 3 },
|
||
fill: 'repeat',
|
||
sourceW: 32,
|
||
sourceH: 24,
|
||
scale: 1,
|
||
});
|
||
// The scale is the texture's, not the frame's (#96); a value that is no size falls back to 1.
|
||
texture.props.sliceScale = 2.5;
|
||
expect(resolveTexture(fixture.project, sprite.props)?.slice?.scale).toBe(2.5);
|
||
texture.props.sliceScale = 0;
|
||
expect(resolveTexture(fixture.project, sprite.props)?.slice?.scale).toBe(1);
|
||
delete texture.props.sliceScale;
|
||
delete texture.frames[0]!.border;
|
||
delete texture.frames[0]!.fill;
|
||
|
||
// A frame that is gone dangles like any other reference: no picture, not a wrong one.
|
||
texture.frames.length = 0;
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
});
|
||
|
||
it('yields nothing rather than throwing when the reference cannot be used', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
sprite.props.texture = 'does-not-exist';
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
// Pointing at an asset that is not a texture at all.
|
||
sprite.props.texture = fixture.sceneId;
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
// A texture whose upload never produced a file.
|
||
sprite.props.texture = texture.id;
|
||
texture.file = '';
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
});
|
||
|
||
it('survives the round trip, and a hand-written file name does not', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
findAsset(fixture.project, fixture.spriteId)!.props.texture = texture.id;
|
||
|
||
const loaded = roundTrip(fixture.project);
|
||
const reloaded = findAsset(loaded, texture.id) as TextureAsset;
|
||
expect(reloaded.file).toBe(FILE);
|
||
expect(reloaded.width).toBe(64);
|
||
expect(resolveTexture(loaded, findAsset(loaded, fixture.spriteId)!.props)?.file).toBe(FILE);
|
||
|
||
const tampered = deserializeProject({
|
||
assets: [{ id: 't', type: 'texture', name: 'X', file: '/etc/passwd', width: -3, height: 'big' }],
|
||
});
|
||
const dropped = findAsset(tampered, 't') as TextureAsset;
|
||
expect(dropped.file).toBe('');
|
||
expect(dropped.width).toBe(0);
|
||
expect(dropped.height).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('CreateTextureCommand', () => {
|
||
it('adds and removes the asset', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
expect(findAsset(fixture.project, texture.id)).toBeDefined();
|
||
|
||
fixture.stack.undo();
|
||
expect(findAsset(fixture.project, texture.id)).toBeUndefined();
|
||
fixture.stack.redo();
|
||
expect((findAsset(fixture.project, texture.id) as TextureAsset).file).toBe(FILE);
|
||
});
|
||
|
||
it('refuses a file name that was never uploaded', () => {
|
||
const fixture = makeSpriteScene();
|
||
expect(() =>
|
||
fixture.stack.execute(new CreateTextureCommand('Bad', { file: 'bricks.png', width: 1, height: 1 })),
|
||
).toThrow();
|
||
});
|
||
});
|
||
|
||
describe('findPropertyUsages', () => {
|
||
it('finds a texture that is worn but never placed', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||
sprite.props.texture = texture.id;
|
||
const objectId = place(fixture, fixture.spriteId, { x: 0, y: 0 });
|
||
|
||
// A texture is never placed, so `findUsages` is blind to it – hence this function.
|
||
expect(findUsages(fixture.project, texture.id)).toHaveLength(0);
|
||
|
||
const usages = findPropertyUsages(fixture.project, texture.id);
|
||
expect(usages).toHaveLength(1);
|
||
expect(usages[0]).toMatchObject({ on: 'asset', key: 'texture' });
|
||
|
||
fixture.object(objectId).overrides.texture = texture.id;
|
||
expect(findPropertyUsages(fixture.project, texture.id)).toHaveLength(2);
|
||
expect(findPropertyUsages(fixture.project, texture.id).some((u) => u.on === 'object')).toBe(true);
|
||
});
|
||
|
||
it('counts sprites wearing a frame of the texture, not just the texture itself', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
texture.frames.push({ id: 'f1', name: 'Idle', x: 0, y: 0, w: 8, h: 8 });
|
||
findAsset(fixture.project, fixture.spriteId)!.props.texture = 'f1';
|
||
|
||
// The asset id alone under-reports sliced sheets, so deleting one asks about every id it owns.
|
||
expect(findPropertyUsages(fixture.project, texture.id)).toHaveLength(0);
|
||
expect(findPropertyUsages(fixture.project, textureRefIds(texture))).toHaveLength(1);
|
||
});
|
||
|
||
it('ignores unrelated ids and an empty reference', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
findAsset(fixture.project, fixture.spriteId)!.props.texture = texture.id;
|
||
|
||
expect(findPropertyUsages(fixture.project, 'other')).toHaveLength(0);
|
||
// An empty reference must not report every sprite that has no texture.
|
||
expect(findPropertyUsages(fixture.project, '')).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('slicing a sheet', () => {
|
||
it('cuts a grid in reading order and derives the cell size from the picture', () => {
|
||
const rects = sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: 2, rows: 2 });
|
||
expect(rects).toEqual([
|
||
{ x: 0, y: 0, w: 32, h: 32 },
|
||
{ x: 32, y: 0, w: 32, h: 32 },
|
||
{ x: 0, y: 32, w: 32, h: 32 },
|
||
{ x: 32, y: 32, w: 32, h: 32 },
|
||
]);
|
||
});
|
||
|
||
it('accounts for an offset and for spacing between cells', () => {
|
||
// 2 columns with a 2 px border and 4 px between them: (64 − 2 − 4) / 2 = 29 px cells.
|
||
const rects = sliceFrames(64, 64, {
|
||
...DEFAULT_SLICE,
|
||
columns: 2,
|
||
rows: 1,
|
||
offsetX: 2,
|
||
spacingX: 4,
|
||
});
|
||
expect(rects).toEqual([
|
||
{ x: 2, y: 0, w: 29, h: 64 },
|
||
{ x: 35, y: 0, w: 29, h: 64 },
|
||
]);
|
||
});
|
||
|
||
it('refuses rather than guessing when the numbers cannot describe a grid', () => {
|
||
// An unmeasured picture, more cells than pixels, and the values a form can produce.
|
||
expect(sliceFrames(0, 0, DEFAULT_SLICE)).toEqual([]);
|
||
expect(sliceFrames(4, 4, { ...DEFAULT_SLICE, columns: 8, rows: 8 })).toEqual([]);
|
||
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: 0 })).toEqual([]);
|
||
// NaN passes every `<` comparison, so it has to be rejected in its own right.
|
||
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: NaN })).toEqual([]);
|
||
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, spacingX: NaN })).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('frame names', () => {
|
||
const sheet = (...names: string[]): TextureAsset => ({
|
||
id: 't',
|
||
type: 'texture',
|
||
name: 'Sheet',
|
||
props: {},
|
||
file: '',
|
||
width: 64,
|
||
height: 64,
|
||
frames: names.map((name, i) => ({ id: `f${i}`, name, x: 0, y: 0, w: 1, h: 1 })),
|
||
});
|
||
|
||
it('numbers a clashing name from 2, the way asset names are numbered', () => {
|
||
expect(uniqueFrameName(sheet(), 'Idle')).toBe('Idle');
|
||
expect(uniqueFrameName(sheet('Idle'), 'Idle')).toBe('Idle 2');
|
||
expect(uniqueFrameName(sheet('Idle', 'Idle 2'), 'Idle')).toBe('Idle 3');
|
||
// A frame keeps its own name available, so renaming it to itself is not a clash.
|
||
expect(uniqueFrameName(sheet('Idle'), 'Idle', 'f0')).toBe('Idle');
|
||
});
|
||
|
||
it('numbers a whole slice from 1 and steps over names already taken', () => {
|
||
expect(nextFrameNames(sheet(), 3)).toEqual(['Frame 1', 'Frame 2', 'Frame 3']);
|
||
// Re-slicing a sheet that keeps its frames must not append a number to a number.
|
||
expect(nextFrameNames(sheet('Frame 2'), 3)).toEqual(['Frame 1', 'Frame 3', 'Frame 4']);
|
||
});
|
||
});
|
||
|
||
describe('texture field references', () => {
|
||
it('offers pictures and their frames, never an animation', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
texture.frames.push({ id: 'f1', name: 'Idle', x: 0, y: 0, w: 32, h: 24 });
|
||
const animation = new CreateAssetCommand('animation', 'Walk');
|
||
fixture.stack.execute(animation);
|
||
|
||
expect(pictureChoices(fixture.project).map((c) => [c.id, c.label])).toEqual([
|
||
[texture.id, 'Bricks'],
|
||
['f1', 'Bricks / Idle'],
|
||
]);
|
||
});
|
||
|
||
it('shows an animation id left from an older project as a dangling reference', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
const animation = new CreateAssetCommand('animation', 'Walk');
|
||
fixture.stack.execute(animation);
|
||
|
||
expect(textureRefLabel(fixture.project, texture.id)).toBe('Bricks');
|
||
expect(textureRefLabel(fixture.project, animation.asset.id)).toBe(`${animation.asset.id}?`);
|
||
expect(textureRefLabel(fixture.project, '')).toBe('None');
|
||
});
|
||
});
|