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.
 
 
 
 
 

226 lines
6.9 KiB

import { dbGlobal } from "drizzle-pkg/lib/db";
import { users } from "drizzle-pkg/lib/schema/auth";
import { mediaRefs, posts } from "drizzle-pkg/lib/schema/content";
import { eq, inArray } from "drizzle-orm";
import { MEDIA_REF_OWNER_POST, MEDIA_REF_OWNER_PROFILE } from "#server/constants/media-refs";
import { extractMediaUrlsFromMarkdown, publicAssetUrlToStorageKey } from "#server/utils/post-media-urls";
import { allowedOriginsFromSitePublicEnv } from "#server/utils/site-public";
export type MediaRefContextDto = {
ownerType: string;
ownerId: number;
/** 人类可读,如文章标题与位置、资料中的头像/简介 */
label: string;
/** 可跳转的站内路径;无权限或私密时为空 */
href: string | null;
};
function extractUrls(markdown: string | null | undefined): string[] {
return extractMediaUrlsFromMarkdown(markdown ?? "", {
allowedAssetOrigins: allowedOriginsFromSitePublicEnv(),
});
}
function postUsageParts(bodyMarkdown: string, coverUrl: string | null, storageKey: string): string[] {
const parts: string[] = [];
if (publicAssetUrlToStorageKey(coverUrl ?? "") === storageKey) {
parts.push("封面");
}
if (extractUrls(bodyMarkdown).some((u) => publicAssetUrlToStorageKey(u) === storageKey)) {
parts.push("正文 Markdown");
}
return parts;
}
function profileUsageParts(avatar: string | null, bioMarkdown: string | null, storageKey: string): string[] {
const parts: string[] = [];
if (publicAssetUrlToStorageKey(avatar ?? "") === storageKey) {
parts.push("头像");
}
if (extractUrls(bioMarkdown).some((u) => publicAssetUrlToStorageKey(u) === storageKey)) {
parts.push("简介 Markdown");
}
return parts;
}
function postHref(
post: {
id: number;
userId: number;
visibility: string;
slug: string;
authorPublicSlug: string | null;
},
viewerUserId: number,
): string | null {
if (post.userId === viewerUserId) {
return `/me/posts/${post.id}`;
}
if (post.visibility === "public" && post.authorPublicSlug) {
return `/@${post.authorPublicSlug}/posts/${encodeURIComponent(post.slug)}`;
}
return null;
}
/**
* 为一批资源构建引用上下文(文章 / 个人资料),用于媒体库与存储审计展示。
*/
export async function buildRefContextsForAssets(
assets: Array<{ id: number; storageKey: string }>,
viewer: { userId: number; role: string },
): Promise<Map<number, MediaRefContextDto[]>> {
const map = new Map<number, MediaRefContextDto[]>();
if (assets.length === 0) {
return map;
}
const assetIds = assets.map((a) => a.id);
const idToKey = new Map(assets.map((a) => [a.id, a.storageKey]));
const refRows = await dbGlobal
.select({
assetId: mediaRefs.assetId,
ownerType: mediaRefs.ownerType,
ownerId: mediaRefs.ownerId,
})
.from(mediaRefs)
.where(inArray(mediaRefs.assetId, assetIds));
const refsByAsset = new Map<number, { ownerType: string; ownerId: number }[]>();
for (const r of refRows) {
const list = refsByAsset.get(r.assetId) ?? [];
list.push({ ownerType: r.ownerType, ownerId: r.ownerId });
refsByAsset.set(r.assetId, list);
}
const postIds = [...new Set(refRows.filter((r) => r.ownerType === MEDIA_REF_OWNER_POST).map((r) => r.ownerId))];
const profileUserIds = [
...new Set(refRows.filter((r) => r.ownerType === MEDIA_REF_OWNER_PROFILE).map((r) => r.ownerId)),
];
const postMap = new Map<
number,
{
id: number;
userId: number;
title: string;
slug: string;
visibility: string;
bodyMarkdown: string;
coverUrl: string | null;
authorPublicSlug: string | null;
}
>();
if (postIds.length > 0) {
const rows = await dbGlobal
.select({
id: posts.id,
userId: posts.userId,
title: posts.title,
slug: posts.slug,
visibility: posts.visibility,
bodyMarkdown: posts.bodyMarkdown,
coverUrl: posts.coverUrl,
authorPublicSlug: users.publicSlug,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(inArray(posts.id, postIds));
for (const p of rows) {
postMap.set(p.id, p);
}
}
const userMap = new Map<number, { id: number; username: string; avatar: string | null; bioMarkdown: string | null }>();
if (profileUserIds.length > 0) {
const urows = await dbGlobal
.select({
id: users.id,
username: users.username,
avatar: users.avatar,
bioMarkdown: users.bioMarkdown,
})
.from(users)
.where(inArray(users.id, profileUserIds));
for (const u of urows) {
userMap.set(u.id, u);
}
}
for (const asset of assets) {
const storageKey = idToKey.get(asset.id);
if (!storageKey) {
continue;
}
const refs = refsByAsset.get(asset.id) ?? [];
const contexts: MediaRefContextDto[] = [];
for (const r of refs) {
if (r.ownerType === MEDIA_REF_OWNER_POST) {
const p = postMap.get(r.ownerId);
if (!p) {
contexts.push({
ownerType: r.ownerType,
ownerId: r.ownerId,
label: `文章 #${r.ownerId}(记录已不存在)`,
href: null,
});
continue;
}
const usage = postUsageParts(p.bodyMarkdown, p.coverUrl, storageKey);
const usageText = usage.length ? ` · ${usage.join("、")}` : "";
contexts.push({
ownerType: r.ownerType,
ownerId: r.ownerId,
label: `文章「${truncateTitle(p.title)}${usageText}`,
href: postHref(
{
id: p.id,
userId: p.userId,
visibility: p.visibility,
slug: p.slug,
authorPublicSlug: p.authorPublicSlug,
},
viewer.userId,
),
});
} else if (r.ownerType === MEDIA_REF_OWNER_PROFILE) {
const u = userMap.get(r.ownerId);
if (!u) {
contexts.push({
ownerType: r.ownerType,
ownerId: r.ownerId,
label: `个人资料用户 #${r.ownerId}(记录已不存在)`,
href: null,
});
continue;
}
const slots = profileUsageParts(u.avatar, u.bioMarkdown, storageKey);
const slotText = slots.length ? slots.join("、") : "图片";
const href = viewer.userId === u.id ? "/me/profile" : null;
contexts.push({
ownerType: r.ownerType,
ownerId: r.ownerId,
label: `用户 @${u.username} 的个人资料 · ${slotText}`,
href,
});
} else {
contexts.push({
ownerType: r.ownerType,
ownerId: r.ownerId,
label: `${r.ownerType} #${r.ownerId}`,
href: null,
});
}
}
map.set(asset.id, contexts);
}
return map;
}
function truncateTitle(title: string, max = 48): string {
const t = title.trim();
if (t.length <= max) {
return t;
}
return `${t.slice(0, max - 1)}`;
}