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.
@@ -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 { authenticate } = useWebAuthn({
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(email.value)
150
+ await (autofill ? authenticateWithAutofill() : authenticate(userName))
144
151
  await refreshSession()
145
152
  } catch (e: any) {
146
- if (!e.message.startsWith('The operation either timed out')) {
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
- <slot
345
+ <div
333
346
  v-if="!optionsFetched"
334
- name="footer"
335
- ></slot>
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(files, props.path, props.isPrivate, (p) => {
61
- progress.value = p
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>
@@ -388,7 +388,7 @@ onMounted(getAuth)
388
388
  required
389
389
  />
390
390
  <button
391
- class="flex fg"
391
+ class="flex-center fg"
392
392
  type="button"
393
393
  @click="showPassword2 = !showPassword2"
394
394
  >
@@ -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.context.params?.path || ''
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
- let cache = `public, max-age=${maxAge || 31536000}`
30
- if (maxAge === 0) {
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
- .find((p) => p.name === name)
29
- ?.data.toString()
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 chunkPart = body.find((p) => p.name === 'chunk')
41
-
42
- if (!Number.isInteger(chunkIndex) || !Number.isInteger(totalChunks) || !filename || !chunkPart) {
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.data,
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
- src,
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
  })
@@ -1,10 +1,7 @@
1
1
  import { z } from 'zod'
2
2
 
3
3
  const querySchema = z.object({
4
- path: z
5
- .string()
6
- .min(1)
7
- .refine((p) => !p.includes('.')),
4
+ path: z.string().min(1),
8
5
  isPrivate: z
9
6
  .enum(['true', 'false'])
10
7
  .transform((v) => v === 'true')
@@ -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 pathPart = body.find((p) => p.name === 'path')
8
- const isPrivatePart = body.find((p) => p.name === 'isPrivate')
9
- const isPrivate = isPrivatePart ? isPrivatePart.data.toString() === 'true' : true
10
- const path = pathPart?.data.toString().trim()
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.filter((p) => p.name === 'file').map((file) => uploadFile(event, file, path, isPrivate)),
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({ statusCode: 401, message: 'Unauthorized' })
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({ statusCode: 403, message: 'Insufficient permissions' })
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.method !== 'GET')) return
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
- statusCode: 404,
4
+ status: 404,
5
5
  message: 'Not found',
6
6
  })
7
7
  }
8
8
 
9
- const templateId = event.context.params?.template || ''
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({ statusCode: 403, message: 'Insufficient permissions' })
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
- statusMessage: 'reauth_required',
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 type { Readable } from 'stream'
33
- import type { H3Event, MultiPartData } from 'h3'
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({ statusCode: 413, message: `File too large, max ${maxSize} MB` })
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({ statusCode: 411, message: 'Content-Length required' })
47
+ if (!length) throw createError({ status: 411, message: 'Content-Length required' })
47
48
  if (Number(length) > (maxMB + 1) * MB) throw fileTooLarge()
48
- return readMultipartFormData(event)
49
+ return readFormData(event).catch(() => undefined)
49
50
  }
50
51
 
51
52
  export async function uploadFile(
52
53
  event: H3Event,
53
- file: MultiPartData,
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 = getSecurePath(path, isPrivate)
59
- path = appendFilename(path, file.filename)
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
- file.data,
68
+ data,
67
69
  file.type || 'application/octet-stream',
68
- undefined,
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, file.data)
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 = getSecurePath(path, isPrivate)
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
- undefined,
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({ statusCode: 400, message: 'Invalid chunk index' })
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 = getSecurePath(path, isPrivate)
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(getS3Key(path), type, isPrivate))!
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({ statusCode: 400, message: 'Invalid upload id' })
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, { cache, headers: range ? { range } : undefined })
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({ statusCode: 416, message: 'Range not satisfiable' })
339
+ throw createError({ status: 416, message: 'Range not satisfiable' })
333
340
  }
334
- if (!response.ok) throw createError({ statusCode: 502, message: 'Failed to fetch file' })
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({ statusCode: 404, message: 'File not found' })
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({ statusCode: 416, message: 'Range not satisfiable' })
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
- return createReadStream(fullPath)
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 filesStats = await readdir(localPath).catch(() => [])
422
- return (
423
- await Promise.all(
424
- filesStats.map(async (name) => {
425
- const localeFilePath = join(localPath, name)
426
- const stats = await stat(localeFilePath)
427
- const rootedPath = join(path, name)
428
- const filePath = removeRoot(rootedPath, isPrivate)
429
- return stats.isDirectory()
430
- ? await listFolder(event, filePath, isPrivate)
431
- : {
432
- name,
433
- size: stats.size,
434
- updatedAt: stats.mtime,
435
- path: filePath,
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({ statusCode: 404, message: 'File not found' })
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({ statusCode: 400, message: 'Invalid path' })
524
+ return createError({ status: 400, message: 'Invalid path' })
515
525
  }
516
526
 
517
- function appendFilename(path: string, filename?: string) {
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 = 'public, max-age=31536000',
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 || 'public, max-age=31536000',
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 = 'public, max-age=31536000',
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 ? getS3SignedUrl(key) : getS3URL(key)
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
- statusCode: 502,
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
- ...(await Promise.all(
804
- (response.Contents || [])
805
- .filter((item) => item.Key === path || item.Key?.startsWith(`${path}/`))
806
- .map(async (item) => ({
807
- path: removeRoot(item.Key || '', isPrivate),
808
- name: item.Key?.split('/').pop() || '',
809
- url: isPrivate ? await getS3SignedUrl(item.Key || '') : getS3URL(item.Key || ''),
810
- updatedAt: item.LastModified ?? new Date(0),
811
- size: item.Size || 0,
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) {
@@ -11,5 +11,5 @@ export function getClientIP(event: H3Event): string {
11
11
  if (clientIP) return clientIP
12
12
  }
13
13
 
14
- return event.node.req.socket?.remoteAddress || 'unknown'
14
+ return getRequestIP(event) || 'unknown'
15
15
  }
package/locales/en.json CHANGED
@@ -15,6 +15,7 @@
15
15
  "fileTooLarge": "Too large, max {max} MB: {files}",
16
16
  "formatFailed": "Could not format or upload {files}",
17
17
  "language": "Language | Languages",
18
+ "or": "or",
18
19
  "paste": "Paste",
19
20
  "rename": "Rename",
20
21
  "retry": "Retry",
package/locales/fr.json CHANGED
@@ -15,6 +15,7 @@
15
15
  "fileTooLarge": "Trop volumineux, max {max} Mo : {files}",
16
16
  "formatFailed": "Impossible de formater ou téléverser {files}",
17
17
  "language": "Langue | Langues",
18
+ "or": "ou",
18
19
  "paste": "Coller",
19
20
  "rename": "Renommer",
20
21
  "retry": "Réessayer",
@@ -1,5 +1,4 @@
1
1
  {
2
2
  "continueAsGuest": "Continue as Guest",
3
- "or": "or",
4
3
  "signinWith": "Sign in with"
5
4
  }
@@ -1,5 +1,4 @@
1
1
  {
2
2
  "continueAsGuest": "Continuer en tant qu'invité",
3
- "or": "ou",
4
3
  "signinWith": "Se connecter avec"
5
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxibase",
3
- "version": "1.3.0",
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",