Skip to content

Commit df623e0

Browse files
arashsheydaantfu
andauthored
feat(assets): file upload extension validation (#391)
Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
1 parent e43e930 commit df623e0

6 files changed

Lines changed: 133 additions & 31 deletions

File tree

packages/devtools-kit/src/_types/options.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export interface ModuleOptions {
5252
*/
5353
experimental?: {
5454
/**
55-
* Timline tab
55+
* Timeline tab
5656
* @deprecated Use `timeline.enable` instead
5757
*/
5858
timeline?: boolean
@@ -83,6 +83,21 @@ export interface ModuleOptions {
8383
}
8484
}
8585

86+
/**
87+
* Options for assets tab
88+
*/
89+
assets?: {
90+
/**
91+
* Allowed file extensions for assets tab to upload.
92+
* To security concern.
93+
*
94+
* Set to '*' to disbale this limitation entirely
95+
*
96+
* @default Common media and txt files
97+
*/
98+
uploadExtensions?: '*' | string[]
99+
}
100+
86101
/**
87102
* Enable anonymous telemetry, helping us improve Nuxt DevTools.
88103
*

packages/devtools-ui-kit/src/components/NDropdown.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
import { ref } from 'vue'
33
import { onClickOutside, useVModel } from '@vueuse/core'
44
5-
const props = defineProps<{ modelValue?: boolean }>()
5+
const props = withDefaults(defineProps<{
6+
modelValue?: boolean
7+
direction?: 'start' | 'end'
8+
}>(), {
9+
direction: 'start',
10+
})
611
const emit = defineEmits<{ (...args: any): void }>()
712
813
const enabled = useVModel(props, 'modelValue', emit, { passive: true })
@@ -23,7 +28,7 @@ onClickOutside(el, () => {
2328

2429
<div
2530
class="absolute z-10 border n-border-base rounded shadow n-transition n-bg-base"
26-
:class="[enabled ? 'op-100' : 'op0 pointer-events-none -translate-y-1']"
31+
:class="[enabled ? 'op-100' : 'op0 pointer-events-none -translate-y-1', direction === 'end' ? 'right-0' : 'left-0']"
2732
>
2833
<slot />
2934
</div>

packages/devtools/client/components/AssetDropZone.vue

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,20 @@
22
import type { AssetEntry } from '~/../src/types'
33
44
const props = defineProps({
5+
modelValue: {
6+
type: Boolean,
7+
required: true,
8+
},
59
folder: {
610
type: String,
711
required: true,
812
},
913
})
1014
11-
const visible = ref(false)
15+
const visible = useVModel(props, 'modelValue')
1216
const lastTarget = ref()
1317
1418
const files = ref<File[]>([])
15-
// TODO: add option to user to choose types
16-
const uploadTypes: string[] = ['image', 'video']
1719
1820
function onDragEnter(e: DragEvent) {
1921
lastTarget.value = e.target
@@ -57,16 +59,7 @@ function setFiles(data: FileList | null) {
5759
classes: 'text-orange',
5860
})
5961
}
60-
else if (uploadTypes.some(type => file.type.includes(type))) {
61-
newFiles.push(file)
62-
}
63-
else {
64-
devtoolsUiShowNotification({
65-
message: `"${file.type}" file type is not allowed`,
66-
icon: 'carbon:face-dissatisfied',
67-
classes: 'text-orange',
68-
})
69-
}
62+
newFiles.push(file)
7063
}
7164
}
7265
files.value = [...files.value, ...newFiles]
@@ -101,10 +94,9 @@ async function uploadFiles() {
10194
icon: 'i-carbon:checkmark',
10295
})
10396
}).catch((error) => {
104-
console.error(error)
10597
close()
10698
devtoolsUiShowNotification({
107-
message: `Error uploading files: ${error}`,
99+
message: `Error uploading files: ${error?.message ?? 'unknown'}`,
108100
icon: 'i-carbon-warning',
109101
classes: 'text-red',
110102
})
@@ -156,13 +148,15 @@ useEventListener('drop', onDrop)
156148
:class="visible ? 'opacity-100 visible' : 'opacity-0 invisible'"
157149
>
158150
<NButton
151+
v-tooltip.bottom-end="'Close'"
159152
icon="carbon-close"
153+
title="Close"
160154
absolute right-5 top-5 z-20 text-xl
161155
:border="false"
162156
@click="close"
163157
/>
164158
<div v-if="!files?.length" h-full w-full flex items-center justify-center>
165-
<label for="drop-zone-input" text-3xl>
159+
<label for="drop-zone-input" text-3xl hover="text-green cursor-pointer" transition-all>
166160
<NIcon icon="carbon-cloud-upload" mr-2 /> Drop files here or click to select
167161
</label>
168162
<input
@@ -179,7 +173,7 @@ useEventListener('drop', onDrop)
179173
Drag and drop files to upload
180174
</p>
181175
</div>
182-
<div grid="~ cols-minmax-8rem" overflow-auto p6>
176+
<div grid="~ cols-minmax-8rem gap-8" overflow-auto p6>
183177
<div
184178
v-for="file, index of files" :key="file.name"
185179
flex="~ col gap-2" relative h-50 w-40 items-center

packages/devtools/client/pages/modules/assets.vue

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ definePageMeta({
1010
})
1111
1212
const assets = useStaticAssets()
13+
const dropzone = ref(false)
1314
const search = ref('')
1415
1516
const fuse = computed(() => new Fuse(assets.value || [], {
@@ -18,11 +19,24 @@ const fuse = computed(() => new Fuse(assets.value || [], {
1819
],
1920
}))
2021
22+
const extensions = reactiveComputed(() => {
23+
const results: { name: string; value: boolean }[] = []
24+
for (const asset of assets.value || []) {
25+
const ext = asset.path.split('.').pop()
26+
if (ext && !results.find(e => e.name === ext))
27+
results.push({ name: ext, value: true })
28+
}
29+
return results
30+
})
31+
2132
const filtered = computed(() => {
2233
const result = search.value
2334
? fuse.value.search(search.value).map(i => i.item)
2435
: (assets.value || [])
25-
return result
36+
return result.filter((asset) => {
37+
const ext = asset.path.split('.').pop()
38+
return !ext || extensions.some(e => e.name === ext && e.value)
39+
})
2640
})
2741
2842
const byFolders = computed(() => {
@@ -81,7 +95,40 @@ const navbar = ref<HTMLElement>()
8195
<div h-full of-auto>
8296
<NNavbar ref="navbar" v-model:search="search" pb2>
8397
<template #actions>
84-
<div flex-none flex="~ gap2 items-center">
98+
<div flex-none flex="~ gap2 items-center" text-lg>
99+
<NButton
100+
v-tooltip.bottom-end="'File Upload'"
101+
icon="carbon:cloud-upload"
102+
title="File Upload" :border="false"
103+
@click="dropzone = !dropzone"
104+
/>
105+
<NDropdown v-if="extensions.length" direction="end" n="sm primary">
106+
<template #trigger="{ click }">
107+
<NButton
108+
v-tooltip.bottom-end="'Filter'"
109+
icon="carbon-filter" :border="false"
110+
title="Filter" p3 text-lg
111+
@click="click()"
112+
/>
113+
<span flex="~ items-center justify-center" absolute bottom--1 right--1 h-4 w-4 rounded-full bg-primary:30 text-8px>
114+
{{ extensions.length }}
115+
</span>
116+
</template>
117+
<div flex="~ col" w-30 of-auto>
118+
<NCheckbox
119+
v-for="item of extensions"
120+
:key="item.name"
121+
v-model="item.value"
122+
flex="~ gap-2"
123+
rounded
124+
px2 py2
125+
>
126+
<span text-xs op75>
127+
{{ item.name }}
128+
</span>
129+
</NCheckbox>
130+
</div>
131+
</NDropdown>
85132
<NButton
86133
v-tooltip.bottom-end="'Toggle View'"
87134
text-lg :border="false"
@@ -97,7 +144,7 @@ const navbar = ref<HTMLElement>()
97144
</div>
98145
</NNavbar>
99146

100-
<AssetDropZone folder="/" />
147+
<AssetDropZone v-model="dropzone" folder="/" />
101148

102149
<template v-if="view === 'grid'">
103150
<template v-if="byFolders.length > 1">

packages/devtools/src/constant.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,34 @@ export const defaultTabOptions: NuxtDevToolsOptions = {
6161
view: 'grid',
6262
},
6363
}
64+
65+
export const defaultAllowedExtensions = [
66+
'png',
67+
'jpg',
68+
'jpeg',
69+
'gif',
70+
'svg',
71+
'webp',
72+
'ico',
73+
'mp4',
74+
'ogg',
75+
'mp3',
76+
'wav',
77+
'mov',
78+
'mkv',
79+
'mpg',
80+
'txt',
81+
'ttf',
82+
'woff',
83+
'woff2',
84+
'eot',
85+
'json',
86+
'js',
87+
'jsx',
88+
'ts',
89+
'tsx',
90+
'md',
91+
'mdx',
92+
'vue',
93+
'webm',
94+
]

packages/devtools/src/server-rpc/assets.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import fsp from 'node:fs/promises'
2+
import { parse } from 'node:path'
23
import { join, resolve } from 'pathe'
34
import { imageMeta } from 'image-meta'
45
import { debounce } from 'perfect-debounce'
56
import fg from 'fast-glob'
67
import type { AssetEntry, AssetInfo, AssetType, ImageMeta, NuxtDevtoolsServerContext, ServerFunctions } from '../types'
8+
import { defaultAllowedExtensions } from '../constant'
79

8-
export function setupAssetsRPC({ nuxt, ensureDevAuthToken, refresh }: NuxtDevtoolsServerContext) {
10+
export function setupAssetsRPC({ nuxt, ensureDevAuthToken, refresh, options }: NuxtDevtoolsServerContext) {
911
const _imageMetaCache = new Map<string, ImageMeta | undefined>()
1012
let cache: AssetInfo[] | null = null
1113

14+
const extensions = options.assets?.uploadExtensions || defaultAllowedExtensions
15+
1216
const publicDir = resolve(nuxt.options.srcDir, nuxt.options.dir.public)
1317

1418
const refreshDebounced = debounce(() => {
@@ -96,25 +100,31 @@ export function setupAssetsRPC({ nuxt, ensureDevAuthToken, refresh }: NuxtDevtoo
96100

97101
return await Promise.all(
98102
files.map(async ({ path, content, encoding, override }) => {
99-
let dir = resolve(baseDir, path)
103+
let finalPath = resolve(baseDir, path)
104+
105+
const { ext } = parse(finalPath)
106+
if (extensions !== '*') {
107+
if (!extensions.includes(ext.toLowerCase()))
108+
throw new Error(`File extension ${ext} is not allowed to upload, allowed extensions are: ${extensions.join(', ')}\nYou can configure it in Nuxt config at \`devtools.assets.uploadExtensions\`.`)
109+
}
110+
100111
if (!override) {
101112
try {
102-
await fsp.stat(dir)
103-
const ext = dir.split('.').pop() as string
104-
const base = dir.slice(0, dir.length - ext.length - 1)
113+
await fsp.stat(finalPath)
114+
const base = finalPath.slice(0, finalPath.length - ext.length - 1)
105115
let i = 1
106116
while (await fsp.access(`${base}-${i}.${ext}`).then(() => true).catch(() => false))
107117
i++
108-
dir = `${base}-${i}.${ext}`
118+
finalPath = `${base}-${i}.${ext}`
109119
}
110120
catch (err) {
111121
// Ignore error if file doesn't exist
112122
}
113123
}
114-
await fsp.writeFile(dir, content, {
124+
await fsp.writeFile(finalPath, content, {
115125
encoding: encoding ?? 'utf-8',
116126
})
117-
return dir
127+
return finalPath
118128
}),
119129
)
120130
},

0 commit comments

Comments
 (0)