Browse Source

refactor: update asset paths and enhance post visibility handling

- Changed asset paths from `/public/assets` to `/public/upload` across various files to ensure consistency in media storage.
- Introduced visibility handling for posts, allowing for better control over comment visibility based on post status.
- Enhanced markdown export functionality with new utilities for exporting unlisted posts and normalizing image URLs.
- Updated tests to reflect changes in asset paths and visibility logic.

These updates improve the overall media management and user experience in handling post visibility and markdown exports.
main
npmrun 2 weeks ago
parent
commit
35652caf35
  1. 2
      .env.example
  2. 2
      app/layouts/blank.vue
  3. 7
      app/pages/@[publicSlug]/posts/[postSlug].vue
  4. 2
      app/pages/me/admin/media-storage.vue
  5. 51
      app/pages/me/posts/[id].vue
  6. 12
      app/pages/me/posts/index.vue
  7. 59
      app/pages/p/[publicSlug]/t/[shareToken].vue
  8. 8
      app/utils/markdown-export.test.ts
  9. BIN
      packages/drizzle-pkg/db.sqlite
  10. 4
      server/api/file/upload.post.ts
  11. 5
      server/api/public/profile/[publicSlug]/posts/[postSlug].get.ts
  12. 4
      server/constants/media.ts
  13. 40
      server/service/posts/index.ts
  14. 20
      server/utils/post-media-urls.test.ts
  15. 6
      server/utils/post-media-urls.ts

2
.env.example

@ -1,7 +1,7 @@
# DATABASE_URL=postgresql://postgres:xxxxxx@localhost:6666/postgres
DATABASE_URL=file:./db.sqlite
NITRO_PORT=3399
# 站点对外根 URL(含协议与域名,可带端口)。用于:① 媒体库复制绝对链接 ② 文章/资料里绝对地址图片是否计为本站 /public/assets/ 引用。生产环境务必设置,与浏览器访问地址一致。
# 站点对外根 URL(含协议与域名,可带端口)。用于:① 媒体库复制绝对链接 ② 文章/资料里绝对地址图片是否计为本站 /public/upload/ 引用。生产环境务必设置,与浏览器访问地址一致。
NUXT_PUBLIC_SITE_URL=https://example.com
# Optional: first admin for an empty instance. Creates an admin only when no user has role=admin yet (same username/password rules as registration).
BOOTSTRAP_ADMIN_USERNAME=

2
app/layouts/blank.vue

@ -1,6 +1,6 @@
<template>
<UApp>
<div class="min-h-screen bg-gradient-to-br from-neutral-50 via-white to-neutral-100 text-default">
<div class="min-h-screen bg-gradient-to-br from-neutral-50 via-white to-neutral-100 text-default dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-800">
<slot />
</div>
</UApp>

7
app/pages/@[publicSlug]/posts/[postSlug].vue

