maxibase 1.4.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>
@@ -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
  >
@@ -15,7 +15,7 @@ const querySchema = z.object({
15
15
  })
16
16
 
17
17
  export default defineEventHandler(async (event) => {
18
- const path = event.context.params?.path || ''
18
+ const path = getRouterParam(event, 'path') || ''
19
19
  const {
20
20
  isPrivate = false,
21
21
  maxAge,
@@ -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'))
@@ -38,9 +37,14 @@ export default defineEventHandler(async (event) => {
38
37
  const isPrivate = get('isPrivate') !== 'false'
39
38
  const parts = parseParts(get('parts'))
40
39
  const maxAge = parseMaxAge(get('maxAge'))
41
- const chunkPart = body.find((p) => p.name === 'chunk')
42
-
43
- if (!Number.isInteger(chunkIndex) || !Number.isInteger(totalChunks) || !filename || !chunkPart) {
40
+ const chunkPart = body.get('chunk')
41
+
42
+ if (
43
+ !Number.isInteger(chunkIndex) ||
44
+ !Number.isInteger(totalChunks) ||
45
+ !filename ||
46
+ !(chunkPart instanceof File)
47
+ ) {
44
48
  throw invalidChunkUpload()
45
49
  }
46
50
 
@@ -50,7 +54,7 @@ export default defineEventHandler(async (event) => {
50
54
  totalChunks,
51
55
  filename,
52
56
  type,
53
- chunk: chunkPart.data,
57
+ chunk: Buffer.from(await chunkPart.arrayBuffer()),
54
58
  path,
55
59
  isPrivate,
56
60
  parts,
@@ -4,20 +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()
11
- const maxAge = parseMaxAge(
12
- body
13
- .find((p) => p.name === 'maxAge')
14
- ?.data.toString()
15
- .trim(),
16
- )
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'))
17
15
 
18
16
  return await Promise.all(
19
17
  body
20
- .filter((p) => p.name === 'file')
18
+ .getAll('file')
19
+ .filter((file) => file instanceof File)
21
20
  .map((file) => uploadFile(event, file, path, isPrivate, maxAge)),
22
21
  )
23
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,8 +29,8 @@ 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
35
  import mime from 'mime'
36
36
 
@@ -38,33 +38,34 @@ const MB = 1024 * 1024
38
38
 
39
39
  function fileTooLarge() {
40
40
  const { maxSize } = useRuntimeConfig().public.files
41
- return createError({ statusCode: 413, message: `File too large, max ${maxSize} MB` })
41
+ return createError({ status: 413, message: `File too large, max ${maxSize} MB` })
42
42
  }
43
43
 
44
44
  export async function readUploadBody(event: H3Event, maxMB: number) {
45
45
  await requireUserSession(event)
46
46
  const length = getRequestHeader(event, 'content-length')
47
- if (!length) throw createError({ statusCode: 411, message: 'Content-Length required' })
47
+ if (!length) throw createError({ status: 411, message: 'Content-Length required' })
48
48
  if (Number(length) > (maxMB + 1) * MB) throw fileTooLarge()
49
- return readMultipartFormData(event)
49
+ return readFormData(event).catch(() => undefined)
50
50
  }
51
51
 
52
52
  export async function uploadFile(
53
53
  event: H3Event,
54
- file: MultiPartData,
54
+ file: File,
55
55
  path = 'files',
56
56
  isPrivate = true,
57
57
  maxAge?: number, // only stored on S3 objects, local files get their headers from nginx
58
58
  ) {
59
59
  await checkFileAccess(event, path)
60
- path = getUploadPath(path, isPrivate, file.filename)
60
+ path = getUploadPath(path, isPrivate, file.name)
61
+ const data = Buffer.from(await file.arrayBuffer())
61
62
 
62
63
  let url
63
64
  if (useS3()) {
64
65
  const s3Key = getS3Key(path)
65
66
  url = await uploadToS3(
66
67
  s3Key,
67
- file.data,
68
+ data,
68
69
  file.type || 'application/octet-stream',
69
70
  getCacheControl(isPrivate, maxAge),
70
71
  isPrivate,
@@ -73,7 +74,7 @@ export async function uploadFile(
73
74
  const filePath = join(process.cwd(), path)
74
75
  const folderPath = filePath.replace(/\/[^/]*$/, '')
75
76
  await mkdir(folderPath, { recursive: true })
76
- await writeFile(filePath, file.data)
77
+ await writeFile(filePath, data)
77
78
 
78
79
  url = getFileURL(path, isPrivate)
79
80
  }
@@ -149,7 +150,7 @@ export async function uploadChunk(
149
150
  },
150
151
  ) {
151
152
  if (chunkIndex < 0 || chunkIndex >= totalChunks || totalChunks < 1) {
152
- throw createError({ statusCode: 400, message: 'Invalid chunk index' })
153
+ throw createError({ status: 400, message: 'Invalid chunk index' })
153
154
  }
154
155
  const { chunkSize, maxSize } = useRuntimeConfig().public.files
155
156
  const chunkBytes = (Number(chunkSize) || 1) * MB
@@ -186,7 +187,7 @@ export async function uploadChunk(
186
187
  uploadId = crypto.randomUUID()
187
188
  parts = []
188
189
  } else if (!isUUID(uploadId)) {
189
- throw createError({ statusCode: 400, message: 'Invalid upload id' })
190
+ throw createError({ status: 400, message: 'Invalid upload id' })
190
191
  }
191
192
 
192
193
  const tmpDir = join(process.cwd(), 'files', 'temp', uploadId)
@@ -335,10 +336,10 @@ export async function getFile(
335
336
  if (response.status === 416) {
336
337
  const contentRange = response.headers.get('content-range')
337
338
  if (contentRange) setHeader(event, 'Content-Range', contentRange)
338
- throw createError({ statusCode: 416, message: 'Range not satisfiable' })
339
+ throw createError({ status: 416, message: 'Range not satisfiable' })
339
340
  }
340
- if (response.status === 404) throw createError({ statusCode: 404, message: 'File not found' })
341
- 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' })
342
343
 
343
344
  const passthrough = {
344
345
  'Content-Type': response.headers.get('content-type'),
@@ -358,7 +359,7 @@ export async function getFile(
358
359
 
359
360
  const stats = await stat(fullPath).catch(() => null)
360
361
  if (!stats?.isFile()) {
361
- throw createError({ statusCode: 404, message: 'File not found' })
362
+ throw createError({ status: 404, message: 'File not found' })
362
363
  }
363
364
 
364
365
  setHeader(event, 'Content-Type', mime.getType(fullPath) || 'application/octet-stream')
@@ -367,7 +368,7 @@ export async function getFile(
367
368
  const parsedRange = parseRangeHeader(range, stats.size)
368
369
  if (parsedRange === 'unsatisfiable') {
369
370
  setHeader(event, 'Content-Range', `bytes */${stats.size}`)
370
- throw createError({ statusCode: 416, message: 'Range not satisfiable' })
371
+ throw createError({ status: 416, message: 'Range not satisfiable' })
371
372
  }
372
373
 
373
374
  if (parsedRange) {
@@ -375,11 +376,12 @@ export async function getFile(
375
376
  setResponseStatus(event, 206)
376
377
  setHeader(event, 'Content-Range', `bytes ${start}-${end}/${stats.size}`)
377
378
  setHeader(event, 'Content-Length', end - start + 1)
378
- return createReadStream(fullPath, { start, end })
379
+ return Readable.toWeb(createReadStream(fullPath, { start, end }))
379
380
  }
380
381
 
381
382
  setHeader(event, 'Content-Length', stats.size)
382
- return createReadStream(fullPath)
383
+ // h3 v2 only streams web streams
384
+ return Readable.toWeb(createReadStream(fullPath))
383
385
  }
384
386
 
385
387
  export async function deletePath(event: H3Event, path: string, isPrivate = true) {
@@ -467,7 +469,7 @@ export async function copyFile(
467
469
  const destPath = join(process.cwd(), dest)
468
470
  const stats = await stat(srcPath).catch(() => null)
469
471
  if (!stats?.isFile()) {
470
- throw createError({ statusCode: 404, message: 'File not found' })
472
+ throw createError({ status: 404, message: 'File not found' })
471
473
  }
472
474
  await mkdir(dirname(destPath), { recursive: true })
473
475
  await fsCopyFile(srcPath, destPath)
@@ -485,7 +487,7 @@ export async function copyFiles(
485
487
  deleteSrc = false,
486
488
  ) {
487
489
  const files = await listFolder(event, src, isPrivate)
488
- if (!files.length) throw createError({ statusCode: 404, message: 'File not found' })
490
+ if (!files.length) throw createError({ status: 404, message: 'File not found' })
489
491
  const normalizedSrc = removeRoot(getSecurePath(src, isPrivate), isPrivate)
490
492
  const normalizedDest = removeRoot(getSecurePath(dest, isPrivate), isPrivate)
491
493
  return Promise.all(
@@ -519,7 +521,7 @@ export function getSecurePath(path: string, isPrivate = true) {
519
521
  }
520
522
 
521
523
  function invalidPath() {
522
- return createError({ statusCode: 400, message: 'Invalid path' })
524
+ return createError({ status: 400, message: 'Invalid path' })
523
525
  }
524
526
 
525
527
  function getUploadPath(path: string, isPrivate: boolean, filename?: string) {
@@ -583,7 +585,7 @@ export function parseMaxAge(value?: string) {
583
585
  if (!value) return undefined
584
586
  const maxAge = Number(value)
585
587
  if (!Number.isInteger(maxAge) || maxAge < 0) {
586
- throw createError({ statusCode: 400, message: 'Invalid maxAge' })
588
+ throw createError({ status: 400, message: 'Invalid maxAge' })
587
589
  }
588
590
  return maxAge
589
591
  }
@@ -788,7 +790,7 @@ export async function deleteFromS3(path: string, isPrivate = true) {
788
790
  const result = await client.send(command)
789
791
  if (result.Errors?.length) {
790
792
  throw createError({
791
- statusCode: 502,
793
+ status: 502,
792
794
  message: `Failed to delete ${result.Errors.length} file(s): ${result.Errors.map((e) => `${e.Key} (${e.Code})`).join(', ')}`,
793
795
  })
794
796
  }
@@ -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.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Nuxt layer with auth, files, logs and backups",
5
5
  "keywords": [
6
6
  "nuxt",