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
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)}…`;
|
|
}
|
|
|