@ -28,6 +28,7 @@ type Post = {
bodyMarkdown: string
coverUrl: string | null
publishedAt: Date | null
visibility: 'private' | 'unlisted' | 'public'
}
const { data, pending, error } = await useAsyncData(
@ -82,6 +83,10 @@ const editPostHref = computed(() =>
data.value && canEditPost.value ? `/me/posts/${data.value.id}` : '',
)
const canViewComments = computed(() =>
data.value?.visibility === 'public',
)
function exportMarkdown(): void {
if (!data.value) {
return
@ -155,7 +160,7 @@ function exportMarkdown(): void {
class="prose prose-neutral dark:prose-invert max-w-none prose-a:text-primary prose-img:rounded-lg prose-headings:text-highlighted prose-p:text-default prose-strong:text-highlighted markdown-body green"
v-html="renderedBody"
/>
<PostComments mode="public-post" :public-slug="publicSlug" :post-slug="postSlug" />
<PostComments v-if="canViewComments" mode="public-post" :public-slug="publicSlug" :post-slug="postSlug" />
</template>
</UContainer>
</template>

2
app/pages/me/admin/media-storage.vue

@ -195,7 +195,7 @@ onMounted(async () => {
媒体存储校验
</h1>
<p class="text-sm text-muted mt-1 max-w-3xl">
比对 <code class="text-xs">public/assets</code> 与表 <code class="text-xs">media_assets</code>
比对 <code class="text-xs">public/upload</code> 与表 <code class="text-xs">media_assets</code>
库中有记录但文件缺失非法 storageKey以及磁盘上未登记的文件
一键清理仅删除<strong> media_refs 引用</strong><strong>磁盘上确实没有文件</strong>的库记录不会删磁盘文件
对有引用但缺文件的记录可使用<strong>重新上传</strong>按原 <code class="text-xs">storage_key</code> 写回 WebP不破坏文章/资料中的链接

51
app/pages/me/posts/[id].vue

@ -27,7 +27,7 @@ const bodyLength = computed(() => state.bodyMarkdown.trim().length)
const publicPostHref = computed(() => {
const ps = user.value?.publicSlug
if (state.visibility !== 'public' || !ps || !state.slug) {
if (!ps || !state.slug) {
return ''
}
return `/@${ps}/posts/${encodeURIComponent(state.slug)}`
@ -119,15 +119,27 @@ async function remove() {
}
const shareUrl = computed(() => {
const slug = user.value?.publicSlug
const slug = user.value?.publicSlug?.trim()
if (state.visibility !== 'unlisted' || !state.shareToken || !slug) {
return ''
}
if (import.meta.client) {
return `${window.location.origin}/p/${slug}/t/${state.shareToken}`
return `${window.location.origin}/p/${encodeURIComponent(slug)}/t/${encodeURIComponent(state.shareToken)}`
}
return ''
})
async function copyShareUrl() {
if (!shareUrl.value || !import.meta.client) {
return
}
try {
await navigator.clipboard.writeText(shareUrl.value)
toast.add({ title: '分享链接已复制', color: 'success' })
} catch {
toast.add({ title: '复制失败,请手动复制', color: 'warning' })
}
}
</script>
<template>
@ -220,11 +232,36 @@ const shareUrl = computed(() => {
<UFormField label="可见性" name="visibility">
<USelect v-model="state.visibility" :items="visibilityItems" />
</UFormField>
<UAlert
<div
v-if="shareUrl"
title="仅链接分享"
:description="shareUrl"
/>
class="rounded-md border border-default bg-elevated/40 p-3 space-y-2"
>
<p class="text-sm font-medium">
仅链接分享
</p>
<div class="flex flex-col gap-2">
<UInput
readonly
size="sm"
class="font-mono text-xs w-full min-w-0"
:model-value="shareUrl"
/>
<div class="flex flex-wrap gap-2">
<UButton size="sm" @click="copyShareUrl">
复制链接
</UButton>
<UButton
size="sm"
color="neutral"
variant="outline"
:to="shareUrl"
target="_blank"
>
打开
</UButton>
</div>
</div>
</div>
</UCard>
<UCard :ui="{ body: 'p-4 sm:p-5 space-y-3' }">

12
app/pages/me/posts/index.vue

@ -26,9 +26,9 @@ onMounted(() => {
void load()
})
function postDetailHref(slug: string, visibility: string) {
function postDetailHref(slug: string) {
const ps = user.value?.publicSlug
if (!ps || visibility !== 'public') {
if (!ps || !slug) {
return ''
}
return `/@${ps}/posts/${encodeURIComponent(slug)}`
@ -98,8 +98,8 @@ function visibilityLabel(visibility: string) {
</div>
<div class="flex flex-wrap gap-1 justify-end">
<UButton
v-if="postDetailHref(p.slug, p.visibility)"
:to="postDetailHref(p.slug, p.visibility)"
v-if="postDetailHref(p.slug)"
:to="postDetailHref(p.slug)"
size="xs"
variant="soft"
color="neutral"
@ -132,8 +132,8 @@ function visibilityLabel(visibility: string) {
<template #footer>
<div class="flex flex-wrap gap-2 justify-end">
<UButton
v-if="postDetailHref(p.slug, p.visibility)"
:to="postDetailHref(p.slug, p.visibility)"
v-if="postDetailHref(p.slug)"
:to="postDetailHref(p.slug)"
size="xs"
variant="soft"
color="neutral"

59
app/pages/p/[publicSlug]/t/[shareToken].vue

@ -2,6 +2,11 @@
import { unwrapApiBody, type ApiResponse } from '../../../../utils/http/factory'
import { formatOccurredOnDisplay, occurredOnToIsoAttr } from '../../../../utils/timeline-datetime'
import { renderSafeMarkdown } from '../../../../utils/render-markdown'
import {
buildMarkdownExportFileName,
downloadMarkdownFile,
normalizeMarkdownImageUrls,
} from '../../../../utils/markdown-export'
definePageMeta({
layout: 'public',
@ -10,6 +15,7 @@ definePageMeta({
const route = useRoute()
const publicSlug = computed(() => route.params.publicSlug as string)
const shareToken = computed(() => route.params.shareToken as string)
const toast = useToast()
type Unlisted = {
kind: 'post' | 'timeline' | 'rssItem'
@ -50,6 +56,42 @@ const renderedUnlistedPostBody = computed(() => {
}
return renderSafeMarkdown(String(data.value.data.bodyMarkdown ?? ''))
})
const unlistedPostPublishedAtLabel = computed(() => {
if (data.value?.kind !== 'post' || !data.value.data.publishedAt) {
return ''
}
return formatOccurredOnDisplay(data.value.data.publishedAt as string | number | Date)
})
const unlistedPostPublishedAtIso = computed(() => {
if (data.value?.kind !== 'post' || !data.value.data.publishedAt) {
return ''
}
return occurredOnToIsoAttr(data.value.data.publishedAt as string | number | Date)
})
function exportUnlistedPostMarkdown(): void {
if (data.value?.kind !== 'post') {
return
}
try {
const origin = window.location.origin
const bodyMarkdown = String(data.value.data.bodyMarkdown ?? '')
const normalizedMarkdown = normalizeMarkdownImageUrls(bodyMarkdown, origin)
const slug = String(data.value.data.slug ?? '').trim()
const id = Number(data.value.data.id)
const filename = buildMarkdownExportFileName({
slug: slug || undefined,
id: Number.isFinite(id) ? id : undefined,
})
downloadMarkdownFile(filename, normalizedMarkdown)
toast.add({ title: '已导出 Markdown', color: 'success' })
} catch {
toast.add({ title: '导出失败,请稍后重试', color: 'error' })
}
}
</script>
<template>
@ -63,11 +105,26 @@ const renderedUnlistedPostBody = computed(() => {
{{ data.kind }}
</UBadge>
<template v-if="data.kind === 'post'">
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="soft"
size="sm"
:disabled="pending"
@click="exportUnlistedPostMarkdown"
>
导出 .md
</UButton>
</div>
<p v-if="unlistedPostPublishedAtLabel" class="text-sm tabular-nums text-muted">
发布于
<time :datetime="unlistedPostPublishedAtIso" class="text-default">{{ unlistedPostPublishedAtLabel }}</time>
</p>
<h1 class="text-2xl font-semibold">
{{ data.data.title as string }}
</h1>
<article
class="prose dark:prose-invert max-w-none prose-a:text-primary prose-img:rounded-lg"
class="prose prose-neutral dark:prose-invert max-w-none prose-a:text-primary prose-img:rounded-lg prose-headings:text-highlighted prose-p:text-default prose-strong:text-highlighted markdown-body green"
v-html="renderedUnlistedPostBody"
/>
<PostComments mode="unlisted" :public-slug="publicSlug" :share-token="shareToken" />

8
app/utils/markdown-export.test.ts

@ -6,11 +6,11 @@ import {
} from "./markdown-export";
describe("normalizeMarkdownImageUrls", () => {
test("converts /public/assets image links to absolute URLs", () => {
const markdown = "![cover](/public/assets/posts/cover.png)";
test("converts /public/upload image links to absolute URLs", () => {
const markdown = "![cover](/public/upload/posts/cover.png)";
const result = normalizeMarkdownImageUrls(markdown, "https://example.com");
expect(result).toBe("![cover](https://example.com/public/assets/posts/cover.png)");
expect(result).toBe("![cover](https://example.com/public/upload/posts/cover.png)");
});
test("converts other site-relative image links to absolute URLs", () => {
@ -43,7 +43,7 @@ describe("normalizeMarkdownImageUrls", () => {
});
test("does not change normal markdown links", () => {
const markdown = "[read more](/public/assets/posts/cover.png)";
const markdown = "[read more](/public/upload/posts/cover.png)";
const result = normalizeMarkdownImageUrls(markdown, "https://example.com");
expect(result).toBe(markdown);

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

4
server/api/file/upload.post.ts

@ -30,7 +30,7 @@ export default defineWrappedResponseHandler(async (event) => {
const user = await event.context.auth.requireUser();
// 存储目录
const uploadDir = path.join(process.cwd(), 'public/assets');
const uploadDir = path.join(process.cwd(), 'public/upload');
// 自动创建目录
if (!fs.existsSync(uploadDir)) {
@ -144,7 +144,7 @@ export default defineWrappedResponseHandler(async (event) => {
result.push({
name: file.originalname,
url: `/public/assets/${finalName}`,
url: `/public/upload/${finalName}`,
mimeType: 'image/webp',
size: stat.size,
path: finalPath,

5
server/api/public/profile/[publicSlug]/posts/[postSlug].get.ts

@ -1,4 +1,4 @@
import { getPublicPostByPublicSlugAndSlug } from "#server/service/posts";
import { getPostByPublicSlugAndSlugForViewer } from "#server/service/posts";
export default defineEventHandler(async (event) => {
const publicSlug = event.context.params?.publicSlug;
@ -7,7 +7,8 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: "无效请求" });
}
const post = await getPublicPostByPublicSlugAndSlug(publicSlug, postSlug);
const viewer = await event.context.auth.getCurrent();
const post = await getPostByPublicSlugAndSlugForViewer(publicSlug, postSlug, viewer?.id ?? null);
if (!post) {
throw createError({ statusCode: 404, statusMessage: "未找到" });
}

4
server/constants/media.ts

@ -1,5 +1,5 @@
/** 与 `upload` 返回及静态路径一致,无前导 host */
export const POST_MEDIA_PUBLIC_PREFIX = "/public/assets/";
export const POST_MEDIA_PUBLIC_PREFIX = "/public/upload/";
/** 从未引用:created_at 起算;曾引用:dereferenced_at 起算 */
export const MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF = 24;
@ -8,4 +8,4 @@ export const MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF = 24;
export const MEDIA_IMAGE_MAX_WIDTH_PX = 1920;
export const MEDIA_WEBP_QUALITY = 82;
export const RELATIVE_ASSETS_DIR = "public/assets";
export const RELATIVE_ASSETS_DIR = "public/upload";

40
server/service/posts/index.ts

@ -2,7 +2,7 @@ import { dbGlobal } from "drizzle-pkg/lib/db";
import { mediaRefs, posts } from "drizzle-pkg/lib/schema/content";
import { reconcileAssetTimestampsAfterRefChange, syncPostMediaRefs } from "#server/service/media";
import { users } from "drizzle-pkg/lib/schema/auth";
import { and, count, desc, eq } from "drizzle-orm";
import { and, count, desc, eq, or } from "drizzle-orm";
import { MEDIA_REF_OWNER_POST } from "#server/constants/media-refs";
import { PUBLIC_LIST_PAGE_SIZE, PUBLIC_PREVIEW_LIMIT } from "#server/constants/public-profile-lists";
import { visibilitySchema, type Visibility } from "#server/constants/visibility";
@ -250,6 +250,44 @@ export async function getPublicPostByPublicSlugAndSlug(publicSlug: string, postS
return row ?? null;
}
export async function getPostByPublicSlugAndSlugForViewer(
publicSlug: string,
postSlug: string,
viewerUserId: number | null,
) {
const slug = postSlug.trim();
if (!slug) {
return null;
}
const visibilityFilter =
viewerUserId != null
? or(eq(posts.visibility, "public"), eq(posts.userId, viewerUserId))
: eq(posts.visibility, "public");
const [row] = await dbGlobal
.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
excerpt: posts.excerpt,
bodyMarkdown: posts.bodyMarkdown,
coverUrl: posts.coverUrl,
publishedAt: posts.publishedAt,
visibility: posts.visibility,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
eq(users.publicSlug, publicSlug),
eq(users.status, "active"),
eq(posts.slug, slug),
visibilityFilter,
),
)
.limit(1);
return row ?? null;
}
export async function getPublicPostCommentContext(
publicSlug: string,
postSlug: string,

20
server/utils/post-media-urls.test.ts

@ -2,40 +2,40 @@ import { describe, expect, test } from "bun:test";
import { extractMediaUrlsFromMarkdown, publicAssetUrlToStorageKey } from "./post-media-urls";
describe("extractMediaUrlsFromMarkdown", () => {
test("accepts site-relative /public/assets/ URL without allowed origins", () => {
expect(extractMediaUrlsFromMarkdown("![](/public/assets/a.webp)")).toEqual(["/public/assets/a.webp"]);
test("accepts site-relative /public/upload/ URL without allowed origins", () => {
expect(extractMediaUrlsFromMarkdown("![](/public/upload/a.webp)")).toEqual(["/public/upload/a.webp"]);
});
test("rejects absolute URL when no allowed origins", () => {
expect(extractMediaUrlsFromMarkdown("![](https://blog.example.com/public/assets/b.webp)")).toEqual([]);
expect(extractMediaUrlsFromMarkdown("![](https://blog.example.com/public/upload/b.webp)")).toEqual([]);
});
test("accepts absolute URL when origin matches allowed list", () => {
expect(
extractMediaUrlsFromMarkdown("![](https://blog.example.com/public/assets/b.webp)", {
extractMediaUrlsFromMarkdown("![](https://blog.example.com/public/upload/b.webp)", {
allowedAssetOrigins: ["https://blog.example.com"],
}),
).toEqual(["/public/assets/b.webp"]);
).toEqual(["/public/upload/b.webp"]);
});
test("rejects absolute URL when origin does not match", () => {
expect(
extractMediaUrlsFromMarkdown("![](https://evil.example/public/assets/b.webp)", {
extractMediaUrlsFromMarkdown("![](https://evil.example/public/upload/b.webp)", {
allowedAssetOrigins: ["https://blog.example.com"],
}),
).toEqual([]);
});
test("strips query on relative asset URL", () => {
expect(extractMediaUrlsFromMarkdown("![](/public/assets/c.webp?v=1)")).toEqual(["/public/assets/c.webp"]);
expect(extractMediaUrlsFromMarkdown("![](/public/upload/c.webp?v=1)")).toEqual(["/public/upload/c.webp"]);
});
test("strips query on absolute asset URL via pathname", () => {
expect(
extractMediaUrlsFromMarkdown("![](https://x.example/public/assets/d.webp?cache=1)", {
extractMediaUrlsFromMarkdown("![](https://x.example/public/upload/d.webp?cache=1)", {
allowedAssetOrigins: ["https://x.example"],
}),
).toEqual(["/public/assets/d.webp"]);
).toEqual(["/public/upload/d.webp"]);
});
test("ignores non-asset absolute URLs", () => {
@ -49,6 +49,6 @@ describe("extractMediaUrlsFromMarkdown", () => {
describe("publicAssetUrlToStorageKey", () => {
test("maps normalized path to storage key", () => {
expect(publicAssetUrlToStorageKey("/public/assets/z.webp")).toBe("z.webp");
expect(publicAssetUrlToStorageKey("/public/upload/z.webp")).toBe("z.webp");
});
});

6
server/utils/post-media-urls.ts

@ -5,7 +5,7 @@ const MD_IMG_RE = /!\[[^\]]*]\(([^)]+)\)/g;
export type MergePostMediaUrlsOptions = {
/**
* origin URL `origin`
* URL `/public/assets/...`
* URL `/public/upload/...`
*/
allowedAssetOrigins?: readonly string[];
};
@ -33,7 +33,7 @@ function buildAllowedOriginSet(entries: readonly string[] | undefined): Set<stri
return s;
}
/** 统一为 `/public/assets/<key>`,供 `publicAssetUrlToStorageKey` 与引用同步使用。 */
/** 统一为 `/public/upload/<key>`,供 `publicAssetUrlToStorageKey` 与引用同步使用。 */
function normalizeUrl(raw: string, allowedOrigins: Set<string>): string {
const t = raw.trim().replace(/^<|>$/g, "").split(/\s+/)[0] ?? "";
if (t.startsWith(POST_MEDIA_PUBLIC_PREFIX)) {
@ -111,7 +111,7 @@ export function mergeProfileMediaUrls(
return mergePostMediaUrls(bioMarkdown ?? "", avatar ?? null, options);
}
/** `/public/assets/foo.webp` → `foo.webp` */
/** `/public/upload/foo.webp` → `foo.webp` */
export function publicAssetUrlToStorageKey(url: string): string | null {
const t = url.trim();
if (!t.startsWith(POST_MEDIA_PUBLIC_PREFIX)) {

Loading…
Cancel
Save