@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="image && !errorLoadingImage">
65
+ <ClientOnly v-if="safeImage && !errorLoadingImage">
62
66
  <img
63
67
  :alt="alt"
64
- :src="image"
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 :src="member.avatarUrl" size="sm" />
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/tela-build",
3
- "version": "1.16.2",
3
+ "version": "1.16.3",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "app.config.ts",
@@ -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
+ }