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.
388 lines
12 KiB
388 lines
12 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 { 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 { MEDIA_REF_OWNER_POST, MEDIA_REF_OWNER_PROFILE } from "#server/constants/media-refs";
|
|
import { mergePostMediaUrls, mergeProfileMediaUrls, 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(mediaRefs).where(eq(mediaRefs.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(mediaRefs)
|
|
.where(eq(mediaRefs.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 postRef = and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), eq(mediaRefs.ownerId, postId));
|
|
const beforeRows = await dbGlobal.select({ assetId: mediaRefs.assetId }).from(mediaRefs).where(postRef);
|
|
const beforeIds = beforeRows.map((r) => r.assetId);
|
|
|
|
await dbGlobal.delete(mediaRefs).where(postRef);
|
|
|
|
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(mediaRefs)
|
|
.values(
|
|
afterIds.map((assetId) => ({
|
|
ownerType: MEDIA_REF_OWNER_POST,
|
|
ownerId: postId,
|
|
assetId,
|
|
})),
|
|
)
|
|
.onConflictDoNothing();
|
|
}
|
|
}
|
|
|
|
await reconcileAssetTimestampsAfterRefChange([...new Set([...beforeIds, ...afterIds])]);
|
|
}
|
|
|
|
export async function syncProfileMediaRefs(
|
|
userId: number,
|
|
bioMarkdown: string | null,
|
|
avatar: string | null,
|
|
): Promise<void> {
|
|
const profileRef = and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_PROFILE), eq(mediaRefs.ownerId, userId));
|
|
const beforeRows = await dbGlobal.select({ assetId: mediaRefs.assetId }).from(mediaRefs).where(profileRef);
|
|
const beforeIds = beforeRows.map((r) => r.assetId);
|
|
|
|
await dbGlobal.delete(mediaRefs).where(profileRef);
|
|
|
|
const urls = mergeProfileMediaUrls(bioMarkdown, avatar);
|
|
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(mediaRefs)
|
|
.values(
|
|
afterIds.map((assetId) => ({
|
|
ownerType: MEDIA_REF_OWNER_PROFILE,
|
|
ownerId: userId,
|
|
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(mediaRefs)
|
|
.where(eq(mediaRefs.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(mediaRefs)
|
|
.where(eq(mediaRefs.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;
|
|
}
|
|
|