maxibase 1.3.0 → 1.5.0
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/layers/base/app/components/AuthCheck.vue +50 -10
- package/layers/base/app/components/files/FilesUpload.vue +10 -3
- package/layers/base/app/pages/signin.vue +0 -10
- package/layers/base/app/pages/user/auth.vue +1 -1
- package/layers/base/app/utils/files.ts +6 -1
- package/layers/base/server/api/files/[...path].get.ts +3 -12
- package/layers/base/server/api/files/chunk.post.ts +15 -9
- package/layers/base/server/api/files/copy.post.ts +2 -10
- package/layers/base/server/api/files/index.get.ts +1 -4
- package/layers/base/server/api/files/index.post.ts +12 -5
- package/layers/base/server/database/defaults/access.ts +2 -2
- package/layers/base/server/middleware/2.mfa.ts +1 -1
- package/layers/base/server/routes/dev/email/[template].ts +2 -2
- package/layers/base/server/utils/access.ts +1 -1
- package/layers/base/server/utils/auth.ts +1 -1
- package/layers/base/server/utils/files.ts +99 -84
- package/layers/base/server/utils/ip.ts +1 -1
- package/locales/en.json +1 -0
- package/locales/fr.json +1 -0
- package/locales/pages/signin/en.json +0 -1
- package/locales/pages/signin/fr.json +0 -1
- package/package.json +2 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { WebAuthnAbortService } from '@simplewebauthn/browser'
|
|
3
|
+
|
|
2
4
|
const emits = defineEmits(['authenticated', 'cancel'])
|
|
3
5
|
const appStore = useAppStore()
|
|
4
6
|
const { $t } = useI18n()
|
|
@@ -133,17 +135,23 @@ function signIn() {
|
|
|
133
135
|
}, 100)
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
const
|
|
138
|
+
const webAuthnEndpoints = {
|
|
137
139
|
registerEndpoint: '/api/auth/webauthn/register',
|
|
138
140
|
authenticateEndpoint: '/api/auth/webauthn/authenticate',
|
|
141
|
+
}
|
|
142
|
+
const { authenticate } = useWebAuthn(webAuthnEndpoints)
|
|
143
|
+
const { authenticate: authenticateWithAutofill, isAutofillSupported } = useWebAuthn({
|
|
144
|
+
...webAuthnEndpoints,
|
|
145
|
+
useBrowserAutofill: true,
|
|
139
146
|
})
|
|
140
147
|
|
|
141
|
-
async function signInWithPasskey() {
|
|
148
|
+
async function signInWithPasskey(userName?: string, autofill = false) {
|
|
142
149
|
try {
|
|
143
|
-
await authenticate(
|
|
150
|
+
await (autofill ? authenticateWithAutofill() : authenticate(userName))
|
|
144
151
|
await refreshSession()
|
|
145
152
|
} catch (e: any) {
|
|
146
|
-
|
|
153
|
+
// any other passkey prompt aborts the pending autofill request
|
|
154
|
+
if (e.name !== 'AbortError' && !e.message.startsWith('The operation either timed out')) {
|
|
147
155
|
handleError(e)
|
|
148
156
|
}
|
|
149
157
|
}
|
|
@@ -160,7 +168,7 @@ async function getSignInOptions(email: string) {
|
|
|
160
168
|
password.value = route.query.token as string
|
|
161
169
|
if (!options.value.hasTOTP) await signInWithOtp()
|
|
162
170
|
} else if (options.value.hasPasskey) {
|
|
163
|
-
await signInWithPasskey()
|
|
171
|
+
await signInWithPasskey(email)
|
|
164
172
|
} else if (!options.value.hasPassword) {
|
|
165
173
|
await requestOtp()
|
|
166
174
|
}
|
|
@@ -187,8 +195,12 @@ onMounted(async () => {
|
|
|
187
195
|
email.value = user.value?.email || (route.query.email as string) || ''
|
|
188
196
|
if (email.value) {
|
|
189
197
|
await getSignInOptions(email.value)
|
|
198
|
+
} else if (await isAutofillSupported()) {
|
|
199
|
+
signInWithPasskey(undefined, true)
|
|
190
200
|
}
|
|
191
201
|
})
|
|
202
|
+
|
|
203
|
+
onBeforeUnmount(() => WebAuthnAbortService.cancelCeremony())
|
|
192
204
|
</script>
|
|
193
205
|
|
|
194
206
|
<template>
|
|
@@ -286,8 +298,9 @@ onMounted(async () => {
|
|
|
286
298
|
type="button"
|
|
287
299
|
:disabled="isLoading"
|
|
288
300
|
class="flex-center g1 w mt1 bg bg-border"
|
|
289
|
-
@click="signInWithPasskey"
|
|
301
|
+
@click="signInWithPasskey(email)"
|
|
290
302
|
>
|
|
303
|
+
<Icon name="lucide:key-round" />
|
|
291
304
|
{{ $t('authCheck.passkey') }}
|
|
292
305
|
</button>
|
|
293
306
|
<button
|
|
@@ -319,7 +332,7 @@ onMounted(async () => {
|
|
|
319
332
|
type="email"
|
|
320
333
|
required
|
|
321
334
|
:disabled="isLoading"
|
|
322
|
-
autocomplete="username"
|
|
335
|
+
autocomplete="username webauthn"
|
|
323
336
|
/>
|
|
324
337
|
</label>
|
|
325
338
|
<button
|
|
@@ -329,10 +342,23 @@ onMounted(async () => {
|
|
|
329
342
|
{{ $t('continue') }}
|
|
330
343
|
</button>
|
|
331
344
|
</form>
|
|
332
|
-
<
|
|
345
|
+
<div
|
|
333
346
|
v-if="!optionsFetched"
|
|
334
|
-
|
|
335
|
-
|
|
347
|
+
class="flex-column"
|
|
348
|
+
>
|
|
349
|
+
<span class="text-center muted-text my1">{{ $t('or') }}</span>
|
|
350
|
+
<button
|
|
351
|
+
type="button"
|
|
352
|
+
:disabled="isLoading"
|
|
353
|
+
class="provider flex-center g2 w mb1 fg b"
|
|
354
|
+
style="font-weight: normal"
|
|
355
|
+
@click="signInWithPasskey()"
|
|
356
|
+
>
|
|
357
|
+
<Icon name="lucide:key-round" />
|
|
358
|
+
{{ $t('authCheck.passkey') }}
|
|
359
|
+
</button>
|
|
360
|
+
<slot name="footer"></slot>
|
|
361
|
+
</div>
|
|
336
362
|
<button
|
|
337
363
|
v-if="appStore.authPromise"
|
|
338
364
|
type="button"
|
|
@@ -352,4 +378,18 @@ onMounted(async () => {
|
|
|
352
378
|
box-sizing: border-box;
|
|
353
379
|
width: min(400px, 100vw - (var(--g-padding-l) + var(--g-padding-r)) * var(--g-padding-ratio2));
|
|
354
380
|
}
|
|
381
|
+
|
|
382
|
+
.provider,
|
|
383
|
+
:slotted(.provider) {
|
|
384
|
+
height: 40px;
|
|
385
|
+
border-radius: var(--g-border-radius);
|
|
386
|
+
font-size: 14px;
|
|
387
|
+
color: inherit;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.provider > :is(img, .iconify),
|
|
391
|
+
:slotted(.provider) > :is(img, .iconify) {
|
|
392
|
+
width: 20px;
|
|
393
|
+
height: 20px;
|
|
394
|
+
}
|
|
355
395
|
</style>
|
|
@@ -9,6 +9,7 @@ const props = defineProps({
|
|
|
9
9
|
type: Boolean,
|
|
10
10
|
default: true,
|
|
11
11
|
},
|
|
12
|
+
maxAge: Number,
|
|
12
13
|
imageFormat: {
|
|
13
14
|
type: Object as PropType<{
|
|
14
15
|
maxHeight?: number
|
|
@@ -57,9 +58,15 @@ async function uploadFile(event: Event) {
|
|
|
57
58
|
files = files.filter((file) => !tooLarge.includes(file))
|
|
58
59
|
}
|
|
59
60
|
if (files.length) {
|
|
60
|
-
let response = await uploadFiles(
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
let response = await uploadFiles(
|
|
62
|
+
files,
|
|
63
|
+
props.path,
|
|
64
|
+
props.isPrivate,
|
|
65
|
+
(p) => {
|
|
66
|
+
progress.value = p
|
|
67
|
+
},
|
|
68
|
+
props.maxAge,
|
|
69
|
+
)
|
|
63
70
|
emits('uploaded', response)
|
|
64
71
|
appStore.notify('saved', 'success')
|
|
65
72
|
}
|
|
@@ -71,7 +71,6 @@ async function signInAnonymously() {
|
|
|
71
71
|
v-if="config.public.oauth.microsoft || config.public.anonymousSignup"
|
|
72
72
|
class="flex-column"
|
|
73
73
|
>
|
|
74
|
-
<span class="text-center muted-text my1">{{ $t('or') }}</span>
|
|
75
74
|
<a
|
|
76
75
|
v-if="config.public.oauth.microsoft"
|
|
77
76
|
href="/auth/microsoft"
|
|
@@ -80,8 +79,6 @@ async function signInAnonymously() {
|
|
|
80
79
|
<img
|
|
81
80
|
src="/images/auth/microsoft.svg"
|
|
82
81
|
alt=""
|
|
83
|
-
width="21"
|
|
84
|
-
height="21"
|
|
85
82
|
/>
|
|
86
83
|
{{ $t('signinWith') }} Microsoft
|
|
87
84
|
</a>
|
|
@@ -111,11 +108,4 @@ async function signInAnonymously() {
|
|
|
111
108
|
box-shadow: inset 0 0 40vh var(--g-fg);
|
|
112
109
|
height: 100dvh;
|
|
113
110
|
}
|
|
114
|
-
|
|
115
|
-
.provider {
|
|
116
|
-
height: 40px;
|
|
117
|
-
border-radius: var(--g-border-radius);
|
|
118
|
-
font-size: 14px;
|
|
119
|
-
color: inherit;
|
|
120
|
-
}
|
|
121
111
|
</style>
|
|
@@ -3,6 +3,7 @@ export async function uploadFiles(
|
|
|
3
3
|
path: string,
|
|
4
4
|
isPrivate: boolean,
|
|
5
5
|
onProgress?: (progress: number) => void,
|
|
6
|
+
maxAge?: number, // seconds the browser and CDN may cache the file, 0 disables caching
|
|
6
7
|
) {
|
|
7
8
|
let totalSize = files.reduce((sum, f) => sum + f.size, 0)
|
|
8
9
|
|
|
@@ -13,6 +14,7 @@ export async function uploadFiles(
|
|
|
13
14
|
files.forEach((file) => formData.append('file', file))
|
|
14
15
|
formData.append('path', path)
|
|
15
16
|
formData.append('isPrivate', String(isPrivate))
|
|
17
|
+
if (maxAge !== undefined) formData.append('maxAge', String(maxAge))
|
|
16
18
|
urls = await postFiles(formData)
|
|
17
19
|
} else {
|
|
18
20
|
let uploadedSize = 0
|
|
@@ -22,6 +24,7 @@ export async function uploadFiles(
|
|
|
22
24
|
formData.append('file', file)
|
|
23
25
|
formData.append('path', path)
|
|
24
26
|
formData.append('isPrivate', String(isPrivate))
|
|
27
|
+
if (maxAge !== undefined) formData.append('maxAge', String(maxAge))
|
|
25
28
|
urls.push(...(await postFiles(formData)))
|
|
26
29
|
uploadedSize += file.size
|
|
27
30
|
const progress = (uploadedSize / totalSize) * 100
|
|
@@ -32,7 +35,7 @@ export async function uploadFiles(
|
|
|
32
35
|
const progress = (uploadedSize / totalSize) * 100
|
|
33
36
|
onProgress?.(Math.round(progress))
|
|
34
37
|
}
|
|
35
|
-
const url = await uploadInChunks(file, path, isPrivate, onChunkProgress)
|
|
38
|
+
const url = await uploadInChunks(file, path, isPrivate, onChunkProgress, maxAge)
|
|
36
39
|
if (url) urls.push(url)
|
|
37
40
|
}
|
|
38
41
|
}
|
|
@@ -51,6 +54,7 @@ export async function uploadInChunks(
|
|
|
51
54
|
path: string,
|
|
52
55
|
isPrivate: boolean,
|
|
53
56
|
onProgress?: (progress: number) => void,
|
|
57
|
+
maxAge?: number,
|
|
54
58
|
) {
|
|
55
59
|
let uploadId
|
|
56
60
|
let parts: any[] = []
|
|
@@ -70,6 +74,7 @@ export async function uploadInChunks(
|
|
|
70
74
|
formData.append('filename', file.name)
|
|
71
75
|
formData.append('path', path)
|
|
72
76
|
formData.append('isPrivate', String(isPrivate))
|
|
77
|
+
if (maxAge !== undefined) formData.append('maxAge', String(maxAge))
|
|
73
78
|
|
|
74
79
|
result = await $fetch('/api/files/chunk', { method: 'POST', body: formData })
|
|
75
80
|
if (i + 1 < totalChunks) {
|
|
@@ -6,8 +6,6 @@ const querySchema = z.object({
|
|
|
6
6
|
.transform((v) => v === 'true')
|
|
7
7
|
.optional(),
|
|
8
8
|
maxAge: z.coerce.number().int().min(0).optional(),
|
|
9
|
-
// only-if-cached is left out, fetch throws on it outside same-origin mode
|
|
10
|
-
serverCache: z.enum(['default', 'no-store', 'reload', 'no-cache', 'force-cache']).optional(),
|
|
11
9
|
// S3 refuses to presign a URL for longer than 7 days
|
|
12
10
|
expireIn: z.coerce.number().int().positive().max(604800).optional(),
|
|
13
11
|
redirect: z
|
|
@@ -17,23 +15,16 @@ const querySchema = z.object({
|
|
|
17
15
|
})
|
|
18
16
|
|
|
19
17
|
export default defineEventHandler(async (event) => {
|
|
20
|
-
const path = event
|
|
18
|
+
const path = getRouterParam(event, 'path') || ''
|
|
21
19
|
const {
|
|
22
20
|
isPrivate = false,
|
|
23
21
|
maxAge,
|
|
24
|
-
serverCache,
|
|
25
22
|
expireIn,
|
|
26
23
|
redirect,
|
|
27
24
|
} = await getValidatedQuery(event, querySchema.parse)
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
cache = 'no-store, no-cache'
|
|
32
|
-
} else if (isPrivate) {
|
|
33
|
-
cache = `private, max-age=${maxAge || 3600}`
|
|
34
|
-
}
|
|
35
|
-
setHeader(event, 'Cache-Control', cache)
|
|
36
|
-
return getFile(event, decodePath(path), isPrivate, serverCache, expireIn, redirect)
|
|
26
|
+
setHeader(event, 'Cache-Control', getCacheControl(isPrivate, maxAge))
|
|
27
|
+
return getFile(event, decodePath(path), isPrivate, expireIn, redirect)
|
|
37
28
|
})
|
|
38
29
|
|
|
39
30
|
// h3 decodes the path but keeps %25 encoded, so a % in a name needs this second pass
|
|
@@ -23,11 +23,10 @@ export default defineEventHandler(async (event) => {
|
|
|
23
23
|
const body = await readUploadBody(event, Number(useRuntimeConfig().public.files.chunkSize) || 1)
|
|
24
24
|
if (!body) throw createError({ status: 400, message: 'No data' })
|
|
25
25
|
|
|
26
|
-
const get = (name: string) =>
|
|
27
|
-
body
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
.trim()
|
|
26
|
+
const get = (name: string) => {
|
|
27
|
+
const value = body.get(name)
|
|
28
|
+
return typeof value === 'string' ? value.trim() : undefined
|
|
29
|
+
}
|
|
31
30
|
|
|
32
31
|
const uploadId = get('uploadId')
|
|
33
32
|
const chunkIndex = Number(get('chunkIndex'))
|
|
@@ -37,9 +36,15 @@ export default defineEventHandler(async (event) => {
|
|
|
37
36
|
const path = get('path') || 'files'
|
|
38
37
|
const isPrivate = get('isPrivate') !== 'false'
|
|
39
38
|
const parts = parseParts(get('parts'))
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
const maxAge = parseMaxAge(get('maxAge'))
|
|
40
|
+
const chunkPart = body.get('chunk')
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
!Number.isInteger(chunkIndex) ||
|
|
44
|
+
!Number.isInteger(totalChunks) ||
|
|
45
|
+
!filename ||
|
|
46
|
+
!(chunkPart instanceof File)
|
|
47
|
+
) {
|
|
43
48
|
throw invalidChunkUpload()
|
|
44
49
|
}
|
|
45
50
|
|
|
@@ -49,9 +54,10 @@ export default defineEventHandler(async (event) => {
|
|
|
49
54
|
totalChunks,
|
|
50
55
|
filename,
|
|
51
56
|
type,
|
|
52
|
-
chunk: chunkPart.
|
|
57
|
+
chunk: Buffer.from(await chunkPart.arrayBuffer()),
|
|
53
58
|
path,
|
|
54
59
|
isPrivate,
|
|
55
60
|
parts,
|
|
61
|
+
maxAge,
|
|
56
62
|
})
|
|
57
63
|
})
|
|
@@ -5,18 +5,10 @@ const bodySchema = z.object({
|
|
|
5
5
|
dest: z.string().min(1),
|
|
6
6
|
isPrivate: z.boolean().optional(),
|
|
7
7
|
deleteSrc: z.boolean().optional(),
|
|
8
|
-
isFolder: z.boolean().optional(),
|
|
9
8
|
})
|
|
10
9
|
|
|
11
10
|
export default defineEventHandler(async (event) => {
|
|
12
|
-
const {
|
|
13
|
-
|
|
14
|
-
dest,
|
|
15
|
-
isPrivate,
|
|
16
|
-
deleteSrc,
|
|
17
|
-
isFolder = !src.includes('.'),
|
|
18
|
-
} = await readValidatedBody(event, bodySchema.parse)
|
|
19
|
-
if (isFolder) await copyFiles(event, src, dest, isPrivate, deleteSrc)
|
|
20
|
-
else await copyFile(event, src, dest, isPrivate, deleteSrc)
|
|
11
|
+
const { src, dest, isPrivate, deleteSrc } = await readValidatedBody(event, bodySchema.parse)
|
|
12
|
+
await copyFiles(event, src, dest, isPrivate, deleteSrc)
|
|
21
13
|
return { success: true }
|
|
22
14
|
})
|
|
@@ -4,12 +4,19 @@ export default defineEventHandler(async (event) => {
|
|
|
4
4
|
throw createError({ status: 400, message: 'No file uploaded' })
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
const get = (name: string) => {
|
|
8
|
+
const value = body.get(name)
|
|
9
|
+
return typeof value === 'string' ? value.trim() : undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const isPrivate = body.has('isPrivate') ? get('isPrivate') === 'true' : true
|
|
13
|
+
const path = get('path')
|
|
14
|
+
const maxAge = parseMaxAge(get('maxAge'))
|
|
11
15
|
|
|
12
16
|
return await Promise.all(
|
|
13
|
-
body
|
|
17
|
+
body
|
|
18
|
+
.getAll('file')
|
|
19
|
+
.filter((file) => file instanceof File)
|
|
20
|
+
.map((file) => uploadFile(event, file, path, isPrivate, maxAge)),
|
|
14
21
|
)
|
|
15
22
|
})
|
|
@@ -3,12 +3,12 @@ import type { H3Event } from 'h3'
|
|
|
3
3
|
// Users reach their own folder u/{id}, admins every folder
|
|
4
4
|
export async function checkFileAccess(event: H3Event, path: string) {
|
|
5
5
|
const { user } = await requireUserSession(event)
|
|
6
|
-
if (!user) throw createError({
|
|
6
|
+
if (!user) throw createError({ status: 401, message: 'Unauthorized' })
|
|
7
7
|
const root = `u/${user.id}`
|
|
8
8
|
if (path.startsWith(root) && (path[root.length] === '/' || path.length === root.length)) {
|
|
9
9
|
return true
|
|
10
10
|
}
|
|
11
11
|
if (user.role === 'admin') return true
|
|
12
12
|
|
|
13
|
-
throw createError({
|
|
13
|
+
throw createError({ status: 403, message: 'Insufficient permissions' })
|
|
14
14
|
}
|
|
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
|
|
|
28
28
|
|
|
29
29
|
const path = event.path.split('?')[0]!
|
|
30
30
|
if (path.startsWith('/api/auth/otp/') || path === '/api/auth/password') return // re-authentication
|
|
31
|
-
if (ENROLLMENT_ROUTES.includes(path) && !(path === '/api/auth' && event
|
|
31
|
+
if (ENROLLMENT_ROUTES.includes(path) && !(path === '/api/auth' && !isMethod(event, 'GET'))) return
|
|
32
32
|
|
|
33
33
|
throw createError({ status: 403, message: 'MFA setup required' })
|
|
34
34
|
})
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export default defineEventHandler(async (event) => {
|
|
2
2
|
if (process.env.NODE_ENV === 'production') {
|
|
3
3
|
throw createError({
|
|
4
|
-
|
|
4
|
+
status: 404,
|
|
5
5
|
message: 'Not found',
|
|
6
6
|
})
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
const templateId = event
|
|
9
|
+
const templateId = getRouterParam(event, 'template') || ''
|
|
10
10
|
const query = getQuery(event)
|
|
11
11
|
const locale = (query.locale as string) || 'en'
|
|
12
12
|
|
|
@@ -4,5 +4,5 @@ export async function isAdmin(event: H3Event) {
|
|
|
4
4
|
const { user } = await requireUserSession(event)
|
|
5
5
|
if (user.role === 'admin') return true
|
|
6
6
|
|
|
7
|
-
throw createError({
|
|
7
|
+
throw createError({ status: 403, message: 'Insufficient permissions' })
|
|
8
8
|
}
|
|
@@ -133,7 +133,7 @@ export async function requireRecentAuth(event: H3Event) {
|
|
|
133
133
|
if (!session.authenticatedAt || Date.now() - session.authenticatedAt > maxAge) {
|
|
134
134
|
throw createError({
|
|
135
135
|
status: 403,
|
|
136
|
-
|
|
136
|
+
statusText: 'reauth_required',
|
|
137
137
|
message: 'Please verify your identity again to continue.',
|
|
138
138
|
})
|
|
139
139
|
}
|
|
@@ -29,50 +29,52 @@ import type { CompletedPart } from '@aws-sdk/client-s3'
|
|
|
29
29
|
import { checkFileAccess } from '#database/access'
|
|
30
30
|
import { createReadStream, createWriteStream } from 'fs'
|
|
31
31
|
import { pipeline } from 'stream/promises'
|
|
32
|
-
import
|
|
33
|
-
import type { H3Event
|
|
32
|
+
import { Readable } from 'stream'
|
|
33
|
+
import type { H3Event } from 'h3'
|
|
34
34
|
import { createHash } from 'crypto'
|
|
35
|
+
import mime from 'mime'
|
|
35
36
|
|
|
36
37
|
const MB = 1024 * 1024
|
|
37
38
|
|
|
38
39
|
function fileTooLarge() {
|
|
39
40
|
const { maxSize } = useRuntimeConfig().public.files
|
|
40
|
-
return createError({
|
|
41
|
+
return createError({ status: 413, message: `File too large, max ${maxSize} MB` })
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export async function readUploadBody(event: H3Event, maxMB: number) {
|
|
44
45
|
await requireUserSession(event)
|
|
45
46
|
const length = getRequestHeader(event, 'content-length')
|
|
46
|
-
if (!length) throw createError({
|
|
47
|
+
if (!length) throw createError({ status: 411, message: 'Content-Length required' })
|
|
47
48
|
if (Number(length) > (maxMB + 1) * MB) throw fileTooLarge()
|
|
48
|
-
return
|
|
49
|
+
return readFormData(event).catch(() => undefined)
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export async function uploadFile(
|
|
52
53
|
event: H3Event,
|
|
53
|
-
file:
|
|
54
|
+
file: File,
|
|
54
55
|
path = 'files',
|
|
55
56
|
isPrivate = true,
|
|
57
|
+
maxAge?: number, // only stored on S3 objects, local files get their headers from nginx
|
|
56
58
|
) {
|
|
57
59
|
await checkFileAccess(event, path)
|
|
58
|
-
path =
|
|
59
|
-
|
|
60
|
+
path = getUploadPath(path, isPrivate, file.name)
|
|
61
|
+
const data = Buffer.from(await file.arrayBuffer())
|
|
60
62
|
|
|
61
63
|
let url
|
|
62
64
|
if (useS3()) {
|
|
63
65
|
const s3Key = getS3Key(path)
|
|
64
66
|
url = await uploadToS3(
|
|
65
67
|
s3Key,
|
|
66
|
-
|
|
68
|
+
data,
|
|
67
69
|
file.type || 'application/octet-stream',
|
|
68
|
-
|
|
70
|
+
getCacheControl(isPrivate, maxAge),
|
|
69
71
|
isPrivate,
|
|
70
72
|
)
|
|
71
73
|
} else {
|
|
72
74
|
const filePath = join(process.cwd(), path)
|
|
73
75
|
const folderPath = filePath.replace(/\/[^/]*$/, '')
|
|
74
76
|
await mkdir(folderPath, { recursive: true })
|
|
75
|
-
await writeFile(filePath,
|
|
77
|
+
await writeFile(filePath, data)
|
|
76
78
|
|
|
77
79
|
url = getFileURL(path, isPrivate)
|
|
78
80
|
}
|
|
@@ -85,17 +87,17 @@ export async function uploadStream(
|
|
|
85
87
|
file: { stream: Readable; filename?: string; type?: string },
|
|
86
88
|
path = 'files',
|
|
87
89
|
isPrivate = true,
|
|
90
|
+
maxAge?: number,
|
|
88
91
|
) {
|
|
89
92
|
await checkFileAccess(event, path)
|
|
90
|
-
path =
|
|
91
|
-
path = appendFilename(path, file.filename)
|
|
93
|
+
path = getUploadPath(path, isPrivate, file.filename)
|
|
92
94
|
|
|
93
95
|
if (useS3()) {
|
|
94
96
|
return uploadStreamToS3(
|
|
95
97
|
getS3Key(path),
|
|
96
98
|
file.stream,
|
|
97
99
|
file.type || 'application/octet-stream',
|
|
98
|
-
|
|
100
|
+
getCacheControl(isPrivate, maxAge),
|
|
99
101
|
isPrivate,
|
|
100
102
|
)
|
|
101
103
|
}
|
|
@@ -133,6 +135,7 @@ export async function uploadChunk(
|
|
|
133
135
|
path,
|
|
134
136
|
isPrivate,
|
|
135
137
|
parts,
|
|
138
|
+
maxAge,
|
|
136
139
|
}: {
|
|
137
140
|
uploadId?: string
|
|
138
141
|
chunkIndex: number
|
|
@@ -143,10 +146,11 @@ export async function uploadChunk(
|
|
|
143
146
|
path: string
|
|
144
147
|
isPrivate: boolean
|
|
145
148
|
parts: CompletedPart[]
|
|
149
|
+
maxAge?: number // only read on the first chunk, when the S3 upload starts
|
|
146
150
|
},
|
|
147
151
|
) {
|
|
148
152
|
if (chunkIndex < 0 || chunkIndex >= totalChunks || totalChunks < 1) {
|
|
149
|
-
throw createError({
|
|
153
|
+
throw createError({ status: 400, message: 'Invalid chunk index' })
|
|
150
154
|
}
|
|
151
155
|
const { chunkSize, maxSize } = useRuntimeConfig().public.files
|
|
152
156
|
const chunkBytes = (Number(chunkSize) || 1) * MB
|
|
@@ -155,12 +159,16 @@ export async function uploadChunk(
|
|
|
155
159
|
}
|
|
156
160
|
|
|
157
161
|
await checkFileAccess(event, path)
|
|
158
|
-
path =
|
|
159
|
-
path = appendFilename(path, filename)
|
|
162
|
+
path = getUploadPath(path, isPrivate, filename)
|
|
160
163
|
|
|
161
164
|
if (useS3()) {
|
|
162
165
|
if (!uploadId) {
|
|
163
|
-
uploadId = (await initMultipartUploadToS3(
|
|
166
|
+
uploadId = (await initMultipartUploadToS3(
|
|
167
|
+
getS3Key(path),
|
|
168
|
+
type,
|
|
169
|
+
isPrivate,
|
|
170
|
+
getCacheControl(isPrivate, maxAge),
|
|
171
|
+
))!
|
|
164
172
|
parts = []
|
|
165
173
|
}
|
|
166
174
|
|
|
@@ -179,7 +187,7 @@ export async function uploadChunk(
|
|
|
179
187
|
uploadId = crypto.randomUUID()
|
|
180
188
|
parts = []
|
|
181
189
|
} else if (!isUUID(uploadId)) {
|
|
182
|
-
throw createError({
|
|
190
|
+
throw createError({ status: 400, message: 'Invalid upload id' })
|
|
183
191
|
}
|
|
184
192
|
|
|
185
193
|
const tmpDir = join(process.cwd(), 'files', 'temp', uploadId)
|
|
@@ -305,7 +313,6 @@ export async function getFile(
|
|
|
305
313
|
event: H3Event,
|
|
306
314
|
path: string,
|
|
307
315
|
isPrivate = false,
|
|
308
|
-
cache: RequestCache = 'no-cache',
|
|
309
316
|
expireIn?: number,
|
|
310
317
|
redirect?: boolean,
|
|
311
318
|
) {
|
|
@@ -324,14 +331,15 @@ export async function getFile(
|
|
|
324
331
|
return sendRedirect(event, url, 302)
|
|
325
332
|
}
|
|
326
333
|
|
|
327
|
-
const response = await fetch(url, {
|
|
334
|
+
const response = await fetch(url, { headers: range ? { range } : undefined })
|
|
328
335
|
|
|
329
336
|
if (response.status === 416) {
|
|
330
337
|
const contentRange = response.headers.get('content-range')
|
|
331
338
|
if (contentRange) setHeader(event, 'Content-Range', contentRange)
|
|
332
|
-
throw createError({
|
|
339
|
+
throw createError({ status: 416, message: 'Range not satisfiable' })
|
|
333
340
|
}
|
|
334
|
-
if (
|
|
341
|
+
if (response.status === 404) throw createError({ status: 404, message: 'File not found' })
|
|
342
|
+
if (!response.ok) throw createError({ status: 502, message: 'Failed to fetch file' })
|
|
335
343
|
|
|
336
344
|
const passthrough = {
|
|
337
345
|
'Content-Type': response.headers.get('content-type'),
|
|
@@ -351,15 +359,16 @@ export async function getFile(
|
|
|
351
359
|
|
|
352
360
|
const stats = await stat(fullPath).catch(() => null)
|
|
353
361
|
if (!stats?.isFile()) {
|
|
354
|
-
throw createError({
|
|
362
|
+
throw createError({ status: 404, message: 'File not found' })
|
|
355
363
|
}
|
|
356
364
|
|
|
365
|
+
setHeader(event, 'Content-Type', mime.getType(fullPath) || 'application/octet-stream')
|
|
357
366
|
setHeader(event, 'Accept-Ranges', 'bytes')
|
|
358
367
|
|
|
359
368
|
const parsedRange = parseRangeHeader(range, stats.size)
|
|
360
369
|
if (parsedRange === 'unsatisfiable') {
|
|
361
370
|
setHeader(event, 'Content-Range', `bytes */${stats.size}`)
|
|
362
|
-
throw createError({
|
|
371
|
+
throw createError({ status: 416, message: 'Range not satisfiable' })
|
|
363
372
|
}
|
|
364
373
|
|
|
365
374
|
if (parsedRange) {
|
|
@@ -367,11 +376,12 @@ export async function getFile(
|
|
|
367
376
|
setResponseStatus(event, 206)
|
|
368
377
|
setHeader(event, 'Content-Range', `bytes ${start}-${end}/${stats.size}`)
|
|
369
378
|
setHeader(event, 'Content-Length', end - start + 1)
|
|
370
|
-
return createReadStream(fullPath, { start, end })
|
|
379
|
+
return Readable.toWeb(createReadStream(fullPath, { start, end }))
|
|
371
380
|
}
|
|
372
381
|
|
|
373
382
|
setHeader(event, 'Content-Length', stats.size)
|
|
374
|
-
|
|
383
|
+
// h3 v2 only streams web streams
|
|
384
|
+
return Readable.toWeb(createReadStream(fullPath))
|
|
375
385
|
}
|
|
376
386
|
|
|
377
387
|
export async function deletePath(event: H3Event, path: string, isPrivate = true) {
|
|
@@ -417,27 +427,26 @@ export async function listFolder(
|
|
|
417
427
|
path = getSecurePath(path, isPrivate)
|
|
418
428
|
|
|
419
429
|
if (useS3()) return await listFromS3(path, isPrivate)
|
|
430
|
+
return listLocal(path, isPrivate)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function listLocal(path: string, isPrivate: boolean): Promise<ListedFile[]> {
|
|
420
434
|
const localPath = join(process.cwd(), path)
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
url: getFileURL(rootedPath, isPrivate),
|
|
437
|
-
}
|
|
438
|
-
}),
|
|
439
|
-
)
|
|
440
|
-
).flat()
|
|
435
|
+
const stats = await stat(localPath).catch(() => null)
|
|
436
|
+
if (stats?.isFile()) {
|
|
437
|
+
return [
|
|
438
|
+
{
|
|
439
|
+
name: basename(path),
|
|
440
|
+
size: stats.size,
|
|
441
|
+
updatedAt: stats.mtime,
|
|
442
|
+
path: removeRoot(path, isPrivate),
|
|
443
|
+
url: getFileURL(path, isPrivate),
|
|
444
|
+
},
|
|
445
|
+
]
|
|
446
|
+
}
|
|
447
|
+
if (!stats?.isDirectory()) return []
|
|
448
|
+
const names = await readdir(localPath)
|
|
449
|
+
return (await Promise.all(names.map((name) => listLocal(join(path, name), isPrivate)))).flat()
|
|
441
450
|
}
|
|
442
451
|
|
|
443
452
|
export async function copyFile(
|
|
@@ -460,7 +469,7 @@ export async function copyFile(
|
|
|
460
469
|
const destPath = join(process.cwd(), dest)
|
|
461
470
|
const stats = await stat(srcPath).catch(() => null)
|
|
462
471
|
if (!stats?.isFile()) {
|
|
463
|
-
throw createError({
|
|
472
|
+
throw createError({ status: 404, message: 'File not found' })
|
|
464
473
|
}
|
|
465
474
|
await mkdir(dirname(destPath), { recursive: true })
|
|
466
475
|
await fsCopyFile(srcPath, destPath)
|
|
@@ -478,6 +487,7 @@ export async function copyFiles(
|
|
|
478
487
|
deleteSrc = false,
|
|
479
488
|
) {
|
|
480
489
|
const files = await listFolder(event, src, isPrivate)
|
|
490
|
+
if (!files.length) throw createError({ status: 404, message: 'File not found' })
|
|
481
491
|
const normalizedSrc = removeRoot(getSecurePath(src, isPrivate), isPrivate)
|
|
482
492
|
const normalizedDest = removeRoot(getSecurePath(dest, isPrivate), isPrivate)
|
|
483
493
|
return Promise.all(
|
|
@@ -511,14 +521,16 @@ export function getSecurePath(path: string, isPrivate = true) {
|
|
|
511
521
|
}
|
|
512
522
|
|
|
513
523
|
function invalidPath() {
|
|
514
|
-
return createError({
|
|
524
|
+
return createError({ status: 400, message: 'Invalid path' })
|
|
515
525
|
}
|
|
516
526
|
|
|
517
|
-
function
|
|
527
|
+
function getUploadPath(path: string, isPrivate: boolean, filename?: string) {
|
|
528
|
+
const isFolder = path.endsWith('/')
|
|
529
|
+
path = getSecurePath(path, isPrivate)
|
|
518
530
|
if (!filename) return path
|
|
519
531
|
if (filename.includes('..')) throw invalidPath()
|
|
520
532
|
filename = cleanFilename(filename)
|
|
521
|
-
if (/[^/]\.[^.]+$/.test(path) || basename(path) === filename) return path
|
|
533
|
+
if (!isFolder && (/[^/]\.[^.]+$/.test(path) || basename(path) === filename)) return path
|
|
522
534
|
return join(path, filename)
|
|
523
535
|
}
|
|
524
536
|
|
|
@@ -563,56 +575,60 @@ export const useS3 = () => {
|
|
|
563
575
|
return s3Client
|
|
564
576
|
}
|
|
565
577
|
|
|
578
|
+
export function getCacheControl(isPrivate: boolean, maxAge?: number) {
|
|
579
|
+
if (maxAge === 0) return 'no-store, no-cache'
|
|
580
|
+
if (isPrivate) return `private, max-age=${maxAge || 3600}`
|
|
581
|
+
return `public, max-age=${maxAge || 31536000}`
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function parseMaxAge(value?: string) {
|
|
585
|
+
if (!value) return undefined
|
|
586
|
+
const maxAge = Number(value)
|
|
587
|
+
if (!Number.isInteger(maxAge) || maxAge < 0) {
|
|
588
|
+
throw createError({ status: 400, message: 'Invalid maxAge' })
|
|
589
|
+
}
|
|
590
|
+
return maxAge
|
|
591
|
+
}
|
|
592
|
+
|
|
566
593
|
export async function uploadToS3(
|
|
567
594
|
key: string,
|
|
568
595
|
body: Buffer,
|
|
569
596
|
contentType: string,
|
|
570
|
-
cache
|
|
597
|
+
cache?: string,
|
|
571
598
|
isPrivate = true,
|
|
572
599
|
) {
|
|
573
600
|
const client = useS3()
|
|
574
601
|
if (!client) return null
|
|
575
602
|
|
|
576
603
|
const config = useRuntimeConfig()
|
|
604
|
+
cache ||= getCacheControl(isPrivate)
|
|
577
605
|
|
|
578
|
-
if (isPrivate) {
|
|
579
|
-
await client.send(
|
|
580
|
-
new PutObjectCommand({
|
|
581
|
-
Bucket: config.s3.privateBucket,
|
|
582
|
-
Key: key,
|
|
583
|
-
Body: body,
|
|
584
|
-
ContentType: contentType,
|
|
585
|
-
CacheControl: cache || 'public, max-age=31536000',
|
|
586
|
-
}),
|
|
587
|
-
)
|
|
588
|
-
|
|
589
|
-
return getS3SignedUrl(key)
|
|
590
|
-
}
|
|
591
606
|
await client.send(
|
|
592
607
|
new PutObjectCommand({
|
|
593
|
-
Bucket: config.s3.publicBucket,
|
|
608
|
+
Bucket: isPrivate ? config.s3.privateBucket : config.s3.publicBucket,
|
|
594
609
|
Key: key,
|
|
595
610
|
Body: body,
|
|
596
611
|
ContentType: contentType,
|
|
597
|
-
CacheControl: cache
|
|
598
|
-
ACL: 'public-read',
|
|
612
|
+
CacheControl: cache,
|
|
613
|
+
ACL: isPrivate ? undefined : 'public-read',
|
|
599
614
|
}),
|
|
600
615
|
)
|
|
601
616
|
|
|
602
|
-
return getS3URL(key)
|
|
617
|
+
return isPrivate ? getFileURL(key) : getS3URL(key)
|
|
603
618
|
}
|
|
604
619
|
|
|
605
620
|
export async function uploadStreamToS3(
|
|
606
621
|
key: string,
|
|
607
622
|
body: Readable,
|
|
608
623
|
contentType: string,
|
|
609
|
-
cache
|
|
624
|
+
cache?: string,
|
|
610
625
|
isPrivate = true,
|
|
611
626
|
) {
|
|
612
627
|
const client = useS3()
|
|
613
628
|
if (!client) return null
|
|
614
629
|
|
|
615
630
|
const config = useRuntimeConfig()
|
|
631
|
+
cache ||= getCacheControl(isPrivate)
|
|
616
632
|
const partSize = 5 * 1024 * 1024 // S3 minimum, only the last part may be smaller
|
|
617
633
|
|
|
618
634
|
let uploadId: string | undefined
|
|
@@ -680,7 +696,7 @@ export async function initMultipartUploadToS3(
|
|
|
680
696
|
Bucket: isPrivate ? config.s3.privateBucket : config.s3.publicBucket,
|
|
681
697
|
Key: key,
|
|
682
698
|
ContentType: contentType,
|
|
683
|
-
CacheControl: cache,
|
|
699
|
+
CacheControl: cache || getCacheControl(isPrivate),
|
|
684
700
|
ACL: isPrivate ? undefined : 'public-read',
|
|
685
701
|
})
|
|
686
702
|
|
|
@@ -734,7 +750,7 @@ export async function completeMultipartUploadToS3(
|
|
|
734
750
|
})
|
|
735
751
|
|
|
736
752
|
await client.send(command)
|
|
737
|
-
return isPrivate ?
|
|
753
|
+
return isPrivate ? getFileURL(key) : getS3URL(key)
|
|
738
754
|
}
|
|
739
755
|
|
|
740
756
|
export async function deleteFromS3(path: string, isPrivate = true) {
|
|
@@ -774,7 +790,7 @@ export async function deleteFromS3(path: string, isPrivate = true) {
|
|
|
774
790
|
const result = await client.send(command)
|
|
775
791
|
if (result.Errors?.length) {
|
|
776
792
|
throw createError({
|
|
777
|
-
|
|
793
|
+
status: 502,
|
|
778
794
|
message: `Failed to delete ${result.Errors.length} file(s): ${result.Errors.map((e) => `${e.Key} (${e.Code})`).join(', ')}`,
|
|
779
795
|
})
|
|
780
796
|
}
|
|
@@ -800,17 +816,15 @@ export async function listFromS3(path: string, isPrivate = true) {
|
|
|
800
816
|
)
|
|
801
817
|
ContinuationToken = response.NextContinuationToken
|
|
802
818
|
files.push(
|
|
803
|
-
...(
|
|
804
|
-
(
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
})),
|
|
813
|
-
)),
|
|
819
|
+
...(response.Contents || [])
|
|
820
|
+
.filter((item) => item.Key === path || item.Key?.startsWith(`${path}/`))
|
|
821
|
+
.map((item) => ({
|
|
822
|
+
path: removeRoot(item.Key || '', isPrivate),
|
|
823
|
+
name: item.Key?.split('/').pop() || '',
|
|
824
|
+
url: isPrivate ? getFileURL(item.Key || '') : getS3URL(item.Key || ''),
|
|
825
|
+
updatedAt: item.LastModified ?? new Date(0),
|
|
826
|
+
size: item.Size || 0,
|
|
827
|
+
})),
|
|
814
828
|
)
|
|
815
829
|
} while (ContinuationToken)
|
|
816
830
|
|
|
@@ -829,6 +843,7 @@ export async function copyFromS3(src: string, dest: string, isPrivate = true, de
|
|
|
829
843
|
Bucket: bucket,
|
|
830
844
|
Key: dest,
|
|
831
845
|
CopySource: copySource,
|
|
846
|
+
ACL: isPrivate ? undefined : 'public-read',
|
|
832
847
|
}),
|
|
833
848
|
)
|
|
834
849
|
if (deleteSrc) {
|
package/locales/en.json
CHANGED
package/locales/fr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maxibase",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Nuxt layer with auth, files, logs and backups",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nuxt",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"@types/qrcode": "^1.5.6",
|
|
72
72
|
"drizzle-orm": "1.0.0-rc.3",
|
|
73
73
|
"goku-css": "^9.0.8",
|
|
74
|
+
"mime": "^4.1.1",
|
|
74
75
|
"nodemailer": "^10.1.7",
|
|
75
76
|
"nuxt-auth-utils": "^0.5.32",
|
|
76
77
|
"nuxt-i18n-micro": "^3.30.1",
|