1 changed files with 328 additions and 0 deletions
@ -0,0 +1,328 @@ |
|||||
|
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 { and, count, desc, eq, inArray, isNotNull, isNull, lte, not, notExists, or, sql } from "drizzle-orm"; |
||||
|
import { |
||||
|
MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF, |
||||
|
MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF, |
||||
|
POST_MEDIA_PUBLIC_PREFIX, |
||||
|
RELATIVE_ASSETS_DIR, |
||||
|
} from "#server/constants/media"; |
||||
|
import { mergePostMediaUrls, publicAssetUrlToStorageKey } from "#server/utils/post-media-urls"; |
||||
|
import { nextIntegerId } from "#server/utils/sqlite-id"; |
||||
|
|
||||
|
const NEVER_REF_MS = MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF * 3600 * 1000; |
||||
|
const AFTER_DEREF_MS = MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF * 3600 * 1000; |
||||
|
|
||||
|
function assetsBaseDir(): string { |
||||
|
return path.resolve(process.cwd(), RELATIVE_ASSETS_DIR); |
||||
|
} |
||||
|
|
||||
|
function isPathUnderAssetsDir(resolvedFile: string): boolean { |
||||
|
const base = assetsBaseDir() + path.sep; |
||||
|
return resolvedFile === assetsBaseDir() || resolvedFile.startsWith(base); |
||||
|
} |
||||
|
|
||||
|
/** 删除主文件及 variantsJson 中列出的、位于 assets 目录下的相对路径 */ |
||||
|
async function unlinkMediaFiles(storageKey: string, variantsJson: string | null): Promise<void> { |
||||
|
const main = path.resolve(assetsBaseDir(), storageKey); |
||||
|
if (!isPathUnderAssetsDir(main)) { |
||||
|
return; |
||||
|
} |
||||
|
try { |
||||
|
await fs.promises.unlink(main); |
||||
|
} catch (e) { |
||||
|
if ((e as NodeJS.ErrnoException).code !== "ENOENT") { |
||||
|
throw e; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const rels = parseVariantRelativePaths(variantsJson); |
||||
|
for (const rel of rels) { |
||||
|
if (rel.includes("..") || path.isAbsolute(rel)) { |
||||
|
continue; |
||||
|
} |
||||
|
const full = path.resolve(assetsBaseDir(), rel); |
||||
|
if (!isPathUnderAssetsDir(full)) { |
||||
|
continue; |
||||
|
} |
||||
|
try { |
||||
|
await fs.promises.unlink(full); |
||||
|
} catch (e) { |
||||
|
if ((e as NodeJS.ErrnoException).code !== "ENOENT") { |
||||
|
throw e; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function parseVariantRelativePaths(raw: string | null): string[] { |
||||
|
if (!raw) { |
||||
|
return []; |
||||
|
} |
||||
|
try { |
||||
|
const j = JSON.parse(raw) as unknown; |
||||
|
if (!Array.isArray(j)) { |
||||
|
return []; |
||||
|
} |
||||
|
return j.filter((x): x is string => typeof x === "string" && x.length > 0); |
||||
|
} catch { |
||||
|
return []; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function isAssetDeletable(row: { |
||||
|
firstReferencedAt: Date | null; |
||||
|
dereferencedAt: Date | null; |
||||
|
createdAt: Date; |
||||
|
}): boolean { |
||||
|
const now = Date.now(); |
||||
|
if (row.firstReferencedAt == null) { |
||||
|
return row.createdAt.getTime() <= now - NEVER_REF_MS; |
||||
|
} |
||||
|
if (row.dereferencedAt == null) { |
||||
|
return false; |
||||
|
} |
||||
|
return row.dereferencedAt.getTime() <= now - AFTER_DEREF_MS; |
||||
|
} |
||||
|
|
||||
|
function orphanCondition() { |
||||
|
return notExists( |
||||
|
dbGlobal.select({ x: sql`1` }).from(postMediaRefs).where(eq(postMediaRefs.assetId, mediaAssets.id)), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
function deletableTimeCondition(now: number) { |
||||
|
const neverRefCutoff = new Date(now - NEVER_REF_MS); |
||||
|
const afterDerefCutoff = new Date(now - AFTER_DEREF_MS); |
||||
|
return or( |
||||
|
and(isNull(mediaAssets.firstReferencedAt), lte(mediaAssets.createdAt, neverRefCutoff)), |
||||
|
and( |
||||
|
isNotNull(mediaAssets.firstReferencedAt), |
||||
|
isNotNull(mediaAssets.dereferencedAt), |
||||
|
lte(mediaAssets.dereferencedAt, afterDerefCutoff), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export async function insertMediaAssetRow(params: { |
||||
|
userId: number; |
||||
|
storageKey: string; |
||||
|
mime: string; |
||||
|
sizeBytes: number; |
||||
|
sha256?: string | null; |
||||
|
variantsJson?: string | null; |
||||
|
}): Promise<number> { |
||||
|
const id = await nextIntegerId(mediaAssets, mediaAssets.id); |
||||
|
await dbGlobal.insert(mediaAssets).values({ |
||||
|
id, |
||||
|
userId: params.userId, |
||||
|
storageKey: params.storageKey, |
||||
|
mime: params.mime, |
||||
|
sizeBytes: params.sizeBytes, |
||||
|
sha256: params.sha256 ?? null, |
||||
|
variantsJson: params.variantsJson ?? null, |
||||
|
}); |
||||
|
return id; |
||||
|
} |
||||
|
|
||||
|
export async function reconcileAssetTimestampsAfterRefChange(assetIds: number[]): Promise<void> { |
||||
|
const unique = [...new Set(assetIds)]; |
||||
|
for (const id of unique) { |
||||
|
const [{ c }] = await dbGlobal |
||||
|
.select({ c: count() }) |
||||
|
.from(postMediaRefs) |
||||
|
.where(eq(postMediaRefs.assetId, id)); |
||||
|
|
||||
|
if (c > 0) { |
||||
|
const [row] = await dbGlobal.select().from(mediaAssets).where(eq(mediaAssets.id, id)).limit(1); |
||||
|
if (!row) { |
||||
|
continue; |
||||
|
} |
||||
|
await dbGlobal |
||||
|
.update(mediaAssets) |
||||
|
.set({ |
||||
|
firstReferencedAt: row.firstReferencedAt ?? new Date(), |
||||
|
dereferencedAt: null, |
||||
|
}) |
||||
|
.where(eq(mediaAssets.id, id)); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const [row] = await dbGlobal.select().from(mediaAssets).where(eq(mediaAssets.id, id)).limit(1); |
||||
|
if (!row) { |
||||
|
continue; |
||||
|
} |
||||
|
if (row.firstReferencedAt == null) { |
||||
|
continue; |
||||
|
} |
||||
|
if (row.dereferencedAt != null) { |
||||
|
continue; |
||||
|
} |
||||
|
await dbGlobal |
||||
|
.update(mediaAssets) |
||||
|
.set({ dereferencedAt: new Date() }) |
||||
|
.where(eq(mediaAssets.id, id)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function syncPostMediaRefs( |
||||
|
userId: number, |
||||
|
postId: number, |
||||
|
bodyMarkdown: string, |
||||
|
coverUrl: string | null, |
||||
|
): Promise<void> { |
||||
|
const beforeRows = await dbGlobal |
||||
|
.select({ assetId: postMediaRefs.assetId }) |
||||
|
.from(postMediaRefs) |
||||
|
.where(eq(postMediaRefs.postId, postId)); |
||||
|
const beforeIds = beforeRows.map((r) => r.assetId); |
||||
|
|
||||
|
await dbGlobal.delete(postMediaRefs).where(eq(postMediaRefs.postId, postId)); |
||||
|
|
||||
|
const urls = mergePostMediaUrls(bodyMarkdown, coverUrl); |
||||
|
const keys = [...new Set(urls.map((u) => publicAssetUrlToStorageKey(u)).filter((k): k is string => k != null))]; |
||||
|
|
||||
|
let afterIds: number[] = []; |
||||
|
if (keys.length > 0) { |
||||
|
const assetRows = await dbGlobal |
||||
|
.select({ id: mediaAssets.id }) |
||||
|
.from(mediaAssets) |
||||
|
.where(and(eq(mediaAssets.userId, userId), inArray(mediaAssets.storageKey, keys))); |
||||
|
afterIds = assetRows.map((r) => r.id); |
||||
|
if (afterIds.length > 0) { |
||||
|
await dbGlobal |
||||
|
.insert(postMediaRefs) |
||||
|
.values(afterIds.map((assetId) => ({ postId, assetId }))) |
||||
|
.onConflictDoNothing(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
await reconcileAssetTimestampsAfterRefChange([...new Set([...beforeIds, ...afterIds])]); |
||||
|
} |
||||
|
|
||||
|
export async function listOrphanCandidatesForUser( |
||||
|
userId: number, |
||||
|
filter: "all" | "deletable" | "cooling", |
||||
|
page: number, |
||||
|
pageSize: number, |
||||
|
): Promise<{ |
||||
|
items: Array<{ |
||||
|
id: number; |
||||
|
storageKey: string; |
||||
|
publicUrl: string; |
||||
|
sizeBytes: number; |
||||
|
createdAt: Date; |
||||
|
firstReferencedAt: Date | null; |
||||
|
dereferencedAt: Date | null; |
||||
|
state: "deletable" | "cooling"; |
||||
|
}>; |
||||
|
total: number; |
||||
|
}> { |
||||
|
const now = Date.now(); |
||||
|
const base = and(eq(mediaAssets.userId, userId), orphanCondition()); |
||||
|
|
||||
|
let whereClause = |
||||
|
filter === "all" |
||||
|
? base |
||||
|
: filter === "deletable" |
||||
|
? and(base, deletableTimeCondition(now)) |
||||
|
: and(base, not(deletableTimeCondition(now))); |
||||
|
|
||||
|
const [{ total: totalRaw }] = await dbGlobal.select({ total: count() }).from(mediaAssets).where(whereClause); |
||||
|
|
||||
|
const offset = Math.max(0, (page - 1) * pageSize); |
||||
|
const rows = await dbGlobal |
||||
|
.select({ |
||||
|
id: mediaAssets.id, |
||||
|
storageKey: mediaAssets.storageKey, |
||||
|
sizeBytes: mediaAssets.sizeBytes, |
||||
|
createdAt: mediaAssets.createdAt, |
||||
|
firstReferencedAt: mediaAssets.firstReferencedAt, |
||||
|
dereferencedAt: mediaAssets.dereferencedAt, |
||||
|
}) |
||||
|
.from(mediaAssets) |
||||
|
.where(whereClause) |
||||
|
.orderBy(desc(mediaAssets.createdAt), desc(mediaAssets.id)) |
||||
|
.limit(pageSize) |
||||
|
.offset(offset); |
||||
|
|
||||
|
const items = rows.map((row) => { |
||||
|
const state: "deletable" | "cooling" = isAssetDeletable(row) ? "deletable" : "cooling"; |
||||
|
return { |
||||
|
id: row.id, |
||||
|
storageKey: row.storageKey, |
||||
|
publicUrl: `${POST_MEDIA_PUBLIC_PREFIX}${row.storageKey}`, |
||||
|
sizeBytes: row.sizeBytes, |
||||
|
createdAt: row.createdAt, |
||||
|
firstReferencedAt: row.firstReferencedAt, |
||||
|
dereferencedAt: row.dereferencedAt, |
||||
|
state, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
return { items, total: totalRaw }; |
||||
|
} |
||||
|
|
||||
|
async function assertAssetDeletableOrThrow(row: typeof mediaAssets.$inferSelect): Promise<void> { |
||||
|
const [{ c }] = await dbGlobal |
||||
|
.select({ c: count() }) |
||||
|
.from(postMediaRefs) |
||||
|
.where(eq(postMediaRefs.assetId, row.id)); |
||||
|
if (c > 0) { |
||||
|
throw createError({ statusCode: 400, statusMessage: "资源仍被文章引用,无法删除" }); |
||||
|
} |
||||
|
if (!isAssetDeletable(row)) { |
||||
|
throw createError({ statusCode: 400, statusMessage: "资源尚在宽限期,暂不可删除" }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function deleteMediaAssetsForUser(userId: number, ids: number[]): Promise<number> { |
||||
|
if (ids.length === 0) { |
||||
|
return 0; |
||||
|
} |
||||
|
|
||||
|
for (const id of ids) { |
||||
|
const [row] = await dbGlobal.select().from(mediaAssets).where(eq(mediaAssets.id, id)).limit(1); |
||||
|
if (!row || row.userId !== userId) { |
||||
|
throw createError({ statusCode: 400, statusMessage: "资源不存在或不属于当前用户" }); |
||||
|
} |
||||
|
await assertAssetDeletableOrThrow(row); |
||||
|
await unlinkMediaFiles(row.storageKey, row.variantsJson); |
||||
|
await dbGlobal.delete(mediaAssets).where(eq(mediaAssets.id, id)); |
||||
|
} |
||||
|
|
||||
|
return ids.length; |
||||
|
} |
||||
|
|
||||
|
export async function purgeAllDeletableOrphansGlobally(limit: number): Promise<number> { |
||||
|
const now = Date.now(); |
||||
|
const whereClause = and(orphanCondition(), deletableTimeCondition(now)); |
||||
|
|
||||
|
const candidates = await dbGlobal |
||||
|
.select() |
||||
|
.from(mediaAssets) |
||||
|
.where(whereClause) |
||||
|
.orderBy(mediaAssets.id) |
||||
|
.limit(limit); |
||||
|
|
||||
|
let deleted = 0; |
||||
|
for (const row of candidates) { |
||||
|
const [{ c }] = await dbGlobal |
||||
|
.select({ c: count() }) |
||||
|
.from(postMediaRefs) |
||||
|
.where(eq(postMediaRefs.assetId, row.id)); |
||||
|
if (c > 0) { |
||||
|
continue; |
||||
|
} |
||||
|
if (!isAssetDeletable(row)) { |
||||
|
continue; |
||||
|
} |
||||
|
await unlinkMediaFiles(row.storageKey, row.variantsJson); |
||||
|
await dbGlobal.delete(mediaAssets).where(eq(mediaAssets.id, row.id)); |
||||
|
deleted += 1; |
||||
|
} |
||||
|
|
||||
|
return deleted; |
||||
|
} |
||||
Loading…
Reference in new issue