@bagelink/vue 1.18.3 → 1.18.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/utils/linkPolicy.d.ts +11 -0
- package/dist/components/form/inputs/RichText/utils/linkPolicy.d.ts.map +1 -0
- package/dist/components/lightbox/AssetPreview.test.d.ts +2 -0
- package/dist/components/lightbox/AssetPreview.test.d.ts.map +1 -0
- package/dist/components/lightbox/AssetPreview.vue.d.ts +2 -0
- package/dist/components/lightbox/AssetPreview.vue.d.ts.map +1 -1
- package/dist/core.css +1 -1
- package/dist/index.cjs +41 -41
- package/dist/index.css +1 -1
- package/dist/index.mjs +6140 -6098
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/form/inputs/DateInput.vue +1 -1
- package/src/components/form/inputs/RichText/index.vue +16 -28
- package/src/components/form/inputs/RichText/utils/linkPolicy.ts +48 -0
- package/src/components/layout/AppContent.vue +1 -1
- package/src/components/lightbox/AssetPreview.test.ts +37 -0
- package/src/components/lightbox/AssetPreview.vue +44 -3
package/package.json
CHANGED
|
@@ -335,7 +335,7 @@ onMounted(() => {
|
|
|
335
335
|
<template #trigger>
|
|
336
336
|
<div ref="datePickerRef" class="date-picker-container">
|
|
337
337
|
<TextInput
|
|
338
|
-
v-model="inputValue" iconStart="
|
|
338
|
+
v-model="inputValue" iconStart="calendar_month"
|
|
339
339
|
:placeholder="underlined ? ' ' : (resolveI18n(placeholder) || defaultPlaceholder)"
|
|
340
340
|
:required="underlined ? false : required" :disabled="!isEditable"
|
|
341
341
|
:error="!isValid ? $t('date.invalidFormat') : error"
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import type { BagelInputShellProps } from '../bagelInputShell'
|
|
31
31
|
import { useBagelInputShell } from '../bagelInputShell'
|
|
32
32
|
import { RICH_TEXT_EXTENSION_REGISTRY_KEY } from './richTextExtensions'
|
|
33
|
+
import { normalizeRichTextLink, sanitizeRichTextLinks } from './utils/linkPolicy'
|
|
33
34
|
|
|
34
35
|
// Disable automatic inheritance of non-prop attributes
|
|
35
36
|
defineOptions({
|
|
@@ -696,7 +697,7 @@ function showTooltipMessage(message: string, x?: number, y?: number) {
|
|
|
696
697
|
function openLinkModal(selection: Selection, range: Range, existingLink: HTMLAnchorElement | null) {
|
|
697
698
|
pendingLinkData = { selection, range, existingLink }
|
|
698
699
|
linkForm.value = {
|
|
699
|
-
url: existingLink?.href || '',
|
|
700
|
+
url: existingLink?.getAttribute('href') || '',
|
|
700
701
|
title: existingLink?.getAttribute('title') || '',
|
|
701
702
|
openInNewTab: existingLink?.target !== '_self'
|
|
702
703
|
}
|
|
@@ -705,17 +706,19 @@ function openLinkModal(selection: Selection, range: Range, existingLink: HTMLAnc
|
|
|
705
706
|
|
|
706
707
|
// Function to submit link
|
|
707
708
|
function submitLink() {
|
|
708
|
-
|
|
709
|
+
const normalizedUrl = normalizeRichTextLink(linkForm.value.url)
|
|
710
|
+
if (!pendingLinkData || !normalizedUrl) { return }
|
|
709
711
|
|
|
710
712
|
const { selection, range, existingLink } = pendingLinkData
|
|
711
|
-
const {
|
|
713
|
+
const { title, openInNewTab } = linkForm.value
|
|
712
714
|
const { doc } = editor.state
|
|
713
715
|
|
|
714
716
|
if (!doc) { return }
|
|
717
|
+
linkForm.value.url = normalizedUrl
|
|
715
718
|
|
|
716
719
|
if (existingLink) {
|
|
717
720
|
// Update existing link
|
|
718
|
-
existingLink.href
|
|
721
|
+
existingLink.setAttribute('href', normalizedUrl)
|
|
719
722
|
if (title.trim()) {
|
|
720
723
|
existingLink.title = title.trim()
|
|
721
724
|
} else {
|
|
@@ -731,7 +734,7 @@ function submitLink() {
|
|
|
731
734
|
// Create new link
|
|
732
735
|
try {
|
|
733
736
|
const anchor = doc.createElement('a')
|
|
734
|
-
anchor.href
|
|
737
|
+
anchor.setAttribute('href', normalizedUrl)
|
|
735
738
|
if (title.trim()) {
|
|
736
739
|
anchor.title = title.trim()
|
|
737
740
|
}
|
|
@@ -766,14 +769,8 @@ function submitLink() {
|
|
|
766
769
|
|
|
767
770
|
// Function to visit link
|
|
768
771
|
function visitLink() {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
let url = linkForm.value.url.trim()
|
|
772
|
-
|
|
773
|
-
// Add https:// if no protocol is specified
|
|
774
|
-
if (!url.match(/^https?:\/\//)) {
|
|
775
|
-
url = `https://${url}`
|
|
776
|
-
}
|
|
772
|
+
const url = normalizeRichTextLink(linkForm.value.url)
|
|
773
|
+
if (!url) { return }
|
|
777
774
|
|
|
778
775
|
if (linkForm.value.openInNewTab) {
|
|
779
776
|
window.open(url, '_blank', 'noopener,noreferrer')
|
|
@@ -784,20 +781,7 @@ function visitLink() {
|
|
|
784
781
|
|
|
785
782
|
// Function to validate URL
|
|
786
783
|
function isValidUrl(url: string): boolean {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
try {
|
|
790
|
-
URL.canParse(url)
|
|
791
|
-
return true
|
|
792
|
-
} catch {
|
|
793
|
-
// Try with https:// prefix if it's missing
|
|
794
|
-
try {
|
|
795
|
-
URL.canParse(`https://${url}`)
|
|
796
|
-
return true
|
|
797
|
-
} catch {
|
|
798
|
-
return false
|
|
799
|
-
}
|
|
800
|
-
}
|
|
784
|
+
return normalizeRichTextLink(url) !== null
|
|
801
785
|
}
|
|
802
786
|
|
|
803
787
|
// Function to open image modal
|
|
@@ -931,7 +915,7 @@ function submitImage() {
|
|
|
931
915
|
|
|
932
916
|
// Wrap the image in an anchor when a link is provided, otherwise return the bare image
|
|
933
917
|
function createImageContent(): HTMLElement {
|
|
934
|
-
const link = imageForm.value.link
|
|
918
|
+
const link = normalizeRichTextLink(imageForm.value.link)
|
|
935
919
|
if (!link) { return img }
|
|
936
920
|
|
|
937
921
|
const anchor = doc!.createElement('a')
|
|
@@ -2001,6 +1985,10 @@ function getCleanHTML(doc: Document | null): string {
|
|
|
2001
1985
|
if (fig.classList.length === 0) { fig.removeAttribute('class') }
|
|
2002
1986
|
})
|
|
2003
1987
|
|
|
1988
|
+
// Keep imported/pasted legacy links from leaking into saved output. Invalid
|
|
1989
|
+
// destinations lose their href, but their visible content is preserved.
|
|
1990
|
+
sanitizeRichTextLinks(clone)
|
|
1991
|
+
|
|
2004
1992
|
const html = clone.innerHTML
|
|
2005
1993
|
if (!clone.textContent?.trim() && !clone.querySelector('img, video, iframe, table, hr, figure')) {
|
|
2006
1994
|
return ''
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const EXPLICIT_SAFE_URL = /^(?:https?:\/\/|mailto:|tel:)/i
|
|
2
|
+
const BARE_DOMAIN = /^(?:localhost(?::\d+)?|(?:[a-z0-9-]+\.)+[a-z]{2,})(?:[/:?#][^\s]*)?$/i
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate and normalize a link authored in RichText.
|
|
6
|
+
*
|
|
7
|
+
* Relative application paths must be root-relative (`/contact`). Ambiguous
|
|
8
|
+
* imported CMS links (`edit?...`, `admin/...`) are rejected instead of being
|
|
9
|
+
* silently resolved against the current page.
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeRichTextLink(value: unknown): string | null {
|
|
12
|
+
if (typeof value !== 'string') return null
|
|
13
|
+
const url = value.trim()
|
|
14
|
+
if (!url || /[\u0000-\u001F\u007F]/.test(url)) return null
|
|
15
|
+
|
|
16
|
+
if (url.startsWith('/') && !url.startsWith('//')) return url
|
|
17
|
+
if (url.startsWith('#') && url.length > 1) return url
|
|
18
|
+
|
|
19
|
+
if (EXPLICIT_SAFE_URL.test(url)) {
|
|
20
|
+
if (/^mailto:/i.test(url)) return url.slice(7).trim() ? url : null
|
|
21
|
+
if (/^tel:/i.test(url)) return url.slice(4).trim() ? url : null
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(url)
|
|
24
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? url : null
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return BARE_DOMAIN.test(url) ? `https://${url}` : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Remove invalid destinations while preserving their visible linked content. */
|
|
34
|
+
export function sanitizeRichTextLinks(root: ParentNode): number {
|
|
35
|
+
let removed = 0
|
|
36
|
+
for (const anchor of Array.from(root.querySelectorAll<HTMLAnchorElement>('a[href]'))) {
|
|
37
|
+
const normalized = normalizeRichTextLink(anchor.getAttribute('href'))
|
|
38
|
+
if (normalized) {
|
|
39
|
+
anchor.setAttribute('href', normalized)
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
anchor.removeAttribute('href')
|
|
43
|
+
anchor.removeAttribute('target')
|
|
44
|
+
anchor.removeAttribute('rel')
|
|
45
|
+
removed++
|
|
46
|
+
}
|
|
47
|
+
return removed
|
|
48
|
+
}
|
|
@@ -329,7 +329,7 @@ flex-shrink: 0;
|
|
|
329
329
|
box-sizing: border-box;
|
|
330
330
|
min-height: var(--bgl-app-header-h, 60px);
|
|
331
331
|
gap: 0.5rem 0.75rem;
|
|
332
|
-
padding-inline:
|
|
332
|
+
padding-inline: var(--bgl-content-padding, 1rem);
|
|
333
333
|
padding-block: 0.5rem;
|
|
334
334
|
flex-wrap: wrap;
|
|
335
335
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { mount } from '@vue/test-utils'
|
|
4
|
+
import { nextTick } from 'vue'
|
|
5
|
+
import { createI18n } from '../../i18n'
|
|
6
|
+
import AssetPreview from './AssetPreview.vue'
|
|
7
|
+
|
|
8
|
+
createI18n()
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.restoreAllMocks()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('AssetPreview protected delivery', () => {
|
|
15
|
+
it('uses the provided authenticated loader and revokes its object URL', async () => {
|
|
16
|
+
const createObjectURL = vi.fn(() => 'blob:protected')
|
|
17
|
+
const revokeObjectURL = vi.fn()
|
|
18
|
+
Object.defineProperties(URL, {
|
|
19
|
+
createObjectURL: { configurable: true, value: createObjectURL },
|
|
20
|
+
revokeObjectURL: { configurable: true, value: revokeObjectURL },
|
|
21
|
+
})
|
|
22
|
+
const load = vi.fn(async () => new Blob(['protected']))
|
|
23
|
+
const wrapper = mount(AssetPreview, {
|
|
24
|
+
props: { asset: { asset_id: 'asset-1' }, type: 'file', load },
|
|
25
|
+
global: { stubs: { BglBtn: { props: ['href'], template: '<a :href="href"><slot /></a>' } } },
|
|
26
|
+
})
|
|
27
|
+
await nextTick()
|
|
28
|
+
await nextTick()
|
|
29
|
+
|
|
30
|
+
expect(load).toHaveBeenCalledWith({ asset_id: 'asset-1' })
|
|
31
|
+
expect(createObjectURL).toHaveBeenCalledOnce()
|
|
32
|
+
expect(wrapper.get('a').attributes('href')).toBe('blob:protected')
|
|
33
|
+
|
|
34
|
+
wrapper.unmount()
|
|
35
|
+
expect(revokeObjectURL).toHaveBeenCalledWith('blob:protected')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -1,13 +1,54 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { useAssetDelivery, type AssetReference } from '../../assets'
|
|
3
3
|
import { Btn, Image } from '../../index'
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import { computed, onUnmounted, ref, watch } from 'vue'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
asset: AssetReference
|
|
8
|
+
type: string
|
|
9
|
+
name?: string
|
|
10
|
+
/** Authenticated byte loader for protected assets. Public assets omit it. */
|
|
11
|
+
load?: (asset: AssetReference) => Promise<Blob>
|
|
12
|
+
}>()
|
|
13
|
+
const publicDelivery = useAssetDelivery(() => props.load ? undefined : props.asset)
|
|
14
|
+
const protectedUrl = ref<string>()
|
|
15
|
+
const protectedError = ref(false)
|
|
16
|
+
let generation = 0
|
|
17
|
+
|
|
18
|
+
function clearProtectedUrl() {
|
|
19
|
+
if (protectedUrl.value) URL.revokeObjectURL(protectedUrl.value)
|
|
20
|
+
protectedUrl.value = undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
watch(() => [props.asset.asset_id, props.load] as const, async () => {
|
|
24
|
+
const current = ++generation
|
|
25
|
+
clearProtectedUrl()
|
|
26
|
+
protectedError.value = false
|
|
27
|
+
if (!props.load) return
|
|
28
|
+
try {
|
|
29
|
+
const blob = await props.load(props.asset)
|
|
30
|
+
if (current === generation) protectedUrl.value = URL.createObjectURL(blob)
|
|
31
|
+
} catch {
|
|
32
|
+
if (current === generation) protectedError.value = true
|
|
33
|
+
}
|
|
34
|
+
}, { immediate: true })
|
|
35
|
+
|
|
36
|
+
onUnmounted(() => {
|
|
37
|
+
generation++
|
|
38
|
+
clearProtectedUrl()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const url = computed(() => protectedUrl.value ?? publicDelivery.url.value)
|
|
42
|
+
const error = computed(() => protectedError.value || publicDelivery.error.value)
|
|
43
|
+
const onError = () => {
|
|
44
|
+
if (props.load) protectedError.value = true
|
|
45
|
+
else publicDelivery.onError()
|
|
46
|
+
}
|
|
6
47
|
</script>
|
|
7
48
|
|
|
8
49
|
<template>
|
|
9
50
|
<div class="flex column gap-1">
|
|
10
|
-
<Image v-if="type === 'image'" :src="asset" />
|
|
51
|
+
<Image v-if="type === 'image' && (!load || url)" :src="load ? url : asset" @error="onError" />
|
|
11
52
|
<video v-else-if="type === 'video' && url" :src="url" controls @error="onError" />
|
|
12
53
|
<iframe v-else-if="type === 'pdf' && url" :src="url" :title="name || 'PDF preview'" referrerpolicy="no-referrer" />
|
|
13
54
|
<Btn v-if="url" :href="url" target="_blank" rel="noopener noreferrer" value="Open / download" />
|