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.
206 lines
5.0 KiB
206 lines
5.0 KiB
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { dbGlobal } from "drizzle-pkg/lib/db";
|
|
import { mediaAssets, mediaRefs } from "drizzle-pkg/lib/schema/content";
|
|
import { 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 StorageAuditRefDetail = {
|
|
ownerType: string;
|
|
ownerId: number;
|
|
};
|
|
|
|
export type StorageAuditMissingRow = {
|
|
id: number;
|
|
userId: number;
|
|
storageKey: string;
|
|
refCount: number;
|
|
refs: StorageAuditRefDetail[];
|
|
};
|
|
|
|
export type StorageAuditResult = {
|
|
scannedAt: string;
|
|
dbRowCount: number;
|
|
diskFileCount: number;
|
|
missingOnDisk: StorageAuditMissingRow[];
|
|
invalidStorageKey: StorageAuditMissingRow[];
|
|
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 refRows = await dbGlobal
|
|
.select({
|
|
assetId: mediaRefs.assetId,
|
|
ownerType: mediaRefs.ownerType,
|
|
ownerId: mediaRefs.ownerId,
|
|
})
|
|
.from(mediaRefs);
|
|
|
|
const refsByAsset = new Map<number, StorageAuditRefDetail[]>();
|
|
for (const row of refRows) {
|
|
const list = refsByAsset.get(row.assetId) ?? [];
|
|
list.push({ ownerType: row.ownerType, ownerId: row.ownerId });
|
|
refsByAsset.set(row.assetId, list);
|
|
}
|
|
for (const list of refsByAsset.values()) {
|
|
list.sort(
|
|
(a, b) => a.ownerType.localeCompare(b.ownerType) || a.ownerId - b.ownerId,
|
|
);
|
|
}
|
|
|
|
function refsForAsset(assetId: number): StorageAuditRefDetail[] {
|
|
return refsByAsset.get(assetId) ?? [];
|
|
}
|
|
|
|
function refCountForAsset(assetId: number): number {
|
|
return refsForAsset(assetId).length;
|
|
}
|
|
|
|
const dbKeys = new Set<string>();
|
|
const missingOnDisk: StorageAuditMissingRow[] = [];
|
|
const invalidStorageKey: StorageAuditMissingRow[] = [];
|
|
|
|
for (const r of rows) {
|
|
const refCount = refCountForAsset(r.id);
|
|
const refs = refsForAsset(r.id);
|
|
if (!isSafeStorageKey(r.storageKey)) {
|
|
invalidStorageKey.push({
|
|
id: r.id,
|
|
userId: r.userId,
|
|
storageKey: r.storageKey,
|
|
refCount,
|
|
refs,
|
|
});
|
|
continue;
|
|
}
|
|
dbKeys.add(r.storageKey);
|
|
const full = resolvedFilePath(r.storageKey);
|
|
if (!full) {
|
|
invalidStorageKey.push({
|
|
id: r.id,
|
|
userId: r.userId,
|
|
storageKey: r.storageKey,
|
|
refCount,
|
|
refs,
|
|
});
|
|
continue;
|
|
}
|
|
if (!fs.existsSync(full)) {
|
|
missingOnDisk.push({
|
|
id: r.id,
|
|
userId: r.userId,
|
|
storageKey: r.storageKey,
|
|
refCount,
|
|
refs,
|
|
});
|
|
}
|
|
}
|
|
|
|
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 stillRefs = await dbGlobal
|
|
.select({ one: mediaRefs.assetId })
|
|
.from(mediaRefs)
|
|
.where(eq(mediaRefs.assetId, c.id))
|
|
.limit(1);
|
|
if (stillRefs.length > 0) {
|
|
continue;
|
|
}
|
|
await dbGlobal.delete(mediaAssets).where(eq(mediaAssets.id, c.id));
|
|
removedIds.push(c.id);
|
|
}
|
|
|
|
return { removed: removedIds.length, removedIds };
|
|
}
|
|
|