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
54 lines
2.5 KiB
Vue
54 lines
2.5 KiB
Vue
<script setup lang="ts">
|
|
// journal-category: one category's header and rich content above its posts.
|
|
// The slug resolves against GET /categories?include_empty=1, so an empty
|
|
// category answers 200 with its content; an unknown slug is a real 404 (this
|
|
// route may be a root-level catch-all in the app).
|
|
import { sanitizeJournalHtml } from '../utils/htmlGate.ts'
|
|
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
|
|
import { listTitle } from '../utils/seo.ts'
|
|
import { toListPost } from '../utils/view.ts'
|
|
import type { JournalModuleOptions } from '../utils/types.ts'
|
|
|
|
definePageMeta({ key: (route) => route.fullPath })
|
|
|
|
const route = useRoute()
|
|
const api = useJournalApi()
|
|
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
|
const { t } = useI18n()
|
|
const slug = String(route.params.category ?? '')
|
|
|
|
const page = parsePageParam(route.query.page)
|
|
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
|
|
|
const { data, error } = await useAsyncData(`journal-category-${slug}-${page}`, async () => {
|
|
const found = await api.findCategory(slug)
|
|
if (!found) return null
|
|
const posts = await api.listPosts({ category: found.slug, page, perPage: cfg.perPage.category, sort: cfg.sort })
|
|
// Only this category reaches the payload, with its content already sanitized.
|
|
const { children: _children, ...category } = found
|
|
return {
|
|
category: { ...category, content: sanitizeJournalHtml(found.content) || null },
|
|
posts: { ...posts, data: posts.data.map(toListPost) },
|
|
}
|
|
})
|
|
if (error.value) {
|
|
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
|
|
}
|
|
if (!data.value) throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
|
if (pageOutOfRange(page, data.value.posts.meta.last_page)) {
|
|
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
|
}
|
|
|
|
const title = listTitle(data.value.category.name, page, t('journal.pagination.pageTitle', { page }))
|
|
const description = data.value.category.description ?? undefined
|
|
useSeoMeta({ title, ogTitle: title, description, ogDescription: description })
|
|
</script>
|
|
|
|
<template>
|
|
<section v-if="data" class="journal-category">
|
|
<JournalCategoryHeader :category="data.category" :page="page" />
|
|
<JournalPostList :posts="data.posts.data" :empty="$t('journal.category.empty')" />
|
|
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />
|
|
</section>
|
|
</template>
|