fix(janitor): protect dm_channels.icon files from cleanup (owner + receiver)
getProfileReferencedFilenames() didn't include dm_channels.icon, so the storage janitor deleted group DM icons within ~1 hour: - Owner instance: PATCH /api/dm/:id leaves an attachments row with messageId=null and dmMessageId=null. After 1h, getUnlinkedAttachments flags it and cleanupStorage phase 2 deletes the file because the filename isn't in profileReferenced. - Receiver instance: downloadProfileAsset writes the icon directly to uploadDir with no attachments row. cleanupStorage phase 1 treats it as orphaned and deletes it. Fix: include dm_channels.icon (non-null, not soft-deleted, not http://) in the profile-referenced set. Soft-deleted DMs are excluded so their files still get reaped by cleanupSoftDeletedDmChannels. Absolute URLs are skipped because they live on a remote instance. Also mirror the avatar precedent at the PATCH endpoint by deleting the new icon's standalone attachment row — the file is now protected via dm_channels.icon, matching users.ts:473.
This commit is contained in:
@@ -1606,6 +1606,12 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
deleteUploadFile(oldIcon);
|
deleteUploadFile(oldIcon);
|
||||||
deleteAttachmentByFilename(oldIcon);
|
deleteAttachmentByFilename(oldIcon);
|
||||||
}
|
}
|
||||||
|
// Clean up the attachment record for the newly-set icon — the file is
|
||||||
|
// now referenced via dm_channels.icon (protected by the storage janitor),
|
||||||
|
// so the standalone attachment row is unnecessary. Mirrors users.ts:473.
|
||||||
|
if (iconChanged && nextIcon && !nextIcon.startsWith('http')) {
|
||||||
|
deleteAttachmentByFilename(nextIcon);
|
||||||
|
}
|
||||||
|
|
||||||
return reply.code(200).send({
|
return reply.code(200).send({
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import * as schema from '../db/schema.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
|
||||||
|
// Mutable references closed over by the vi.mock factories.
|
||||||
|
let testDb: TestDb;
|
||||||
|
let testRawDb: Database.Database;
|
||||||
|
let tmpUploadDir: string;
|
||||||
|
|
||||||
|
vi.mock('../db/index.js', () => ({
|
||||||
|
getDb: () => testDb,
|
||||||
|
getRawDb: () => testRawDb,
|
||||||
|
schema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../config.js', async () => {
|
||||||
|
const real = await import('../config.js');
|
||||||
|
return {
|
||||||
|
config: new Proxy(real.config, {
|
||||||
|
get(target, prop: string) {
|
||||||
|
if (prop === 'uploadDir') return tmpUploadDir;
|
||||||
|
return (target as Record<string, unknown>)[prop];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyMigrations(db: Database.Database): void {
|
||||||
|
const migrationsDir = path.resolve(__dirname, '../../drizzle');
|
||||||
|
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
||||||
|
for (const f of files) {
|
||||||
|
const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
|
||||||
|
const statements = sql.split(/-->\s*statement-breakpoint/);
|
||||||
|
for (const stmt of statements) {
|
||||||
|
const clean = stmt.trim();
|
||||||
|
if (clean) db.exec(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFile(name: string, body: string = 'data'): string {
|
||||||
|
const full = path.join(tmpUploadDir, name);
|
||||||
|
fs.writeFileSync(full, body);
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertDmChannel(opts: {
|
||||||
|
id: string;
|
||||||
|
icon?: string | null;
|
||||||
|
deletedAt?: number | null;
|
||||||
|
ownerId?: string | null;
|
||||||
|
}): void {
|
||||||
|
testDb.insert(schema.dmChannels).values({
|
||||||
|
id: opts.id,
|
||||||
|
ownerId: opts.ownerId ?? 'owner-1',
|
||||||
|
federatedId: null,
|
||||||
|
ownerHomeUserId: null,
|
||||||
|
ownerHomeInstance: null,
|
||||||
|
deletedAt: opts.deletedAt ?? null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
name: 'Test Group',
|
||||||
|
icon: opts.icon ?? null,
|
||||||
|
metadataUpdatedAt: 0,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAttachment(opts: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
size?: number;
|
||||||
|
createdAt?: number;
|
||||||
|
messageId?: string | null;
|
||||||
|
dmMessageId?: string | null;
|
||||||
|
thumbnailFilename?: string | null;
|
||||||
|
}): void {
|
||||||
|
testDb.insert(schema.attachments).values({
|
||||||
|
id: opts.id,
|
||||||
|
messageId: opts.messageId ?? null,
|
||||||
|
dmMessageId: opts.dmMessageId ?? null,
|
||||||
|
uploaderId: 'user-1',
|
||||||
|
filename: opts.filename,
|
||||||
|
originalName: opts.filename,
|
||||||
|
mimetype: 'image/png',
|
||||||
|
size: opts.size ?? 100,
|
||||||
|
thumbnailFilename: opts.thumbnailFilename ?? null,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
duration: null,
|
||||||
|
sourceUrl: null,
|
||||||
|
federationStatus: null,
|
||||||
|
// Default: 2h ago — comfortably past the 1h unlinked cutoff
|
||||||
|
createdAt: opts.createdAt ?? (Date.now() - 2 * 60 * 60 * 1000),
|
||||||
|
} as typeof schema.attachments.$inferInsert).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpUploadDir = path.join(os.tmpdir(), `backspace-janitor-${crypto.randomBytes(8).toString('hex')}`);
|
||||||
|
fs.mkdirSync(tmpUploadDir, { recursive: true });
|
||||||
|
|
||||||
|
testRawDb = new Database(':memory:');
|
||||||
|
testRawDb.pragma('foreign_keys = ON');
|
||||||
|
testDb = drizzle(testRawDb, { schema });
|
||||||
|
applyMigrations(testRawDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (fs.existsSync(tmpUploadDir)) {
|
||||||
|
fs.rmSync(tmpUploadDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
try { testRawDb.close(); } catch { /* noop */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanupStorage — dm_channels.icon protection', () => {
|
||||||
|
it('preserves a receiver-side dm_channels.icon file (no attachment row)', async () => {
|
||||||
|
// Receiver-instance scenario: a federated icon was downloaded directly to
|
||||||
|
// disk via downloadProfileAsset — no attachments row ever created. The
|
||||||
|
// file is referenced solely by dm_channels.icon. Pre-fix, the janitor's
|
||||||
|
// orphan sweep would treat this as orphaned and delete it.
|
||||||
|
const { cleanupStorage } = await import('./storageJanitor.js');
|
||||||
|
|
||||||
|
const filename = 'dm-icon-receiver.png';
|
||||||
|
writeFile(filename);
|
||||||
|
insertDmChannel({ id: 'dm-1', icon: filename });
|
||||||
|
|
||||||
|
const result = cleanupStorage(false);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(tmpUploadDir, filename))).toBe(true);
|
||||||
|
expect(result.deletedFiles).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an owner-set dm_channels.icon file (unlinked attachment row + db reference)', async () => {
|
||||||
|
// Owner-instance scenario: PATCH /api/dm/:id wrote dm_channels.icon and
|
||||||
|
// an attachments row exists with messageId=null AND dmMessageId=null.
|
||||||
|
// Past the 1h unlinked grace, the janitor flags it as unlinked. Pre-fix,
|
||||||
|
// the file was deleted because dm_channels.icon was not in the
|
||||||
|
// profile-referenced set.
|
||||||
|
const { cleanupStorage } = await import('./storageJanitor.js');
|
||||||
|
|
||||||
|
const filename = 'dm-icon-owner.png';
|
||||||
|
writeFile(filename);
|
||||||
|
insertDmChannel({ id: 'dm-2', icon: filename });
|
||||||
|
insertAttachment({ id: 'att-1', filename });
|
||||||
|
|
||||||
|
const result = cleanupStorage(false);
|
||||||
|
|
||||||
|
// File must remain — referenced by dm_channels.icon (the bug fix)
|
||||||
|
expect(fs.existsSync(path.join(tmpUploadDir, filename))).toBe(true);
|
||||||
|
// No bytes freed: file is profile-referenced; attachment row is also
|
||||||
|
// skipped by getUnlinkedAttachments (which filters out profile-referenced
|
||||||
|
// filenames) so it stays put — same as the avatar precedent. The bytes
|
||||||
|
// counter is the load-bearing assertion: pre-fix it would have been > 0
|
||||||
|
// because the file would have been deleted as orphaned.
|
||||||
|
expect(result.freedBytes).toBe(0);
|
||||||
|
expect(result.deletedFiles).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still deletes truly orphaned files (regression check)', async () => {
|
||||||
|
const { cleanupStorage } = await import('./storageJanitor.js');
|
||||||
|
|
||||||
|
const filename = 'truly-orphan.png';
|
||||||
|
writeFile(filename);
|
||||||
|
// No dm_channels.icon, no attachments row — pure orphan.
|
||||||
|
|
||||||
|
const result = cleanupStorage(false);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(tmpUploadDir, filename))).toBe(false);
|
||||||
|
expect(result.deletedFiles).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not protect files referenced by soft-deleted DM channels', async () => {
|
||||||
|
// Soft-deleted DMs have their own purge path (cleanupSoftDeletedDmChannels).
|
||||||
|
// The icon protection should only apply to live channels — otherwise we
|
||||||
|
// leak files for DMs that are pending hard-delete.
|
||||||
|
const { cleanupStorage } = await import('./storageJanitor.js');
|
||||||
|
|
||||||
|
const filename = 'soft-deleted-icon.png';
|
||||||
|
writeFile(filename);
|
||||||
|
insertDmChannel({ id: 'dm-3', icon: filename, deletedAt: Date.now() - 60_000 });
|
||||||
|
|
||||||
|
const result = cleanupStorage(false);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(tmpUploadDir, filename))).toBe(false);
|
||||||
|
expect(result.deletedFiles).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not try to protect remote-URL icons (federated absolute URLs)', async () => {
|
||||||
|
// Federated DM icons stored as absolute https:// URLs reference assets on
|
||||||
|
// the remote instance. There is no local file to protect, so the janitor
|
||||||
|
// must not add the URL string to its referenced-filenames set (which
|
||||||
|
// would be a no-op anyway, but confirms the http-skip branch).
|
||||||
|
const { cleanupStorage } = await import('./storageJanitor.js');
|
||||||
|
|
||||||
|
insertDmChannel({ id: 'dm-4', icon: 'https://other.example/api/uploads/foo.png' });
|
||||||
|
// Drop an unrelated orphan in the upload dir to confirm normal sweep still runs.
|
||||||
|
const orphan = 'unrelated-orphan.png';
|
||||||
|
writeFile(orphan);
|
||||||
|
|
||||||
|
const result = cleanupStorage(false);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(tmpUploadDir, orphan))).toBe(false);
|
||||||
|
expect(result.deletedFiles).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,6 +96,23 @@ function getProfileReferencedFilenames(): Set<string> {
|
|||||||
if (row.banner) referenced.add(path.basename(row.banner));
|
if (row.banner) referenced.add(path.basename(row.banner));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group DM icons. Skip soft-deleted DMs (the soft-deleted-DM purge in
|
||||||
|
// cleanupSoftDeletedDmChannels handles their attachments separately) and
|
||||||
|
// skip absolute URLs (those reference assets on a remote instance, not
|
||||||
|
// local upload-dir files).
|
||||||
|
const dmIconRows = db.select({ icon: schema.dmChannels.icon })
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(and(
|
||||||
|
isNotNull(schema.dmChannels.icon),
|
||||||
|
isNull(schema.dmChannels.deletedAt),
|
||||||
|
))
|
||||||
|
.all();
|
||||||
|
for (const row of dmIconRows) {
|
||||||
|
if (row.icon && !row.icon.startsWith('http')) {
|
||||||
|
referenced.add(path.basename(row.icon));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return referenced;
|
return referenced;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user