feat: manage uploaded image retention
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { AssetStore } from '../src/asset-store.mjs';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'image-assets-'));
|
||||
let now = Date.UTC(2028, 0, 1);
|
||||
const config = {
|
||||
uploadRoot: join(root, 'uploads'),
|
||||
assetDbPath: join(root, 'metadata', 'assets.sqlite3'),
|
||||
contentRetentionMs: 730 * DAY,
|
||||
contentQuarantineMs: 30 * DAY,
|
||||
assetMaintenanceIntervalMs: DAY,
|
||||
assetTouchFlushIntervalMs: 60_000,
|
||||
};
|
||||
const store = new AssetStore(config, { now: () => now });
|
||||
await store.initialize();
|
||||
t.after(async () => {
|
||||
store.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
return { root, config, store, setNow(value) { now = value; }, getNow() { return now; } };
|
||||
}
|
||||
|
||||
async function addFile(f, category, client, filename, body, createdAt) {
|
||||
const directory = join(f.config.uploadRoot, category, client);
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(join(directory, filename), body);
|
||||
f.store.register({ category, client, filename, body, createdAt });
|
||||
}
|
||||
|
||||
test('user icons remain permanent while old content becomes a deletion candidate', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const old = f.getNow() - 731 * DAY;
|
||||
const icon = `${'a'.repeat(32)}.png`;
|
||||
const content = `${'b'.repeat(32)}.webp`;
|
||||
await addFile(f, 'user-icons', 'core2026', icon, Buffer.from('icon'), old);
|
||||
await addFile(f, 'content', 'core2026', content, Buffer.from('content'), old);
|
||||
|
||||
assert.equal(f.store.markCandidates(), 1);
|
||||
assert.equal(f.store.get(`icons/users/core2026/${icon}`).state, 'active');
|
||||
assert.equal(f.store.get(`uploads/core2026/${content}`).state, 'candidate');
|
||||
assert.equal((await f.store.preview(`uploads/core2026/${content}`)).body.toString(), 'content');
|
||||
assert.equal(f.store.get(`uploads/core2026/${content}`).state, 'candidate');
|
||||
assert.deepEqual(f.store.summary().groups['user-icons:active'], { count: 1, bytes: 4 });
|
||||
});
|
||||
|
||||
test('content access is batched, renews retention, and cancels candidate state', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const path = `uploads/core/${'c'.repeat(32)}.png`;
|
||||
await addFile(f, 'content', 'core', `${'c'.repeat(32)}.png`, Buffer.from('content'), f.getNow() - 731 * DAY);
|
||||
f.store.markCandidates();
|
||||
assert.equal(f.store.get(path).state, 'candidate');
|
||||
|
||||
assert.equal(f.store.touch(path), true);
|
||||
assert.equal(f.store.touch(path), true);
|
||||
assert.equal(f.store.flushTouches(), 1);
|
||||
assert.equal(f.store.get(path).state, 'active');
|
||||
assert.equal(f.store.get(path).lastSeenAt, new Date(f.getNow()).toISOString());
|
||||
assert.equal(f.store.touch('icons/users/core/not-content.png'), false);
|
||||
});
|
||||
|
||||
test('candidate quarantine is recoverable and permanent deletion requires the grace period', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const filename = `${'d'.repeat(32)}.gif`;
|
||||
const path = `uploads/core2026/${filename}`;
|
||||
const body = Buffer.from('content');
|
||||
await addFile(f, 'content', 'core2026', filename, body, f.getNow() - 731 * DAY);
|
||||
f.store.markCandidates();
|
||||
|
||||
await f.store.quarantine(path);
|
||||
await assert.rejects(f.store.delete(path), /grace period/);
|
||||
await f.store.restore(path);
|
||||
assert.equal(await readFile(join(f.config.uploadRoot, 'content', 'core2026', filename), 'utf8'), 'content');
|
||||
assert.equal(f.store.get(path).state, 'active');
|
||||
|
||||
f.setNow(f.getNow() + 731 * DAY);
|
||||
f.store.markCandidates();
|
||||
await f.store.quarantine(path);
|
||||
f.setNow(f.getNow() + 31 * DAY);
|
||||
const deleted = await f.store.delete(path);
|
||||
assert.equal(deleted.state, 'deleted');
|
||||
assert.equal(f.store.summary().totalCount, 0);
|
||||
});
|
||||
@@ -176,3 +176,96 @@ test('sync endpoint rejects unknown callers and body fields outside the sync con
|
||||
});
|
||||
assert.equal(extraField.status, 400);
|
||||
});
|
||||
|
||||
test('internal content access endpoint batches only valid uploaded content paths', async (t) => {
|
||||
const service = { async initialize() {}, async recordError() {} };
|
||||
const touched = [];
|
||||
const assetStore = {
|
||||
async initialize() {},
|
||||
touch(path) { touched.push(path); return path.startsWith('uploads/'); },
|
||||
close() {},
|
||||
};
|
||||
const { server } = await createApp({ maxBodyBytes: 4096 }, {
|
||||
service, uploadStore: noUploadStore, assetStore,
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
t.after(() => server.close());
|
||||
const address = server.address();
|
||||
const validPath = `/uploads/core2026/${'e'.repeat(32)}.png?cache=1`;
|
||||
const valid = await fetch(`http://127.0.0.1:${address.port}/v1/internal/content-access`, {
|
||||
headers: { 'x-image-path': validPath },
|
||||
});
|
||||
assert.equal(valid.status, 204);
|
||||
assert.deepEqual(touched, [`uploads/core2026/${'e'.repeat(32)}.png`]);
|
||||
|
||||
const legacyBase = await fetch(`http://127.0.0.1:${address.port}/v1/internal/content-access`, {
|
||||
method: 'HEAD',
|
||||
headers: { 'x-image-path': `/image/uploads/core/${'f'.repeat(32)}.webp` },
|
||||
});
|
||||
assert.equal(legacyBase.status, 204);
|
||||
assert.deepEqual(touched, [
|
||||
`uploads/core2026/${'e'.repeat(32)}.png`,
|
||||
`uploads/core/${'f'.repeat(32)}.webp`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('admin panel uses a separate login session and CSRF-protected asset actions', async (t) => {
|
||||
const service = { async initialize() {}, async recordError() {} };
|
||||
const actions = [];
|
||||
const asset = {
|
||||
path: `uploads/core/${'f'.repeat(32)}.webp`, category: 'content', client: 'core',
|
||||
filename: `${'f'.repeat(32)}.webp`, sizeBytes: 10, digest: null,
|
||||
createdAt: new Date(0).toISOString(), lastSeenAt: new Date(0).toISOString(),
|
||||
state: 'candidate', candidateAt: new Date(0).toISOString(), quarantinedAt: null,
|
||||
deletedAt: null, eligibleAt: new Date(0).toISOString(), deleteAvailableAt: null, deleteAvailable: false,
|
||||
};
|
||||
const assetStore = {
|
||||
async initialize() {}, touch() { return false; }, close() {},
|
||||
list() { return { total: 1, limit: 100, offset: 0, assets: [asset] }; },
|
||||
summary() { return { totalCount: 1, totalBytes: 10, groups: { 'content:candidate': { count: 1, bytes: 10 } } }; },
|
||||
async quarantine(path) { actions.push(['quarantine', path]); return { ...asset, state: 'quarantined' }; },
|
||||
};
|
||||
const config = {
|
||||
maxBodyBytes: 4096,
|
||||
adminPanelPassword: 'panel-password-value',
|
||||
adminPanelSessionSecret: 'p'.repeat(32),
|
||||
adminPanelSessionTtlMs: 8 * 3_600_000,
|
||||
};
|
||||
const { server } = await createApp(config, { service, uploadStore: noUploadStore, assetStore });
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
t.after(() => server.close());
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
const anonymousApi = await fetch(`${base}/admin/api/assets`);
|
||||
assert.equal(anonymousApi.status, 401);
|
||||
const loginBody = new URLSearchParams({ password: config.adminPanelPassword });
|
||||
const login = await fetch(`${base}/admin/login`, {
|
||||
method: 'POST', redirect: 'manual',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: loginBody,
|
||||
});
|
||||
assert.equal(login.status, 303);
|
||||
const cookie = login.headers.get('set-cookie').split(';', 1)[0];
|
||||
assert.match(login.headers.get('set-cookie'), /HttpOnly; Secure; SameSite=Strict/);
|
||||
const dashboard = await fetch(`${base}/admin/`, { headers: { cookie } });
|
||||
assert.equal(dashboard.status, 200);
|
||||
assert.match(dashboard.headers.get('content-security-policy'), /frame-ancestors 'none'/);
|
||||
const html = await dashboard.text();
|
||||
const csrf = html.match(/name="csrf-token" content="([^"]+)"/)[1];
|
||||
|
||||
const list = await fetch(`${base}/admin/api/assets`, { headers: { cookie } });
|
||||
assert.equal(list.status, 200);
|
||||
assert.equal((await list.json()).assets[0].state, 'candidate');
|
||||
const rejected = await fetch(`${base}/admin/api/assets/action`, {
|
||||
method: 'POST', headers: { cookie, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ path: asset.path, action: 'quarantine' }),
|
||||
});
|
||||
assert.equal(rejected.status, 403);
|
||||
const accepted = await fetch(`${base}/admin/api/assets/action`, {
|
||||
method: 'POST', headers: { cookie, 'content-type': 'application/json', 'x-csrf-token': csrf },
|
||||
body: JSON.stringify({ path: asset.path, action: 'quarantine' }),
|
||||
});
|
||||
assert.equal(accepted.status, 200);
|
||||
assert.deepEqual(actions, [['quarantine', asset.path]]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user