You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

180 lines
4.3 KiB

import fs from "node:fs";
import path from "node:path";
import { dbGlobal } from "drizzle-pkg/lib/db";
import { mediaAssets, postMediaRefs } from "drizzle-pkg/lib/schema/content";
import { count, eq } from "drizzle-orm";
import { RELATIVE_ASSETS_DIR } from "#server/constants/media";
function assetsBaseDir(): string {
return path.resolve(process.cwd(), RELATIVE_ASSETS_DIR);
}
function isSafeStorageKey(key: string): boolean {
if (!key || key !== path.basename(key)) {
return false;
}
return !key.includes("..");
}
function resolvedFilePath(storageKey: string): string | null {
if (!isSafeStorageKey(storageKey)) {
return null;
}
const base = assetsBaseDir();
const full = path.resolve(base, storageKey);
const prefix = base + path.sep;
if (full !== base && !full.startsWith(prefix)) {
return null;
}
return full;
}
export type StorageAuditMissingRow = {
id: number;
userId: number;
storageKey: string;
refCount: number;
};
export type StorageAuditResult = {
scannedAt: string;
dbRowCount: number;
diskFileCount: number;
missingOnDisk: StorageAuditMissingRow[];
invalidStorageKey: Array<{ id: number; userId: number; storageKey: string; refCount: number }>;
onDiskNotInDb: string[];
};
export async function auditMediaStorageVsDb(): Promise<StorageAuditResult> {
const base = assetsBaseDir();
const rows = await dbGlobal
.select({
id: mediaAssets.id,
userId: mediaAssets.userId,
storageKey: mediaAssets.storageKey,
})
.from(mediaAssets);
const refAgg = await dbGlobal
.select({
assetId: postMediaRefs.assetId,
c: count(),
})
.from(postMediaRefs)
.groupBy(postMediaRefs.assetId);
const refMap = new Map<number, number>();
for (const r of refAgg) {
refMap.set(r.assetId, r.c);
}
const dbKeys = new Set<string>();
const missingOnDisk: StorageAuditMissingRow[] = [];
const invalidStorageKey: StorageAuditMissingRow[] = [];
for (const r of rows) {
const refCount = refMap.get(r.id) ?? 0;
if (!isSafeStorageKey(r.storageKey)) {
invalidStorageKey.push({
id: r.id,
userId: r.userId,
storageKey: r.storageKey,
refCount,
});
continue;
}
dbKeys.add(r.storageKey);
const full = resolvedFilePath(r.storageKey);
if (!full) {
invalidStorageKey.push({
id: r.id,
userId: r.userId,
storageKey: r.storageKey,
refCount,
});
continue;
}
if (!fs.existsSync(full)) {
missingOnDisk.push({
id: r.id,
userId: r.userId,
storageKey: r.storageKey,
refCount,
});
}
}
let diskFileCount = 0;
const onDiskNotInDb: string[] = [];
if (!fs.existsSync(base)) {
return {
scannedAt: new Date().toISOString(),
dbRowCount: rows.length,
diskFileCount: 0,
missingOnDisk,
invalidStorageKey,
onDiskNotInDb: [],
};
}
const dirents = await fs.promises.readdir(base, { withFileTypes: true });
for (const e of dirents) {
if (!e.isFile()) {
continue;
}
if (e.name.startsWith(".")) {
continue;
}
diskFileCount += 1;
if (!dbKeys.has(e.name)) {
onDiskNotInDb.push(e.name);
}
}
onDiskNotInDb.sort();
return {
scannedAt: new Date().toISOString(),
dbRowCount: rows.length,
diskFileCount,
missingOnDisk,
invalidStorageKey,
onDiskNotInDb,
};
}
/**
* 删除「磁盘上不存在」且「无任何文章引用」的 media_assets 行(不尝试 unlink)。
*/
export async function removeUnreferencedDbRowsForMissingFiles(): Promise<{
removed: number;
removedIds: number[];
}> {
const audit = await auditMediaStorageVsDb();
const candidates = audit.missingOnDisk.filter((m) => m.refCount === 0);
if (candidates.length === 0) {
return { removed: 0, removedIds: [] };
}
const removedIds: number[] = [];
for (const c of candidates) {
const full = resolvedFilePath(c.storageKey);
if (!full) {
continue;
}
if (fs.existsSync(full)) {
continue;
}
const [{ n }] = await dbGlobal
.select({ n: count() })
.from(postMediaRefs)
.where(eq(postMediaRefs.assetId, c.id));
if (n > 0) {
continue;
}
await dbGlobal.delete(mediaAssets).where(eq(mediaAssets.id, c.id));
removedIds.push(c.id);
}
return { removed: removedIds.length, removedIds };
}