The three-regex gate could be bypassed (an attribute without a space, <svg/onload>, entity-encoded or tab-split javascript: URLs, MathML xlink:href, base, meta refresh, forms) and it blanked a whole post or category when ordinary text such as "Data: 12 maja 2024" matched. - sanitizeJournalHtml runs sanitize-html with the Journal plugin's bluemonday policy: UGC elements plus footer, class, schema.org microdata, four validated inline styles, footnote roles, dir=auto, two chat transcript data attributes; http, https, mailto and relative URLs only - gateJournalHtml and isSafeJournalHtml keep their signatures; html is always the sanitized value and is never blanked - post body, category content and both page payloads are sanitized; the blocked notice, its style and string, and the blocked props and content_blocked field are removed - toPostView moves to postView.ts so list pages and cards do not load the sanitizer - tests cover every bypass from the review, the Data:/metadata: text and an imported author footer; the SSR smoke expects the unsafe sample's safe paragraphs
73 lines
2.8 KiB
Vue
73 lines
2.8 KiB
Vue
<script setup lang="ts">
|
|
// journal-post: one published post by slug. An unknown, draft or future post
|
|
// is a real 404. The transform sanitizes the body before it reaches the payload.
|
|
// Related posts fall back to the latest posts when the API has none (it
|
|
// returns none for a post without a category).
|
|
import { postSeoMeta } from '../utils/seo.ts'
|
|
import { toPostView } from '../utils/postView.ts'
|
|
import { toListPost } from '../utils/view.ts'
|
|
import type { JournalModuleOptions } from '../utils/types.ts'
|
|
|
|
const route = useRoute()
|
|
const api = useJournalApi()
|
|
const links = useJournalLinks()
|
|
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
|
const slug = computed(() => String(route.params.slug ?? ''))
|
|
|
|
const { data: post, error } = await useAsyncData(
|
|
() => `journal-post-${slug.value}`,
|
|
async () => {
|
|
const found = await api.getPost(slug.value)
|
|
if (!found) return null
|
|
const count = Math.max(0, cfg.relatedCount)
|
|
let related = found.related_posts ?? []
|
|
if (related.length === 0 && count > 0) {
|
|
const latest = await api.listPosts({ perPage: count + 1, sort: cfg.sort })
|
|
related = latest.data.filter((p) => p.slug !== found.slug)
|
|
}
|
|
return { ...found, related_posts: related.slice(0, count).map(toListPost) }
|
|
},
|
|
{ transform: (p) => (p ? toPostView(p) : null) },
|
|
)
|
|
|
|
if (error.value) {
|
|
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
|
|
}
|
|
if (!post.value) {
|
|
throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true })
|
|
}
|
|
|
|
useSeoMeta(postSeoMeta(post.value, useSiteConfig().url || useRequestURL().origin))
|
|
</script>
|
|
|
|
<template>
|
|
<article v-if="post" class="journal-post">
|
|
<header class="journal-post__header">
|
|
<h1 class="journal-post__title">{{ post.title }}</h1>
|
|
<JournalPostMeta :post="post" />
|
|
</header>
|
|
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" eager />
|
|
<JournalPostBody :html="post.content_html" />
|
|
<nav v-if="post.previous_post || post.next_post" class="journal-post__siblings">
|
|
<NuxtLink
|
|
v-if="post.previous_post && links.postLink(post.previous_post.slug)"
|
|
:to="links.postLink(post.previous_post.slug)!"
|
|
rel="prev"
|
|
class="journal-post__previous"
|
|
>
|
|
<span>{{ $t('journal.post.previous') }}</span> {{ post.previous_post.title }}
|
|
</NuxtLink>
|
|
<NuxtLink
|
|
v-if="post.next_post && links.postLink(post.next_post.slug)"
|
|
:to="links.postLink(post.next_post.slug)!"
|
|
rel="next"
|
|
class="journal-post__next"
|
|
>
|
|
<span>{{ $t('journal.post.next') }}</span> {{ post.next_post.title }}
|
|
</NuxtLink>
|
|
</nav>
|
|
<JournalRelatedPosts :posts="post.related_posts" />
|
|
<JournalRssLink />
|
|
</article>
|
|
</template>
|