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.
 
 
 
 
 

647 lines
20 KiB

import fs from "node:fs";
import path from "node:path";
import sharp from "sharp";
import { dbGlobal } from "drizzle-pkg/lib/db";
import { users } from "drizzle-pkg/lib/schema/auth";
import { mediaAssets, mediaRefs, posts } from "drizzle-pkg/lib/schema/content";
import { alias } from "drizzle-orm/sqlite-core";
import { and, count, desc, eq, exists, inArray, isNotNull, isNull, lte, not, notExists, or, sql } from "drizzle-orm";
import {
MEDIA_IMAGE_MAX_WIDTH_PX,
MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF,
MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF,
MEDIA_WEBP_QUALITY,
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 { buildRefContextsForAssets, type MediaRefContextDto } from "#server/service/media/ref-context";
import { mergePostMediaUrls, mergeProfileMediaUrls, publicAssetUrlToStorageKey } from "#server/utils/post-media-urls";
import { allowedOriginsFromSitePublicEnv } from "#server/utils/site-public";
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;
}
/**
* 将临时上传的栅格图转 WebP 并写入该资产既有 `storageKey` 路径,更新 `sizeBytes` / `mime`。
* 用于「库有记录但磁盘缺文件」或用户主动替换文件;不改变 `id` 与 `storageKey`,引用仍有效。
*/
export async function replaceMediaAssetFileFromTempUpload(params: {
assetId: number;
actorUserId: number;
actorIsAdmin: boolean;
tempInputPath: string;
}): Promise<{ storageKey: string; sizeBytes: number }> {
const [row] = await dbGlobal.select().from(mediaAssets).where(eq(mediaAssets.id, params.assetId)).limit(1);
if (!row) {
throw createError({ statusCode: 404, statusMessage: "媒体不存在" });
}
if (!params.actorIsAdmin && row.userId !== params.actorUserId) {
throw createError({ statusCode: 403, statusMessage: "无权操作该媒体" });
}
const key = row.storageKey;
if (!key || key !== path.basename(key) || key.includes("..")) {
throw createError({ statusCode: 400, statusMessage: "非法 storage_key,无法覆盖写入" });
}
const base = assetsBaseDir();
const destPath = path.resolve(base, key);
if (!isPathUnderAssetsDir(destPath)) {
throw createError({ statusCode: 400, statusMessage: "非法存储路径" });
}
if (!fs.existsSync(base)) {
fs.mkdirSync(base, { recursive: true });
}
try {
await sharp(params.tempInputPath)
.rotate()
.resize({
width: MEDIA_IMAGE_MAX_WIDTH_PX,
height: MEDIA_IMAGE_MAX_WIDTH_PX,
fit: "inside",
withoutEnlargement: true,
})
.webp({ quality: MEDIA_WEBP_QUALITY })
.toFile(destPath);
} catch (procErr) {
if (fs.existsSync(destPath)) {
try {
fs.unlinkSync(destPath);
} catch {
/* ignore */
}
}
const msg = procErr instanceof Error ? procErr.message : "图片处理失败";
throw createError({ statusCode: 400, statusMessage: msg });
} finally {
if (params.tempInputPath !== destPath) {
try {
if (fs.existsSync(params.tempInputPath)) {
fs.unlinkSync(params.tempInputPath);
}
} catch {
/* ignore */
}
}
}
const stat = fs.statSync(destPath);
await dbGlobal
.update(mediaAssets)
.set({
sizeBytes: stat.size,
mime: "image/webp",
sha256: null,
variantsJson: null,
})
.where(eq(mediaAssets.id, params.assetId));
return { storageKey: key, sizeBytes: stat.size };
}
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 allowedOrigins = allowedOriginsFromSitePublicEnv();
const urls = mergePostMediaUrls(bodyMarkdown, coverUrl, { allowedAssetOrigins: allowedOrigins });
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 allowedOrigins = allowedOriginsFromSitePublicEnv();
const urls = mergeProfileMediaUrls(bioMarkdown, avatar, { allowedAssetOrigins: allowedOrigins });
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;
}
function escapeSqlLikePattern(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
}
/** storage_key、user_note、引用(owner_type / owner_id、文章标题与 slug、资料用户名)任一 LIKE 命中 */
function userMediaAssetsSearchCondition(term: string) {
const pat = `%${escapeSqlLikePattern(term)}%`;
const profileUser = alias(users, "media_search_profile");
const refMatch = exists(
dbGlobal
.select({ one: sql`1` })
.from(mediaRefs)
.leftJoin(
posts,
and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), eq(mediaRefs.ownerId, posts.id)),
)
.leftJoin(
profileUser,
and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_PROFILE), eq(mediaRefs.ownerId, profileUser.id)),
)
.where(
and(
eq(mediaRefs.assetId, mediaAssets.id),
or(
sql`${mediaRefs.ownerType} LIKE ${pat} ESCAPE '\\'`,
sql`CAST(${mediaRefs.ownerId} AS TEXT) LIKE ${pat} ESCAPE '\\'`,
sql`COALESCE(${posts.title}, '') LIKE ${pat} ESCAPE '\\'`,
sql`COALESCE(${posts.slug}, '') LIKE ${pat} ESCAPE '\\'`,
sql`COALESCE(${profileUser.username}, '') LIKE ${pat} ESCAPE '\\'`,
),
),
),
);
return or(
sql`${mediaAssets.storageKey} LIKE ${pat} ESCAPE '\\'`,
sql`COALESCE(${mediaAssets.userNote}, '') LIKE ${pat} ESCAPE '\\'`,
refMatch,
);
}
export type UserMediaAssetListRow = {
id: number;
storageKey: string;
mime: string;
sizeBytes: number;
createdAt: Date;
refCount: number;
userNote: string | null;
refContexts: MediaRefContextDto[];
/** 无引用且已过孤儿宽限,可立即从资源库删除 */
canDelete: boolean;
/** 不可删除原因:仍被引用 / 尚在宽限期 */
deleteBlockedReason: "referenced" | "cooling" | null;
/** 宽限结束后可删的预计时间(ISO);仅 cooling 时有值 */
deleteGraceExpiresAt: string | null;
};
export async function listUserMediaAssetsPage(
userId: number,
page: number,
pageSize: number,
viewer: { userId: number; role: string },
opts?: { search?: string | null },
): Promise<{ items: UserMediaAssetListRow[]; total: number }> {
const term = typeof opts?.search === "string" ? opts.search.trim() : "";
const userFilter = eq(mediaAssets.userId, userId);
const whereClause = term ? and(userFilter, userMediaAssetsSearchCondition(term)) : userFilter;
const [{ total: totalRaw }] = await dbGlobal.select({ total: count() }).from(mediaAssets).where(whereClause);
const total = typeof totalRaw === "bigint" ? Number(totalRaw) : Number(totalRaw);
const offset = (page - 1) * pageSize;
const rows = await dbGlobal
.select({
id: mediaAssets.id,
storageKey: mediaAssets.storageKey,
mime: mediaAssets.mime,
sizeBytes: mediaAssets.sizeBytes,
createdAt: mediaAssets.createdAt,
userNote: mediaAssets.userNote,
firstReferencedAt: mediaAssets.firstReferencedAt,
dereferencedAt: mediaAssets.dereferencedAt,
})
.from(mediaAssets)
.where(whereClause)
.orderBy(desc(mediaAssets.createdAt))
.limit(pageSize)
.offset(offset);
if (rows.length === 0) {
return { items: [], total };
}
const ids = rows.map((r) => r.id);
const refAgg = await dbGlobal
.select({
assetId: mediaRefs.assetId,
refCount: count(),
})
.from(mediaRefs)
.where(inArray(mediaRefs.assetId, ids))
.groupBy(mediaRefs.assetId);
const refMap = new Map<number, number>();
for (const r of refAgg) {
const c = r.refCount;
refMap.set(r.assetId, typeof c === "bigint" ? Number(c) : Number(c));
}
const ctxMap = await buildRefContextsForAssets(
rows.map((row) => ({ id: row.id, storageKey: row.storageKey })),
viewer,
);
const items: UserMediaAssetListRow[] = rows.map((row) => {
const refCount = refMap.get(row.id) ?? 0;
const graceRow = {
firstReferencedAt: row.firstReferencedAt,
dereferencedAt: row.dereferencedAt,
createdAt: row.createdAt,
};
const deletable = isAssetDeletable(graceRow);
const canDelete = refCount === 0 && deletable;
let deleteBlockedReason: "referenced" | "cooling" | null = null;
if (refCount > 0) {
deleteBlockedReason = "referenced";
} else if (!deletable) {
deleteBlockedReason = "cooling";
}
const deleteGraceExpiresAt =
deleteBlockedReason === "cooling" ? (computeOrphanGraceExpiresAt(graceRow)?.toISOString() ?? null) : null;
return {
id: row.id,
storageKey: row.storageKey,
mime: row.mime,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt,
refCount,
userNote: row.userNote ?? null,
refContexts: ctxMap.get(row.id) ?? [],
canDelete,
deleteBlockedReason,
deleteGraceExpiresAt,
};
});
return { items, total };
}
export async function setMediaAssetUserNote(params: {
assetId: number;
note: string | null;
actorUserId: number;
actorIsAdmin: boolean;
}): Promise<void> {
const [row] = await dbGlobal.select().from(mediaAssets).where(eq(mediaAssets.id, params.assetId)).limit(1);
if (!row) {
throw createError({ statusCode: 404, statusMessage: "媒体不存在" });
}
if (!params.actorIsAdmin && row.userId !== params.actorUserId) {
throw createError({ statusCode: 403, statusMessage: "无权操作该媒体" });
}
const trimmed = params.note === null ? null : params.note.trim();
if (trimmed && trimmed.length > 500) {
throw createError({ statusCode: 400, statusMessage: "备注最多 500 字" });
}
await dbGlobal
.update(mediaAssets)
.set({ userNote: trimmed && trimmed.length > 0 ? trimmed : null })
.where(eq(mediaAssets.id, params.assetId));
}