Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 162 additions & 30 deletions docs/dependency-upgrade-plan.md

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions nuxt-app/components/ConferenceCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
<!-- Description -->
<p
class="mt-6 line-clamp-4 space-y-8 text-base font-light leading-normal text-white md:text-xl lg:text-2xl"
v-html="description"
/>
>
{{ description }}
</p>

<!-- Likes -->
<LinkButton class="mt-6" :href="href">Mehr Infos</LinkButton>
Expand All @@ -31,7 +32,8 @@
<script lang="ts">
import type { PropType } from 'vue'
import { computed, defineComponent } from 'vue'
import type { ConferenceItem, MeetupItem } from '../types';
import { getPlainText } from '~/helpers/sanitize'
import type { ConferenceItem } from '../types'
import LinkButton from './LinkButton.vue'
import ConferenceCover from './ConferenceCover.vue'
import MeetupStartAndEnd from './MeetupStartAndEnd.vue'
Expand All @@ -54,8 +56,7 @@ export default defineComponent({
// Create href to meetup subpage
const href = computed(() => `/konferenz/${props.conference.slug}`)

// Create plain description text
const description = computed(() => props.conference.text_1?.replace(/<[^<>]+>/g, ''))
const description = computed(() => getPlainText(props.conference.text_1))

return {
href,
Expand Down
6 changes: 3 additions & 3 deletions nuxt-app/components/InnerHtml.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<template>
<!-- we can safely disable this eslint rule since we sanitize is with DOMPurify before -->
<!-- v-html is intended here: this component exists to render CMS rich text, sanitised first. -->
<div class="inner-html" :class="variantClass" v-html="sanitizedHtml" />
</template>

<script lang="ts">
import DOMPurify from 'isomorphic-dompurify'
import { sanitizeHtml } from '~/helpers/sanitize'
import type { PropType } from 'vue'
import { computed, defineComponent } from 'vue'

Expand All @@ -22,7 +22,7 @@ export default defineComponent({
setup(props) {
// Convert variant to class string
const variantClass = computed(() => props.variant.replace(/_/g, '-'))
const sanitizedHtml = computed(() => DOMPurify.sanitize(props.html))
const sanitizedHtml = computed(() => sanitizeHtml(props.html))

return {
variantClass,
Expand Down
9 changes: 5 additions & 4 deletions nuxt-app/components/MeetupCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@
<!-- Description -->
<p
class="mt-6 line-clamp-4 space-y-8 text-base font-light leading-normal text-white md:text-xl lg:text-2xl"
v-html="description"
/>
>
{{ description }}
</p>

<!-- Likes -->
<LinkButton class="mt-6" :href="href">Mehr Infos</LinkButton>
Expand All @@ -30,6 +31,7 @@
</template>

<script lang="ts">
import { getPlainText } from '~/helpers/sanitize'
import type { PropType } from 'vue'
import { computed, defineComponent } from 'vue'
import type { MeetupItem } from '../types'
Expand Down Expand Up @@ -59,8 +61,7 @@ export default defineComponent({
// Create href to meetup subpage
const href = computed(() => `/meetup/${props.meetup.slug}`)

// Create plain description text
const description = computed(() => props.meetup.description.replace(/<[^<>]+>/g, ''))
const description = computed(() => getPlainText(props.meetup.description))

return {
href,
Expand Down
4 changes: 2 additions & 2 deletions nuxt-app/components/NewsTicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<script lang="ts">
import type { PropType } from 'vue'
import { computed, defineComponent } from 'vue'
import DOMPurify from 'isomorphic-dompurify';
import { sanitizeInlineHtml } from '~/helpers/sanitize';

export default defineComponent({
props: {
Expand All @@ -28,7 +28,7 @@ export default defineComponent({
// Create style with animation duration
const style = computed(() => `animation-duration: ${props.news.join().length * 0.2}s`)
const preparedNewsItems = computed(() => {
return props.news.map(news => `${DOMPurify.sanitize(news, {FORBID_TAGS: ['p']})} +++ `)
return props.news.map(news => `${sanitizeInlineHtml(news)} +++ `)
})

return {
Expand Down
10 changes: 5 additions & 5 deletions nuxt-app/components/PickOfTheDayListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,17 @@
</h3>
<LeaveSiteIcon class="-mt-1 h-4 lg:h-5 xl:h-6" />
</div>
<p
class="mt-2 space-y-8 text-base font-light leading-normal md:text-xl lg:text-2xl"
v-html="description"
/>
<p class="mt-2 space-y-8 text-base font-light leading-normal md:text-xl lg:text-2xl">
{{ description }}
</p>
</div>
</a>
</li>
</template>

<script setup lang="ts">
import LeaveSiteIcon from '~/assets/icons/leave-site.svg'
import { getPlainText } from '~/helpers/sanitize'
import type { PickOfTheDayItem } from '~/types'
import { computed } from 'vue'
import DirectusImage from './DirectusImage.vue'
Expand All @@ -49,5 +49,5 @@ const props = defineProps<{
pickOfTheDay: Pick<PickOfTheDayItem, 'name' | 'website_url' | 'description' | 'image'>
}>()

const description = computed(() => props.pickOfTheDay.description.replace(/<[^<>]+>/g, ''))
const description = computed(() => getPlainText(props.pickOfTheDay.description))
</script>
13 changes: 0 additions & 13 deletions nuxt-app/components/PodcastPlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,6 @@
:class="isExpanded ? 'pointer-events-none invisible opacity-0' : 'delay-200 duration-500'"
:style="isExpanded ? 'transition: visibility 0s .15s, opacity .15s' : undefined"
>
<!-- <button
class="h-6 text-pink"
type="button"
data-cursor-hover
@click.stop=""
v-html="require('../assets/icons/heart.svg?raw')"
/> -->
<div class="flex h-full w-8 justify-center">
<button
v-if="podcastPlayer.paused"
Expand Down Expand Up @@ -168,12 +161,6 @@
:class="!isExpanded && 'invisible xl:visible'"
:style="!isExpanded ? 'transition: visibility 0s 0.3s' : undefined"
>
<!-- <button
class="h-6 text-pink"
type="button"
data-cursor-hover
v-html="require('../assets/icons/heart.svg?raw')"
/> -->
<div class="flex space-x-8">
<button
v-if="clipboard.isSupported || share.isSupported"
Expand Down
10 changes: 8 additions & 2 deletions nuxt-app/components/ProfileCreationDone.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
<script setup lang="ts">
defineProps<{
import { sanitizeHtml } from '~/helpers/sanitize'
import { computed } from 'vue'

const props = defineProps<{
mainText: string
}>()

// CMS rich text, so it keeps its markup — but it must be sanitised before reaching v-html.
const sanitizedMainText = computed(() => sanitizeHtml(props.mainText))
</script>

<template>
<div class="profile-creation-done mt-20 flex flex-col items-center justify-center">
<div class="flex-col items-center justify-center md:flex">
<div
class="mb-2 mt-5 text-3xl font-semibold italic leading-[3rem] text-white md:text-4xl"
v-html="mainText"
v-html="sanitizedMainText"
/>
</div>

Expand Down
8 changes: 6 additions & 2 deletions nuxt-app/components/ProfileCreationMainInfos.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
<script setup lang="ts">
import ProfilePicture from '~/components/ProfilePicture.vue'
import { useProfileCreationStore } from '~/composables/useProfileCreationStore'
import { sanitizeHtml } from '~/helpers/sanitize'
import { computed, ref, watch } from 'vue'

defineProps<{
const props = defineProps<{
heading: string
introText: string
}>()

// CMS rich text, so it keeps its markup — but it must be sanitised before reaching v-html.
const sanitizedIntroText = computed(() => sanitizeHtml(props.introText))

const emit = defineEmits(['validityChange'])
const store = useProfileCreationStore()
const { mainInfos } = storeToRefs(store)
Expand Down Expand Up @@ -40,7 +44,7 @@ function updateLastName(value: string) {
</div>
</div>

<div class="intro-text mb-2 mt-5 text-base font-light text-white md:text-4xl" v-html="introText"></div>
<div class="intro-text mb-2 mt-5 text-base font-light text-white md:text-4xl" v-html="sanitizedIntroText"></div>
<ProfilePicture class="mb-10" />
<div class="flex w-full flex-col items-center justify-center">
<InputFieldWithHeadline
Expand Down
16 changes: 9 additions & 7 deletions nuxt-app/components/SearchResultCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,17 @@
<!-- Description -->
<p
class="mt-4 line-clamp-4 text-sm font-light leading-normal text-white md:text-lg lg:mt-8 lg:text-xl lg:leading-relaxed"
v-html="viewModel.description"
/>
>
{{ viewModel.description }}
</p>
</div>
</nuxt-link>
</div>
</template>

<script setup lang="ts">
import LeaveSiteIcon from '~/assets/icons/leave-site.svg'
import { getPlainText } from '~/helpers/sanitize'
import { getFullPodcastTitle, getFullSpeakerName } from 'shared-code'
import { computed } from 'vue'

Expand Down Expand Up @@ -86,35 +88,35 @@ const viewModel = computed(() => {
`/podcast/${props.item.slug}`,
getFullPodcastTitle(props.item) || "",
"Podcast // " + publishDate,
props.item.description?.replace(/<[^<>]+>/g, "") || "",
getPlainText(props.item.description),
)
case "transcript":
return new ViewModel(
`/podcast/${props.item.slug}`,
getFullPodcastTitle(props.item) || "",
"Podcast // " + publishDate,
props.item.transcript?.replace(/<[^<>]+>/g, "") || "",
getPlainText(props.item.transcript),
)
case "meetup":
return new ViewModel(
`/meetup/${props.item.slug}`,
props.item.title || "",
"Meetup // " + publishDate,
props.item.description?.replace(/<[^<>]+>/g, "") || "",
getPlainText(props.item.description),
)
case "speaker":
return new ViewModel(
`/hall-of-fame/${props.item.slug}`,
getFullSpeakerName(props.item) || "",
"Speaker // " + publishDate,
props.item.description?.replace(/<[^<>]+>/g, "") || "",
getPlainText(props.item.description),
)
case "pick_of_the_day":
return new ViewModel(
props.item.website_url,
props.item.name || "",
"Pick of the Day // " + publishDate,
props.item.description?.replace(/<[^<>]+>/g, "") || "",
getPlainText(props.item.description),
true,
)
default:
Expand Down
9 changes: 5 additions & 4 deletions nuxt-app/components/SpeakerListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ class="mt-10 md:mt-0"
<!-- Description -->
<p
class="mt-5 line-clamp-4 space-y-8 text-base font-light leading-normal text-white md:mt-10 md:text-xl lg:text-2xl"
v-html="description"
/>
>
{{ description }}
</p>

<!-- Link -->
<LinkButton class="mt-6" :href="href">Mehr Infos</LinkButton>
Expand All @@ -42,6 +43,7 @@ class="mt-10 md:mt-0"
import { getFullSpeakerName } from 'shared-code'
import type { PropType } from 'vue'
import { computed, defineComponent } from 'vue'
import { getPlainText } from '~/helpers/sanitize'
import type { SpeakerItem } from '../types'
import DirectusImage from './DirectusImage.vue'
import LinkButton from './LinkButton.vue'
Expand All @@ -66,8 +68,7 @@ export default defineComponent({
// Create get full name function
const fullName = computed(() => getFullSpeakerName(props.speaker))

// Create plain description text
const description = computed(() => props.speaker.description.replace(/<[^<>]+>/g, ''))
const description = computed(() => getPlainText(props.speaker.description))

// Create href to speaker's subpage
const href = computed(() => `/hall-of-fame/${props.speaker.slug}`)
Expand Down
60 changes: 60 additions & 0 deletions nuxt-app/helpers/sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import DOMPurify from 'isomorphic-dompurify'

// The single place that decides how untrusted CMS text is made safe to render. Every `v-html` binding
// and every plain-text excerpt in the app goes through one of these, so a policy change — tightening
// the allowed tags, swapping the sanitiser — happens here rather than in each component.
//
// Deliberately not re-exported from `helpers/index.ts`: that barrel is imported by server routes, and
// pulling isomorphic-dompurify in through it would instantiate jsdom for consumers that only wanted a
// date helper. Import this module directly.

/**
* Sanitises CMS rich text for rendering as HTML.
*
* Use this for anything bound to `v-html`. Keeps the markup editors legitimately produce — headings,
* emphasis, links, styled spans — and removes scripts, event handlers and unsafe URLs.
*/
export function sanitizeHtml(html: string | null | undefined): string {
if (!html) {
return ''
}

return DOMPurify.sanitize(html)
}

/**
* Like {@link sanitizeHtml}, but for text rendered inline, where a block element would break the
* layout. Used by the news ticker, whose items sit inside a single scrolling line.
*/
export function sanitizeInlineHtml(html: string | null | undefined): string {
if (!html) {
return ''
}

return DOMPurify.sanitize(html, { FORBID_TAGS: ['p'] })
}

/**
* Reduces CMS rich text to plain text.
*
* For card excerpts and search results, which want a snippet of prose rather than markup. Returns
* real text, not an HTML string — every entity is decoded (`f&uuml;r` becomes `für`, `&amp;` becomes
* `&`), so the result is safe to render with `{{ }}` and must not be passed to `v-html`.
*
* Parses rather than pattern-matches on purpose. Stripping tags with a regex such as
* `/<[^<>]+>/g` cannot match a tag that contains `<` or `>`, so `<img<a> src=x onerror=alert(1)>`
* survives it as a working tag.
*/
export function getPlainText(html: string | null | undefined): string {
if (!html) {
return ''
}

const fragment = DOMPurify.sanitize(html, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
RETURN_DOM_FRAGMENT: true,
})

return fragment.textContent ?? ''
}
Loading
Loading