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.
 
 
 

345 lines
10 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 { 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;
}
/** 孤儿资源「宽限结束、允许删除」的绝对时间;无法推算时返回 null */
export function computeOrphanGraceExpiresAt(row: {
firstReferencedAt: Date | null;
dereferencedAt: Date | null;
createdAt: Date;
}): Date | null {
if (row.firstReferencedAt == null) {
return new Date(row.createdAt.getTime() + NEVER_REF_MS);
}
if (row.dereferencedAt == null) {
return null;
}
return new Date(row.dereferencedAt.getTime() + 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;
graceExpiresAt: 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,
graceExpiresAt: computeOrphanGraceExpiresAt(row),
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;
}