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
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 };
|
|
}
|
|
|