@meistrari/tela-build 1.16.2 → 1.16.3
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.
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script setup lang=ts>
|
|
2
|
+
import { sanitizeAvatarImageUrl } from '../../../utils/avatar'
|
|
3
|
+
|
|
2
4
|
export type AvatarSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
3
5
|
|
|
4
6
|
const props = defineProps<{
|
|
@@ -44,6 +46,8 @@ const style = computed(() => ({
|
|
|
44
46
|
width: `${sizes[resolvedSize.value].width}px`,
|
|
45
47
|
height: `${sizes[resolvedSize.value].height}px`,
|
|
46
48
|
}))
|
|
49
|
+
const safeImage = computed(() => sanitizeAvatarImageUrl(props.image))
|
|
50
|
+
|
|
47
51
|
watch(() => props.image, () => {
|
|
48
52
|
errorLoadingImage.value = false
|
|
49
53
|
})
|
|
@@ -58,10 +62,10 @@ watch(() => props.image, () => {
|
|
|
58
62
|
flex items-center justify-center
|
|
59
63
|
:style="style"
|
|
60
64
|
>
|
|
61
|
-
<ClientOnly v-if="
|
|
65
|
+
<ClientOnly v-if="safeImage && !errorLoadingImage">
|
|
62
66
|
<img
|
|
63
67
|
:alt="alt"
|
|
64
|
-
:src="
|
|
68
|
+
:src="safeImage"
|
|
65
69
|
:width="sizes[resolvedSize].width"
|
|
66
70
|
:height="sizes[resolvedSize].height"
|
|
67
71
|
loading="lazy"
|
|
@@ -111,7 +111,7 @@ onUnmounted(() => {
|
|
|
111
111
|
cursor-pointer
|
|
112
112
|
@click="$emit('select', member)"
|
|
113
113
|
>
|
|
114
|
-
<TelaAvatar :
|
|
114
|
+
<TelaAvatar :image="member.avatarUrl" size="sm" />
|
|
115
115
|
<div flex="~ col" gap-3px text-14px>
|
|
116
116
|
<div body-12-semibold>
|
|
117
117
|
{{ member.name }}
|
package/package.json
CHANGED
package/utils/avatar.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const KNOWN_AVATAR_HOSTS = [
|
|
2
|
+
'googleusercontent.com',
|
|
3
|
+
'graph.microsoft.com',
|
|
4
|
+
'avatars.githubusercontent.com',
|
|
5
|
+
'gravatar.com',
|
|
6
|
+
]
|
|
7
|
+
const ALLOWED_BASE64_IMAGE_MIME_TYPES = [
|
|
8
|
+
'image/png',
|
|
9
|
+
'image/jpeg',
|
|
10
|
+
'image/jpg',
|
|
11
|
+
'image/webp',
|
|
12
|
+
'image/gif',
|
|
13
|
+
'image/avif',
|
|
14
|
+
]
|
|
15
|
+
const MAX_AVATAR_BYTES = 5 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
type PublicRuntimeConfig = {
|
|
18
|
+
baseUrl?: string
|
|
19
|
+
authApiUrl?: string
|
|
20
|
+
avatarBucketUrl?: string
|
|
21
|
+
avatarAllowedOrigins?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseHost(value: string): string | undefined {
|
|
25
|
+
const trimmed = value.trim()
|
|
26
|
+
if (!trimmed)
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
return new URL(trimmed).hostname.toLowerCase()
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
try {
|
|
34
|
+
return new URL(`https://${trimmed}`).hostname.toLowerCase()
|
|
35
|
+
}
|
|
36
|
+
catch {}
|
|
37
|
+
|
|
38
|
+
if (!/^[a-z0-9.-]+$/i.test(trimmed))
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
return trimmed.toLowerCase()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getPublicRuntimeConfig(): PublicRuntimeConfig {
|
|
46
|
+
if (typeof window === 'undefined')
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
const nuxtWindow = window as typeof window & {
|
|
50
|
+
__NUXT__?: {
|
|
51
|
+
config?: {
|
|
52
|
+
public?: PublicRuntimeConfig
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return nuxtWindow.__NUXT__?.config?.public ?? {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getConfiguredAvatarHosts(config: PublicRuntimeConfig): string[] {
|
|
61
|
+
const origins = [
|
|
62
|
+
config.baseUrl,
|
|
63
|
+
config.authApiUrl,
|
|
64
|
+
config.avatarBucketUrl,
|
|
65
|
+
...(config.avatarAllowedOrigins?.split(',') ?? []),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
return origins
|
|
69
|
+
.filter((origin): origin is string => Boolean(origin))
|
|
70
|
+
.map(origin => parseHost(origin))
|
|
71
|
+
.filter((host): host is string => Boolean(host))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isHostAllowed(host: string, allowedHosts: string[]): boolean {
|
|
75
|
+
const normalizedHost = host.toLowerCase()
|
|
76
|
+
|
|
77
|
+
return allowedHosts.some((allowedHost) => {
|
|
78
|
+
return normalizedHost === allowedHost || normalizedHost.endsWith(`.${allowedHost}`)
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isAllowedBase64DataImage(image: string): boolean {
|
|
83
|
+
const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/]+=*)$/i.exec(image)
|
|
84
|
+
if (!match?.[1] || !match[2])
|
|
85
|
+
return false
|
|
86
|
+
|
|
87
|
+
const mimeType = match[1].toLowerCase()
|
|
88
|
+
if (!ALLOWED_BASE64_IMAGE_MIME_TYPES.includes(mimeType))
|
|
89
|
+
return false
|
|
90
|
+
|
|
91
|
+
const payload = match[2]
|
|
92
|
+
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0
|
|
93
|
+
const estimatedBytes = Math.floor((payload.length * 3) / 4) - padding
|
|
94
|
+
|
|
95
|
+
return estimatedBytes > 0 && estimatedBytes <= MAX_AVATAR_BYTES
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function sanitizeAvatarImageUrl(image?: string): string | undefined {
|
|
99
|
+
if (!image)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
const trimmedImage = image.trim()
|
|
103
|
+
if (!trimmedImage)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
if (isAllowedBase64DataImage(trimmedImage))
|
|
107
|
+
return trimmedImage
|
|
108
|
+
|
|
109
|
+
if (trimmedImage.startsWith('/'))
|
|
110
|
+
return trimmedImage
|
|
111
|
+
|
|
112
|
+
let parsedUrl: URL
|
|
113
|
+
try {
|
|
114
|
+
parsedUrl = new URL(trimmedImage)
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!['http:', 'https:'].includes(parsedUrl.protocol))
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
const runtimeConfig = getPublicRuntimeConfig()
|
|
124
|
+
const allowedHosts = new Set([
|
|
125
|
+
...KNOWN_AVATAR_HOSTS,
|
|
126
|
+
...getConfiguredAvatarHosts(runtimeConfig),
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
if (typeof window !== 'undefined' && window.location.hostname) {
|
|
130
|
+
allowedHosts.add(window.location.hostname.toLowerCase())
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!isHostAllowed(parsedUrl.hostname, [...allowedHosts]))
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
return trimmedImage
|
|
137
|
+
}
|