maxibase 1.0.1 → 1.1.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.
Files changed (32) hide show
  1. package/README.md +20 -0
  2. package/docker/Dockerfile +3 -3
  3. package/layers/base/app/components/AuthCheck.vue +89 -50
  4. package/layers/base/app/components/files/FilesUpload.vue +9 -0
  5. package/layers/base/app/pages/user/auth.vue +3 -3
  6. package/layers/base/app/stores/app.ts +4 -9
  7. package/layers/base/nuxt.config.ts +4 -3
  8. package/layers/base/server/api/admin/logs.get.ts +1 -2
  9. package/layers/base/server/api/auth/otp/get.post.ts +0 -6
  10. package/layers/base/server/api/auth/otp/verify.post.ts +37 -39
  11. package/layers/base/server/api/auth/password.post.ts +2 -7
  12. package/layers/base/server/api/auth/refresh.post.ts +2 -2
  13. package/layers/base/server/api/auth/totp/confirm.post.ts +1 -7
  14. package/layers/base/server/api/auth/totp/verify.post.ts +15 -6
  15. package/layers/base/server/api/auth/webauthn/authenticate.post.ts +8 -2
  16. package/layers/base/server/api/files/[...path].get.ts +23 -14
  17. package/layers/base/server/api/files/chunk.post.ts +25 -4
  18. package/layers/base/server/api/files/index.get.ts +1 -3
  19. package/layers/base/server/api/files/index.post.ts +1 -1
  20. package/layers/base/server/middleware/0.rateLimit.ts +1 -1
  21. package/layers/base/server/routes/auth/microsoft.get.ts +30 -17
  22. package/layers/base/server/tasks/clean.ts +2 -1
  23. package/layers/base/server/utils/auth.ts +27 -5
  24. package/layers/base/server/utils/backup.ts +1 -1
  25. package/layers/base/server/utils/files.ts +129 -14
  26. package/layers/base/server/utils/rateLimit.ts +7 -2
  27. package/locales/en.json +3 -0
  28. package/locales/fr.json +3 -0
  29. package/locales/pages/user-auth/en.json +0 -1
  30. package/locales/pages/user-auth/fr.json +0 -1
  31. package/nuxt.config.ts +1 -0
  32. package/package.json +2 -2
package/README.md CHANGED
@@ -10,6 +10,21 @@ Setup your env file by editing the `.env.example` file and rename it to `.env`.
10
10
 
11
11
  Use the route `http://localhost:3000/dev/email/[templateId]` to see the email while you edit. You can put the locale or any parameter in the query.
12
12
 
13
+ ### Microsoft sign-in
14
+
15
+ In your Azure app registration, go to Token configuration → Add optional claim → ID and add
16
+ `email` and `xms_edov`. Without them every Microsoft sign-in is refused.
17
+
18
+ A Microsoft account signs in to the user with the same email, so the email is only trusted
19
+ when `xms_edov` says its domain is verified in the account's tenant. Any tenant can write any
20
+ address in a user's `mail`, so trusting it unverified lets them sign in as anyone.
21
+
22
+ The tenant defaults to `organizations`: any work or school account, no personal ones. Set
23
+ `NUXT_OAUTH_MICROSOFT_TENANT=common` to also accept personal accounts.
24
+
25
+ `/auth/microsoft` signs in with the Microsoft account already signed in, link to
26
+ `/auth/microsoft?prompt=select_account` to let the user pick another one.
27
+
13
28
  ## Deployment
14
29
 
15
30
  ### On your server
@@ -39,6 +54,9 @@ location /files/ {
39
54
  # Optional: Hide S3 headers for security
40
55
  proxy_hide_header x-amz-request-id;
41
56
  proxy_hide_header x-amz-id-2;
57
+
58
+ add_header X-Content-Type-Options nosniff always;
59
+ add_header Content-Security-Policy sandbox always;
42
60
  }
43
61
  ```
44
62
 
@@ -47,6 +65,8 @@ location /files/ {
47
65
  ```
48
66
  location /files/ {
49
67
  alias /home/debian/project/volumes/files/public/;
68
+ add_header X-Content-Type-Options nosniff always;
69
+ add_header Content-Security-Policy sandbox always;
50
70
  }
51
71
  ```
52
72
 
package/docker/Dockerfile CHANGED
@@ -1,6 +1,6 @@
1
- FROM node:24-alpine AS build
1
+ FROM node:26-alpine AS build
2
2
 
3
- RUN corepack enable && corepack prepare pnpm@latest --activate
3
+ RUN npm install -g corepack && corepack enable
4
4
 
5
5
  WORKDIR /app
6
6
 
@@ -12,7 +12,7 @@ COPY . ./
12
12
 
13
13
  RUN pnpm build
14
14
 
15
- FROM node:24-alpine
15
+ FROM node:26-alpine
16
16
  WORKDIR /app
17
17
 
18
18
  RUN apk add --no-cache postgresql-client age
@@ -112,15 +112,25 @@ function handleError(error: any) {
112
112
  appStore.notify(error.data?.message || 'Bad credentials', 'error')
113
113
  }
114
114
 
115
+ const signInForm = useTemplateRef<HTMLFormElement>('signInForm')
116
+ let signInTimer: ReturnType<typeof setTimeout> | undefined
117
+
118
+ watch(totp, (value) => {
119
+ const digits = value.replace(/\s/g, '')
120
+ if (digits !== value) totp.value = digits
121
+ })
122
+
123
+ // Paste, change and submit can fire together: wait for the pasted value, then sign in once
115
124
  function signIn() {
116
- if (!email.value) return
117
- if (options.value.hasTOTP && !totp.value) return
118
- setTimeout(() => {
125
+ clearTimeout(signInTimer)
126
+ signInTimer = setTimeout(() => {
127
+ if (!email.value || isLoading.value) return
128
+ // paste and change bypass the browser's check, which only guards the submit
129
+ if (!signInForm.value?.reportValidity()) return
119
130
  if (totp.value) signInWithTOTP()
120
- else if (!password.value) requestOtp()
121
131
  else if (password.value.length === 6) signInWithOtp()
122
132
  else signInWithPassword()
123
- }, 100) // wait for paste event to complete
133
+ }, 100)
124
134
  }
125
135
 
126
136
  const { authenticate } = useWebAuthn({
@@ -162,9 +172,11 @@ async function getSignInOptions(email: string) {
162
172
  }
163
173
  }
164
174
 
165
- async function pastePassword() {
175
+ const pasteFields = { password, totp }
176
+
177
+ async function pasteTo(field: keyof typeof pasteFields) {
166
178
  try {
167
- password.value = (await navigator.clipboard.readText()).trim()
179
+ pasteFields[field].value = (await navigator.clipboard.readText()).trim()
168
180
  signIn()
169
181
  } catch (e) {
170
182
  appStore.notify($t('authCheck.clipboardPermission') as string, 'error')
@@ -188,6 +200,7 @@ onMounted(async () => {
188
200
  <slot name="header"></slot>
189
201
  <form
190
202
  v-if="optionsFetched"
203
+ ref="signInForm"
191
204
  class="flex-column g2"
192
205
  @submit.prevent="signIn"
193
206
  >
@@ -197,52 +210,69 @@ onMounted(async () => {
197
210
  :value="email"
198
211
  autocomplete="username email"
199
212
  />
200
- <label for="password">
201
- {{
202
- options.hasPassword && !otpRequested ? $t('authCheck.password') : $t('authCheck.code')
203
- }}
204
- </label>
205
- <div class="group flex-row">
206
- <input
207
- id="password"
208
- v-model.trim="password"
209
- :type="showPassword ? 'text' : 'password'"
210
- :disabled="isLoading"
211
- autocomplete="current-password"
212
- :pattern="
213
- otpRequested
214
- ? '[0-9]{6}'
215
- : `.{${config.public.password.min},${config.public.password.max}}`
216
- "
217
- @paste="signIn"
218
- />
219
- <button
220
- type="button"
221
- class="flex-center fg"
222
- @click="showPassword = !showPassword"
223
- >
224
- <Icon :name="showPassword ? 'lucide:eye-off' : 'lucide:eye'" />
225
- </button>
226
- <button
227
- type="button"
228
- class="flex-center fg"
229
- :title="$t('paste') as string"
230
- @click="pastePassword"
231
- >
232
- <Icon name="lucide:clipboard-paste" />
233
- </button>
213
+ <div>
214
+ <label for="password">
215
+ {{
216
+ options.hasPassword && !otpRequested ? $t('authCheck.password') : $t('authCheck.code')
217
+ }}
218
+ </label>
219
+ <div class="group flex-row">
220
+ <input
221
+ id="password"
222
+ v-model.trim="password"
223
+ :type="showPassword ? 'text' : 'password'"
224
+ :disabled="isLoading"
225
+ autocomplete="current-password"
226
+ :pattern="
227
+ otpRequested
228
+ ? '[0-9]{6}'
229
+ : `[0-9]{6}|.{${config.public.password.min},${config.public.password.max}}`
230
+ "
231
+ required
232
+ @paste="signIn"
233
+ />
234
+ <button
235
+ type="button"
236
+ class="flex-center fg"
237
+ @click="showPassword = !showPassword"
238
+ >
239
+ <Icon :name="showPassword ? 'lucide:eye-off' : 'lucide:eye'" />
240
+ </button>
241
+ <button
242
+ type="button"
243
+ class="flex-center fg"
244
+ :title="$t('paste') as string"
245
+ @click="pasteTo('password')"
246
+ >
247
+ <Icon name="lucide:clipboard-paste" />
248
+ </button>
249
+ </div>
234
250
  </div>
235
251
  <div v-if="options.hasTOTP">
236
252
  <label for="totp">{{ $t('authCheck.authenticatorCode') }}</label>
237
- <input
238
- id="totp"
239
- v-model.trim="totp"
240
- type="text"
241
- :disabled="isLoading || !password"
242
- autocomplete="one-time-code"
243
- @paste="signInWithTOTP"
244
- @change="signInWithTOTP"
245
- />
253
+ <div class="group flex-row">
254
+ <input
255
+ id="totp"
256
+ v-model.trim="totp"
257
+ type="text"
258
+ :disabled="isLoading || !password"
259
+ autocomplete="one-time-code"
260
+ inputmode="numeric"
261
+ pattern="[0-9]{6}"
262
+ required
263
+ @paste="signIn"
264
+ @change="signIn"
265
+ />
266
+ <button
267
+ type="button"
268
+ class="flex-center fg"
269
+ :title="$t('paste') as string"
270
+ :disabled="isLoading || !password"
271
+ @click="pasteTo('totp')"
272
+ >
273
+ <Icon name="lucide:clipboard-paste" />
274
+ </button>
275
+ </div>
246
276
  </div>
247
277
  <button
248
278
  type="submit"
@@ -251,6 +281,15 @@ onMounted(async () => {
251
281
  >
252
282
  {{ $t('authCheck.signin') }}
253
283
  </button>
284
+ <button
285
+ v-if="options.hasPasskey"
286
+ type="button"
287
+ :disabled="isLoading"
288
+ class="flex-center g1 w mt1 bg bg-border"
289
+ @click="signInWithPasskey"
290
+ >
291
+ {{ $t('authCheck.passkey') }}
292
+ </button>
254
293
  <button
255
294
  v-if="!otpRequested"
256
295
  type="button"
@@ -47,6 +47,15 @@ async function uploadFile(event: Event) {
47
47
  appStore.notify('formatFailed', 'error', false, { files: failed.join(', ') })
48
48
  }
49
49
  }
50
+ const maxSize = Number(useRuntimeConfig().public.files.maxSize)
51
+ const tooLarge = files.filter((file) => file.size > maxSize * 1024 * 1024)
52
+ if (tooLarge.length) {
53
+ appStore.notify('fileTooLarge', 'error', false, {
54
+ files: tooLarge.map((file) => file.name).join(', '),
55
+ max: maxSize,
56
+ })
57
+ files = files.filter((file) => !tooLarge.includes(file))
58
+ }
50
59
  if (files.length) {
51
60
  let response = await uploadFiles(files, props.path, props.isPrivate, (p) => {
52
61
  progress.value = p
@@ -38,7 +38,7 @@ const { $getLocale, $t } = useI18n()
38
38
  const isMfaSetup = computed(() => user.value?.requiresMfaSetup === true)
39
39
 
40
40
  async function checkAuth() {
41
- return isMfaSetup.value || (await appStore.checkAuth())
41
+ return appStore.checkAuth()
42
42
  }
43
43
 
44
44
  const isCurrentEmail = computed(() => {
@@ -424,14 +424,14 @@ onMounted(getAuth)
424
424
  />
425
425
  <button
426
426
  type="button"
427
- class="bg danger-text flex"
427
+ class="bg danger-text flex-center"
428
428
  :disabled="!canRemovePasskey"
429
429
  :title="canRemovePasskey ? '' : $t('lastFactor')"
430
430
  @click="deletePasskey(credential.id)"
431
431
  >
432
432
  <Icon name="lucide:trash" />
433
433
  </button>
434
- <button class="flex fg"><Icon name="lucide:save" /></button>
434
+ <button class="flex-center fg"><Icon name="lucide:save" /></button>
435
435
  </div>
436
436
  </form>
437
437
  <button
@@ -15,7 +15,7 @@ export const useAppStore = defineStore('app', () => {
15
15
  reject: (value?: any) => any
16
16
  } | null>(null)
17
17
 
18
- const authVerifiedAt = ref(0)
18
+ const { session } = useUserSession()
19
19
  const authPromise = ref()
20
20
 
21
21
  function confirm(message?: string) {
@@ -32,19 +32,14 @@ export const useAppStore = defineStore('app', () => {
32
32
 
33
33
  async function checkAuth(): Promise<boolean> {
34
34
  const reverifyAfter = useRuntimeConfig().public.recentAuth.maxAge * 1000
35
- if (Date.now() - authVerifiedAt.value < reverifyAfter) {
35
+ if (Date.now() - (session.value?.authenticatedAt ?? 0) < reverifyAfter) {
36
36
  return true
37
37
  }
38
38
  return new Promise<boolean>((resolve) => {
39
39
  authPromise.value = { resolve }
40
+ }).finally(() => {
41
+ authPromise.value = null
40
42
  })
41
- .then((verified) => {
42
- if (verified) authVerifiedAt.value = Date.now()
43
- return verified
44
- })
45
- .finally(() => {
46
- authPromise.value = null
47
- })
48
43
  }
49
44
 
50
45
  function setLoading(state: boolean) {
@@ -27,6 +27,7 @@ export default defineNuxtConfig({
27
27
  files: {
28
28
  url: '/files',
29
29
  chunkSize: 5, // in MB
30
+ maxSize: 100, // in MB
30
31
  },
31
32
  },
32
33
  session: {
@@ -38,7 +39,7 @@ export default defineNuxtConfig({
38
39
  microsoft: {
39
40
  clientId: '',
40
41
  clientSecret: '',
41
- tenant: 'common',
42
+ tenant: 'organizations',
42
43
  },
43
44
  },
44
45
  refreshToken: {
@@ -66,7 +67,7 @@ export default defineNuxtConfig({
66
67
  secretAccessKey: '',
67
68
  },
68
69
  backup: {
69
- dumpArgs: '-F c',
70
+ dumpArgs: '-F c --exclude-table-data=auth.refresh_tokens',
70
71
  retentionDays: 30,
71
72
  agePublicKey: '', // age public key to encrypt backups
72
73
  },
@@ -88,7 +89,7 @@ export default defineNuxtConfig({
88
89
  },
89
90
  scheduledTasks: {
90
91
  '0 3 * * *': 'backup',
91
- '0 2 1 * *': 'clean',
92
+ '0 2 * * *': 'clean',
92
93
  },
93
94
  storage: {
94
95
  auth: {
@@ -9,8 +9,7 @@ const querySchema = z.object({
9
9
  })
10
10
 
11
11
  export default defineEventHandler(async (event) => {
12
- const query = getQuery(event)
13
- const { from, to, search, type, limit } = querySchema.parse(query)
12
+ const { from, to, search, type, limit } = await getValidatedQuery(event, querySchema.parse)
14
13
  const text = prepareStringForSearch(search)
15
14
 
16
15
  const rows = await db.query.logs.findMany({
@@ -22,12 +22,6 @@ export default defineEventHandler(async (event) => {
22
22
  goto,
23
23
  path = 'signin',
24
24
  } = await readValidatedBody(event, bodySchema.parse)
25
- if (!email || typeof email !== 'string') {
26
- throw createError({
27
- status: 400,
28
- message: 'Email is required',
29
- })
30
- }
31
25
 
32
26
  const storage = useStorage('auth')
33
27
  const record = await storage.getItem<OTP>(email)
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import type { H3Event } from 'h3'
2
3
  import type { OTP } from './get.post'
3
4
 
4
5
  const bodySchema = z.object({
@@ -8,47 +9,44 @@ const bodySchema = z.object({
8
9
 
9
10
  export default defineEventHandler(async (event) => {
10
11
  const { email, otp } = await readValidatedBody(event, bodySchema.parse)
11
- if (!email || !otp) {
12
- throw createError({
13
- status: 400,
14
- message: 'Email and OTP are required',
15
- })
16
- }
17
- await verifyOTP(email, otp)
18
12
 
19
- const session = await getUserSession(event)
20
- if (session?.user?.isAnonymous) {
21
- let user: any = await getAuth(event, email).catch(() => null)
22
- if (!user) {
23
- user = {
24
- id: session.user.id,
25
- email,
26
- }
27
- await setAuth(user)
28
- }
29
- return await setSession(event, user)
30
- }
13
+ const existing = await getAuth(event, email, true).catch((error) => {
14
+ if (error.statusCode !== 404) throw error
15
+ return null
16
+ })
17
+ if (existing) requireSecondFactor(existing)
31
18
 
32
- const user = await getAuth(event, email).catch((error) => {
33
- const config = useRuntimeConfig()
19
+ await verifyOTP(email, otp)
34
20
 
35
- if (error.statusCode === 404 && config.autoSignup) {
36
- return createAuth({ email })
37
- }
38
- throw error
39
- })
40
- if (user.totp) {
41
- throw createError({
42
- status: 401,
43
- message: 'TOTP required',
44
- })
45
- }
46
- await setSession(event, user)
21
+ const user = existing ?? (await signUp(event, email))
22
+ requireSecondFactor(user) // an anonymous account can already have a TOTP
23
+ return setSession(event, user)
47
24
  })
48
25
 
26
+ async function signUp(event: H3Event, email: string) {
27
+ const session = await getUserSession(event)
28
+ if (session?.user?.isAnonymous) return setAuth({ id: session.user.id, email })
29
+ if (useRuntimeConfig().autoSignup) return createAuth({ email })
30
+ throw createError({ status: 404, message: 'User not found' })
31
+ }
32
+
49
33
  const MAX_OTP_ATTEMPTS = 3
50
34
 
51
- export async function verifyOTP(email: string, otp: string): Promise<void> {
35
+ const verifying = new Set<string>()
36
+
37
+ export async function verifyOTP(email: string, otp: string, consume = true): Promise<void> {
38
+ if (verifying.has(email)) {
39
+ throw createError({ status: 429, message: 'Verification already in progress.' })
40
+ }
41
+ verifying.add(email)
42
+ try {
43
+ await checkOTP(email, otp, consume)
44
+ } finally {
45
+ verifying.delete(email)
46
+ }
47
+ }
48
+
49
+ async function checkOTP(email: string, otp: string, consume: boolean) {
52
50
  const storage = useStorage('auth')
53
51
  const record = await storage.getItem<OTP>(email)
54
52
  if (!record) {
@@ -59,12 +57,12 @@ export async function verifyOTP(email: string, otp: string): Promise<void> {
59
57
  throw createError({ status: 400, message: 'Request a new OTP.' })
60
58
  }
61
59
 
60
+ record.attempts++
61
+ await storage.setItem(email, record)
62
+
62
63
  const hash = otp.length > 6 ? record.token : record.otp
63
- if (hash && (await verifyPassword(hash, otp))) {
64
- await storage.removeItem(email)
65
- } else {
66
- record.attempts++
67
- await storage.setItem(email, record)
64
+ if (!hash || !(await verifyPassword(hash, otp))) {
68
65
  throw createError({ status: 400, message: 'Invalid OTP.' })
69
66
  }
67
+ if (consume) await storage.removeItem(email)
70
68
  }
@@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
16
16
  }).parse,
17
17
  )
18
18
 
19
- const user = await getAuth(event, email).catch((error: any) => {
19
+ const user = await getAuth(event, email, true).catch((error: any) => {
20
20
  if (error?.statusCode === 404) return null
21
21
  throw error
22
22
  })
@@ -31,12 +31,7 @@ export default defineEventHandler(async (event) => {
31
31
  throw badCredentials()
32
32
  }
33
33
 
34
- if (user.totp) {
35
- throw createError({
36
- status: 401,
37
- message: 'TOTP required',
38
- })
39
- }
34
+ requireSecondFactor(user)
40
35
 
41
36
  return setSession(event, user)
42
37
  })
@@ -1,5 +1,5 @@
1
1
  export default defineEventHandler(async (event) => {
2
- const { user, refreshToken } = await verifyRefreshToken(event)
3
- await revokeRefreshToken(refreshToken.token)
2
+ const { user } = await verifyRefreshToken(event)
3
+ await revokeRefreshToken(getCookie(event, 'refresh_token')!) // verified above, so it is set
4
4
  await createRefreshToken(user.id, event)
5
5
  })
@@ -10,16 +10,10 @@ const bodySchema = z.object({
10
10
 
11
11
  export default defineEventHandler(async (event) => {
12
12
  const { secret, token } = await readValidatedBody(event, bodySchema.parse)
13
- if (!secret || !token) {
14
- throw createError({
15
- status: 400,
16
- message: 'Missing required fields',
17
- })
18
- }
19
13
 
20
14
  const { user } = await requireRecentAuth(event)
21
15
 
22
- if (await verify({ secret, token })) {
16
+ if ((await verify({ secret, token })).valid) {
23
17
  await db.update(auth).set({ totp: secret }).where(eq(auth.id, user.id))
24
18
  await setSession(event, user, false) // refresh requiresMfaSetup
25
19
  } else throw createError({ status: 400, message: 'Invalid code.' })
@@ -12,25 +12,34 @@ export default defineEventHandler(async (event) => {
12
12
  token: z.string().length(6),
13
13
  }).parse,
14
14
  )
15
- if (!email || !(otp || password) || !token) {
15
+ if (!otp && !password) {
16
16
  throw createError({
17
17
  status: 400,
18
18
  message: 'Missing required fields',
19
19
  })
20
20
  }
21
21
 
22
- const user = await getAuth(event, email)
22
+ const user = await getAuth(event, email).catch((error: any) => {
23
+ if (error?.statusCode === 404) return null
24
+ throw error
25
+ })
23
26
 
24
- if (otp) await verifyOTP(email, otp)
25
- else if (!user.password || !(await verifyPassword(user.password, password!))) {
27
+ if (otp) await verifyOTP(email, otp, false)
28
+ else if (!user?.password) {
29
+ await hashPassword(password!)
30
+ throw createError({ status: 400, message: 'Invalid password.' })
31
+ } else if (!(await verifyPassword(user.password, password!))) {
26
32
  throw createError({ status: 400, message: 'Invalid password.' })
27
33
  }
28
34
 
29
- if (!user.totp) {
35
+ if (!user?.totp) {
30
36
  throw createError({ status: 400, message: 'TOTP is not enabled.' })
31
37
  }
32
38
 
33
- if (await verify({ secret: user.totp, token })) {
39
+ await enforceRateLimit(event, 'totp', { limit: 5, window: 900 }, user.id)
40
+
41
+ if ((await verify({ secret: user.totp, token })).valid) {
42
+ if (otp) await useStorage('auth').removeItem(email)
34
43
  await setSession(event, user)
35
44
  } else throw createError({ status: 400, message: 'Invalid TOTP.' })
36
45
  })
@@ -1,5 +1,5 @@
1
1
  import { eq } from 'drizzle-orm'
2
- import { auth } from '#server/database/schema'
2
+ import { auth, credentials } from '#server/database/schema'
3
3
 
4
4
  export default defineWebAuthnAuthenticateEventHandler({
5
5
  async storeChallenge(event, challenge, attemptId) {
@@ -28,7 +28,13 @@ export default defineWebAuthnAuthenticateEventHandler({
28
28
  if (!credential) throw createError({ status: 400, message: 'Credential not found' })
29
29
  return credential as any
30
30
  },
31
- async onSuccess(event, { credential }) {
31
+ async onSuccess(event, { credential, authenticationInfo }) {
32
+ // the next assertion must report a higher counter, which exposes a cloned authenticator
33
+ await db
34
+ .update(credentials)
35
+ .set({ counter: authenticationInfo.newCounter })
36
+ .where(eq(credentials.id, credential.id))
37
+
32
38
  const user = await db
33
39
  .select()
34
40
  .from(auth)
@@ -5,19 +5,26 @@ const querySchema = z.object({
5
5
  .enum(['true', 'false'])
6
6
  .transform((v) => v === 'true')
7
7
  .optional(),
8
- maxAge: z.coerce.number().optional(),
9
- serverCache: z.string().optional(),
10
- expireIn: z.coerce.number().optional(),
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
+ // S3 refuses to presign a URL for longer than 7 days
12
+ expireIn: z.coerce.number().int().positive().max(604800).optional(),
11
13
  redirect: z
12
14
  .enum(['true', 'false'])
13
15
  .transform((v) => v === 'true')
14
16
  .optional(),
15
17
  })
16
18
 
17
- export default defineEventHandler((event) => {
19
+ export default defineEventHandler(async (event) => {
18
20
  const path = event.context.params?.path || ''
19
- const query = getQuery(event)
20
- const { isPrivate = false, maxAge, serverCache, expireIn, redirect } = querySchema.parse(query)
21
+ const {
22
+ isPrivate = false,
23
+ maxAge,
24
+ serverCache,
25
+ expireIn,
26
+ redirect,
27
+ } = await getValidatedQuery(event, querySchema.parse)
21
28
 
22
29
  let cache = `public, max-age=${maxAge || 31536000}`
23
30
  if (maxAge === 0) {
@@ -26,12 +33,14 @@ export default defineEventHandler((event) => {
26
33
  cache = `private, max-age=${maxAge || 3600}`
27
34
  }
28
35
  setHeader(event, 'Cache-Control', cache)
29
- return getFile(
30
- event,
31
- decodeURIComponent(path),
32
- isPrivate,
33
- serverCache as RequestCache,
34
- expireIn,
35
- redirect,
36
- )
36
+ return getFile(event, decodePath(path), isPrivate, serverCache, expireIn, redirect)
37
37
  })
38
+
39
+ // h3 decodes the path but keeps %25 encoded, so a % in a name needs this second pass
40
+ function decodePath(path: string) {
41
+ try {
42
+ return decodeURIComponent(path)
43
+ } catch {
44
+ throw createError({ status: 400, message: 'Invalid path' }) // a % not followed by hex
45
+ }
46
+ }
@@ -1,5 +1,26 @@
1
+ import { z } from 'zod'
2
+
3
+ const partsSchema = z.array(
4
+ z.object({
5
+ ETag: z.string().optional(), // absent on local uploads
6
+ PartNumber: z.number().int().positive(),
7
+ }),
8
+ )
9
+
10
+ function invalidChunkUpload() {
11
+ return createError({ status: 400, message: 'Invalid chunk upload' })
12
+ }
13
+
14
+ export function parseParts(value?: string) {
15
+ try {
16
+ return partsSchema.parse(JSON.parse(value || '[]'))
17
+ } catch {
18
+ throw invalidChunkUpload()
19
+ }
20
+ }
21
+
1
22
  export default defineEventHandler(async (event) => {
2
- const body = await readMultipartFormData(event)
23
+ const body = await readUploadBody(event, Number(useRuntimeConfig().public.files.chunkSize) || 1)
3
24
  if (!body) throw createError({ status: 400, message: 'No data' })
4
25
 
5
26
  const get = (name: string) =>
@@ -15,11 +36,11 @@ export default defineEventHandler(async (event) => {
15
36
  const type = get('type') || 'application/octet-stream'
16
37
  const path = get('path') || 'files'
17
38
  const isPrivate = get('isPrivate') !== 'false'
18
- const parts = get('parts') ? JSON.parse(get('parts') || '[]') : []
39
+ const parts = parseParts(get('parts'))
19
40
  const chunkPart = body.find((p) => p.name === 'chunk')
20
41
 
21
- if (isNaN(chunkIndex) || isNaN(totalChunks) || !filename || !chunkPart) {
22
- throw createError({ status: 400, message: 'Invalid chunk upload' })
42
+ if (!Number.isInteger(chunkIndex) || !Number.isInteger(totalChunks) || !filename || !chunkPart) {
43
+ throw invalidChunkUpload()
23
44
  }
24
45
 
25
46
  return uploadChunk(event, {
@@ -12,8 +12,6 @@ const querySchema = z.object({
12
12
  })
13
13
 
14
14
  export default defineEventHandler(async (event) => {
15
- const query = getQuery(event)
16
-
17
- const { path, isPrivate = false } = querySchema.parse(query)
15
+ const { path, isPrivate = false } = await getValidatedQuery(event, querySchema.parse)
18
16
  return listFolder(event, path, isPrivate)
19
17
  })
@@ -1,5 +1,5 @@
1
1
  export default defineEventHandler(async (event) => {
2
- const body = await readMultipartFormData(event)
2
+ const body = await readUploadBody(event, Number(useRuntimeConfig().public.files.maxSize))
3
3
  if (!body) {
4
4
  throw createError({ status: 400, message: 'No file uploaded' })
5
5
  }
@@ -4,7 +4,7 @@ export default defineEventHandler(async (event) => {
4
4
  const config = useRuntimeConfig()
5
5
  if (!config.rateLimit.enabled) return
6
6
 
7
- const path = getRequestURL(event).pathname
7
+ const path = event.path.split('?')[0]!
8
8
  const routes = config.rateLimit.routes as Record<string, RateLimitRule>
9
9
 
10
10
  let matchedRoute = ''
@@ -1,21 +1,34 @@
1
- export default defineOAuthMicrosoftEventHandler({
2
- async onSuccess(event, { user }) {
3
- if (!user.mail) {
4
- throw createError({
5
- status: 401,
6
- message: 'This Microsoft account has no email address',
7
- })
8
- }
1
+ import type { H3Event } from 'h3'
9
2
 
10
- const auth = await getAuth(event, user.mail).catch((error) => {
11
- const config = useRuntimeConfig()
3
+ const scope = ['openid', 'email', 'User.Read']
12
4
 
13
- if (error.statusCode === 404 && config.autoSignup) {
14
- return createAuth({ email: user.mail })
15
- }
16
- throw error
5
+ async function onSuccess(event: H3Event, { tokens }: { tokens: any }) {
6
+ const claims = JSON.parse(Buffer.from(tokens.id_token.split('.')[1], 'base64url').toString())
7
+ if (!claims.email || ![true, '1'].includes(claims.xms_edov)) {
8
+ throw createError({
9
+ status: 401,
10
+ message: 'This Microsoft account has no verified email address',
17
11
  })
18
- await setSession(event, auth)
19
- return sendRedirect(event, '/')
20
- },
12
+ }
13
+
14
+ const auth = await getAuth(event, claims.email).catch((error) => {
15
+ const config = useRuntimeConfig()
16
+
17
+ if (error.statusCode === 404 && config.autoSignup) {
18
+ return createAuth({ email: claims.email })
19
+ }
20
+ throw error
21
+ })
22
+ await setSession(event, auth)
23
+ return sendRedirect(event, '/')
24
+ }
25
+
26
+ const signIn = defineOAuthMicrosoftEventHandler({ config: { scope }, onSuccess })
27
+ const selectAccount = defineOAuthMicrosoftEventHandler({
28
+ config: { scope, authorizationParams: { prompt: 'select_account' } },
29
+ onSuccess,
21
30
  })
31
+
32
+ export default defineEventHandler((event) =>
33
+ getQuery(event).prompt === 'select_account' ? selectAccount(event) : signIn(event),
34
+ )
@@ -1,7 +1,7 @@
1
1
  export default defineTask({
2
2
  meta: {
3
3
  name: 'clean',
4
- description: 'Clean old backups and logs',
4
+ description: 'Clean old backups, logs and unfinished uploads',
5
5
  },
6
6
  async run() {
7
7
  const config = useRuntimeConfig()
@@ -21,6 +21,7 @@ export default defineTask({
21
21
  )
22
22
  }
23
23
  await cleanLogs()
24
+ await cleanTempUploads()
24
25
 
25
26
  return {
26
27
  result: {
@@ -1,6 +1,6 @@
1
1
  import { auth, refreshTokens } from '#server/database/schema'
2
2
  import { eq, lte } from 'drizzle-orm'
3
- import { randomBytes } from 'crypto'
3
+ import { createHash, randomBytes } from 'crypto'
4
4
  import type { H3Event } from 'h3'
5
5
 
6
6
  type AuthUser = typeof auth.$inferSelect
@@ -17,6 +17,21 @@ export async function mfaSetupFlag(user: SessionUser) {
17
17
  return { requiresMfaSetup: !row?.totp && !row?.credentials.length }
18
18
  }
19
19
 
20
+ export function requireSecondFactor(user: Pick<AuthUser, 'totp'> & { credentials?: unknown[] }) {
21
+ if (user.totp) {
22
+ throw createError({
23
+ status: 401,
24
+ message: 'TOTP required',
25
+ })
26
+ }
27
+ if (useRuntimeConfig().forceMfa && user.credentials?.length) {
28
+ throw createError({
29
+ status: 401,
30
+ message: 'Passkey required',
31
+ })
32
+ }
33
+ }
34
+
20
35
  export async function createAuth(user: Pick<AuthUser, 'email'>) {
21
36
  const insertedUsers = await db
22
37
  .insert(auth)
@@ -112,7 +127,7 @@ export async function setSession(event: H3Event, user: SessionUser, refresh = tr
112
127
  export async function requireRecentAuth(event: H3Event) {
113
128
  const session = await requireUserSession(event)
114
129
 
115
- if (session.user.isAnonymous || session.user.requiresMfaSetup) return session
130
+ if (session.user.isAnonymous) return session
116
131
 
117
132
  const maxAge = useRuntimeConfig().public.recentAuth.maxAge * 1000
118
133
  if (!session.authenticatedAt || Date.now() - session.authenticatedAt > maxAge) {
@@ -130,12 +145,16 @@ export function generateRefreshToken(): string {
130
145
  return randomBytes(32).toString('base64url')
131
146
  }
132
147
 
148
+ export function hashRefreshToken(token: string) {
149
+ return createHash('sha256').update(token).digest('base64url')
150
+ }
151
+
133
152
  export async function createRefreshToken(userId: string, event: H3Event) {
134
153
  const token = generateRefreshToken()
135
154
 
136
155
  await db.insert(refreshTokens).values({
137
156
  userId,
138
- token,
157
+ token: hashRefreshToken(token),
139
158
  })
140
159
 
141
160
  const config = useRuntimeConfig()
@@ -158,7 +177,9 @@ export async function verifyRefreshToken(event: H3Event) {
158
177
  })
159
178
  }
160
179
 
161
- const refreshToken = await db.query.refreshTokens.findFirst({ where: { token: existingToken } })
180
+ const refreshToken = await db.query.refreshTokens.findFirst({
181
+ where: { token: hashRefreshToken(existingToken) },
182
+ })
162
183
  if (!refreshToken) {
163
184
  deleteCookie(event, 'refresh_token')
164
185
  throw createError({
@@ -194,8 +215,9 @@ export async function verifyRefreshToken(event: H3Event) {
194
215
  return { refreshToken, user }
195
216
  }
196
217
 
218
+ // Takes the token from the cookie, never the stored value
197
219
  export async function revokeRefreshToken(token: string) {
198
- await db.delete(refreshTokens).where(eq(refreshTokens.token, token))
220
+ await db.delete(refreshTokens).where(eq(refreshTokens.token, hashRefreshToken(token)))
199
221
  }
200
222
 
201
223
  export async function revokeExpiredRefreshToken(creationExpiredAt: Date) {
@@ -37,7 +37,7 @@ function waitForExit(child: ChildProcess, name: string) {
37
37
  export async function createDatabaseBackup(
38
38
  backupName: string,
39
39
  dbUrl: string,
40
- dumpArgs: string = '-F c',
40
+ dumpArgs: string,
41
41
  agePublicKey?: string,
42
42
  ) {
43
43
  const { conn, env } = splitCredentials(dbUrl)
@@ -8,6 +8,7 @@ import {
8
8
  appendFile,
9
9
  rename,
10
10
  readFile as fsReadFile,
11
+ copyFile as fsCopyFile,
11
12
  } from 'fs/promises'
12
13
  import {
13
14
  S3Client,
@@ -21,6 +22,7 @@ import {
21
22
  UploadPartCommand,
22
23
  CompleteMultipartUploadCommand,
23
24
  AbortMultipartUploadCommand,
25
+ ListMultipartUploadsCommand,
24
26
  } from '@aws-sdk/client-s3'
25
27
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
26
28
  import type { CompletedPart } from '@aws-sdk/client-s3'
@@ -31,6 +33,21 @@ import type { Readable } from 'stream'
31
33
  import type { H3Event, MultiPartData } from 'h3'
32
34
  import { createHash } from 'crypto'
33
35
 
36
+ const MB = 1024 * 1024
37
+
38
+ function fileTooLarge() {
39
+ const { maxSize } = useRuntimeConfig().public.files
40
+ return createError({ statusCode: 413, message: `File too large, max ${maxSize} MB` })
41
+ }
42
+
43
+ export async function readUploadBody(event: H3Event, maxMB: number) {
44
+ await requireUserSession(event)
45
+ const length = getRequestHeader(event, 'content-length')
46
+ if (!length) throw createError({ statusCode: 411, message: 'Content-Length required' })
47
+ if (Number(length) > (maxMB + 1) * MB) throw fileTooLarge()
48
+ return readMultipartFormData(event)
49
+ }
50
+
34
51
  export async function uploadFile(
35
52
  event: H3Event,
36
53
  file: MultiPartData,
@@ -39,9 +56,7 @@ export async function uploadFile(
39
56
  ) {
40
57
  await checkFileAccess(event, path)
41
58
  path = getSecurePath(path, isPrivate)
42
- if (file.filename && !/[^/]\.[^.]+$/.test(path) && basename(path) !== basename(file.filename)) {
43
- path = join(path, basename(file.filename))
44
- }
59
+ path = appendFilename(path, file.filename)
45
60
 
46
61
  let url
47
62
  if (useS3()) {
@@ -73,9 +88,7 @@ export async function uploadStream(
73
88
  ) {
74
89
  await checkFileAccess(event, path)
75
90
  path = getSecurePath(path, isPrivate)
76
- if (file.filename && !/[^/]\.[^.]+$/.test(path) && basename(path) !== basename(file.filename)) {
77
- path = join(path, basename(file.filename))
78
- }
91
+ path = appendFilename(path, file.filename)
79
92
 
80
93
  if (useS3()) {
81
94
  return uploadStreamToS3(
@@ -135,12 +148,15 @@ export async function uploadChunk(
135
148
  if (chunkIndex < 0 || chunkIndex >= totalChunks || totalChunks < 1) {
136
149
  throw createError({ statusCode: 400, message: 'Invalid chunk index' })
137
150
  }
151
+ const { chunkSize, maxSize } = useRuntimeConfig().public.files
152
+ const chunkBytes = (Number(chunkSize) || 1) * MB
153
+ if (totalChunks > Math.ceil((Number(maxSize) * MB) / chunkBytes)) {
154
+ throw fileTooLarge()
155
+ }
138
156
 
139
157
  await checkFileAccess(event, path)
140
158
  path = getSecurePath(path, isPrivate)
141
- if (filename && !/[^/]\.[^.]+$/.test(path) && basename(path) !== basename(filename)) {
142
- path = join(path, basename(filename))
143
- }
159
+ path = appendFilename(path, filename)
144
160
 
145
161
  if (useS3()) {
146
162
  if (!uploadId) {
@@ -190,9 +206,69 @@ export async function uploadChunk(
190
206
  return getFileURL(path, isPrivate)
191
207
  }
192
208
 
209
+ const TEMP_UPLOAD_MAX_AGE_MS = 24 * 60 * 60 * 1000
210
+
211
+ // Removes chunked uploads that were started but never finished
212
+ export async function cleanTempUploads(maxAgeMs = TEMP_UPLOAD_MAX_AGE_MS) {
213
+ const cutoff = Date.now() - maxAgeMs
214
+
215
+ const tempDir = join(process.cwd(), 'files', 'temp')
216
+ const uploadIds = await readdir(tempDir).catch(() => [])
217
+ await Promise.all(
218
+ uploadIds.map(async (uploadId) => {
219
+ const uploadDir = join(tempDir, uploadId)
220
+ const stats = await stat(uploadDir)
221
+ if (!stats.isDirectory()) return
222
+ // A retried chunk overwrites its file without touching the folder's mtime
223
+ const chunks = await readdir(uploadDir)
224
+ const chunkTimes = await Promise.all(
225
+ chunks.map(async (chunk) => (await stat(join(uploadDir, chunk))).mtimeMs),
226
+ )
227
+ if (Math.max(stats.mtimeMs, ...chunkTimes) < cutoff) {
228
+ await rm(uploadDir, { recursive: true, force: true })
229
+ }
230
+ }),
231
+ )
232
+
233
+ const client = useS3()
234
+ if (!client) return
235
+
236
+ const config = useRuntimeConfig()
237
+ const buckets = [...new Set([config.s3.privateBucket, config.s3.publicBucket])].filter(Boolean)
238
+ for (const Bucket of buckets) {
239
+ let KeyMarker: string | undefined
240
+ let UploadIdMarker: string | undefined
241
+ do {
242
+ const response = await client.send(
243
+ new ListMultipartUploadsCommand({ Bucket, KeyMarker, UploadIdMarker }),
244
+ )
245
+ await Promise.all(
246
+ (response.Uploads || [])
247
+ .filter((upload) => upload.Initiated && upload.Initiated.getTime() < cutoff)
248
+ .map((upload) =>
249
+ client.send(
250
+ new AbortMultipartUploadCommand({
251
+ Bucket,
252
+ Key: upload.Key,
253
+ UploadId: upload.UploadId,
254
+ }),
255
+ ),
256
+ ),
257
+ )
258
+ KeyMarker = response.IsTruncated ? response.NextKeyMarker : undefined
259
+ UploadIdMarker = response.IsTruncated ? response.NextUploadIdMarker : undefined
260
+ } while (KeyMarker)
261
+ }
262
+ }
263
+
264
+ // A name can hold %, # or ?, which would end or break the URL unencoded
265
+ function encodePath(path: string) {
266
+ return path.split('/').map(encodeURIComponent).join('/')
267
+ }
268
+
193
269
  export function getFileURL(path: string, isPrivate = true) {
194
270
  const config = useRuntimeConfig()
195
- const url = removeRoot(path, isPrivate)
271
+ const url = encodePath(removeRoot(path, isPrivate))
196
272
 
197
273
  if (isPrivate) {
198
274
  return config.public.url + join('/', 'api/files', url) + '?isPrivate=true'
@@ -235,6 +311,8 @@ export async function getFile(
235
311
  ) {
236
312
  await checkFileAccess(event, path)
237
313
  path = getSecurePath(path, isPrivate)
314
+ setHeader(event, 'X-Content-Type-Options', 'nosniff')
315
+ setHeader(event, 'Content-Security-Policy', 'sandbox')
238
316
 
239
317
  const range = getRequestHeader(event, 'range')
240
318
 
@@ -299,6 +377,8 @@ export async function getFile(
299
377
  export async function deletePath(event: H3Event, path: string, isPrivate = true) {
300
378
  await checkFileAccess(event, path)
301
379
  path = getSecurePath(path, isPrivate)
380
+ // An empty path resolves to the root folder, deleting it would wipe every file
381
+ if (path === getSecurePath('', isPrivate)) throw invalidPath()
302
382
  if (useS3()) await deleteFromS3(path, isPrivate)
303
383
  else {
304
384
  const localPath = join(process.cwd(), path)
@@ -371,13 +451,19 @@ export async function copyFile(
371
451
  await checkFileAccess(event, dest)
372
452
  src = getSecurePath(src, isPrivate)
373
453
  dest = getSecurePath(dest, isPrivate)
454
+ // A copy onto itself would truncate the file, and a move would then delete it
455
+ if (src === dest) return
374
456
 
375
457
  if (useS3()) return copyFromS3(src, dest, isPrivate, deleteSrc)
376
458
 
377
459
  const srcPath = join(process.cwd(), src)
378
460
  const destPath = join(process.cwd(), dest)
461
+ const stats = await stat(srcPath).catch(() => null)
462
+ if (!stats?.isFile()) {
463
+ throw createError({ statusCode: 404, message: 'File not found' })
464
+ }
379
465
  await mkdir(dirname(destPath), { recursive: true })
380
- await writeFile(destPath, createReadStream(srcPath))
466
+ await fsCopyFile(srcPath, destPath)
381
467
 
382
468
  if (deleteSrc) {
383
469
  await deletePath(event, removeRoot(src, isPrivate), isPrivate)
@@ -412,8 +498,11 @@ export function getSecurePath(path: string, isPrivate = true) {
412
498
  const root = isPrivate ? config.filesPrivateFolder : config.filesPublicFolder
413
499
  let normalizedPath = path.replace(/^\/+/, '')
414
500
 
415
- if (normalizedPath.split('/').some((segment) => segment === '..' || segment === '.')) {
416
- throw createError({ statusCode: 400, message: 'Invalid path' })
501
+ if (
502
+ normalizedPath.includes('..') ||
503
+ normalizedPath.split('/').some((segment) => segment === '.')
504
+ ) {
505
+ throw invalidPath()
417
506
  }
418
507
 
419
508
  normalizedPath = join('/', root, removeRoot(normalizedPath, isPrivate))
@@ -421,6 +510,32 @@ export function getSecurePath(path: string, isPrivate = true) {
421
510
  return normalizedPath.replace(/^\//, '').replace(/\/$/, '')
422
511
  }
423
512
 
513
+ function invalidPath() {
514
+ return createError({ statusCode: 400, message: 'Invalid path' })
515
+ }
516
+
517
+ function appendFilename(path: string, filename?: string) {
518
+ if (!filename) return path
519
+ if (filename.includes('..')) throw invalidPath()
520
+ filename = cleanFilename(filename)
521
+ if (/[^/]\.[^.]+$/.test(path) || basename(path) === filename) return path
522
+ return join(path, filename)
523
+ }
524
+
525
+ // Keeps letters (accents too), digits, . - and _, anything else becomes a single -.
526
+ // NFC, because macOS sends é as e + accent and the same name would not match.
527
+ export function cleanFilename(filename: string) {
528
+ const name = basename(filename)
529
+ .normalize('NFC')
530
+ .replace(/[^\p{L}\p{M}\p{N}._-]+/gu, '-')
531
+ .replace(/-*\.-*/g, '.')
532
+ .replace(/\.{2,}/g, '.')
533
+ .replace(/-{2,}/g, '-')
534
+ .replace(/^-+|[-.]+$/g, '')
535
+ // Nothing left of the name, or a hidden file: keep the extension behind a visible name
536
+ return !name || name.startsWith('.') ? `file${name}` : name
537
+ }
538
+
424
539
  export function removeRoot(path: string, isPrivate = true) {
425
540
  const config = useRuntimeConfig()
426
541
  const root = isPrivate ? config.filesPrivateFolder : config.filesPublicFolder
@@ -733,7 +848,7 @@ export function getS3Key(path: string) {
733
848
 
734
849
  function getS3URL(path: string) {
735
850
  const config = useRuntimeConfig()
736
- return `${config.public.files.url}/${getS3Key(path)}`
851
+ return `${config.public.files.url}/${encodePath(getS3Key(path))}`
737
852
  }
738
853
 
739
854
  async function getS3SignedUrl(key: string, expiresIn = 3600, isPrivate = true): Promise<string> {
@@ -27,9 +27,14 @@ async function getId(event: H3Event): Promise<string> {
27
27
  return getClientIP(event)
28
28
  }
29
29
 
30
- export async function enforceRateLimit(event: H3Event, route: string, rule: RateLimitRule) {
30
+ export async function enforceRateLimit(
31
+ event: H3Event,
32
+ route: string,
33
+ rule: RateLimitRule,
34
+ id?: string,
35
+ ) {
31
36
  const config = useRuntimeConfig()
32
- const id = await getId(event)
37
+ id ??= await getId(event)
33
38
  const key = `${route}:${id}`
34
39
  const now = Date.now()
35
40
 
package/locales/en.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "add": "Add",
3
3
  "areYouSure": "Are you sure?",
4
+ "authentication": "Authentication",
4
5
  "cancel": "Cancel",
5
6
  "confirm": "Confirm",
6
7
  "continue": "Continue",
@@ -11,6 +12,7 @@
11
12
  "disable": "Disable",
12
13
  "email": "Email",
13
14
  "enable": "Enable",
15
+ "fileTooLarge": "Too large, max {max} MB: {files}",
14
16
  "formatFailed": "Could not format or upload {files}",
15
17
  "language": "Language | Languages",
16
18
  "paste": "Paste",
@@ -27,6 +29,7 @@
27
29
  "code": "Code",
28
30
  "getCode": "Get code",
29
31
  "otpSent": "Code sent to {email}",
32
+ "passkey": "Use a passkey",
30
33
  "password": "Password",
31
34
  "signin": "Sign In"
32
35
  }
package/locales/fr.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "add": "Ajouter",
3
3
  "areYouSure": "Êtes-vous sûr ?",
4
+ "authentication": "Authentification",
4
5
  "cancel": "Annuler",
5
6
  "confirm": "Confirmer",
6
7
  "continue": "Continuer",
@@ -11,6 +12,7 @@
11
12
  "disable": "Désactiver",
12
13
  "email": "Email",
13
14
  "enable": "Activer",
15
+ "fileTooLarge": "Trop volumineux, max {max} Mo : {files}",
14
16
  "formatFailed": "Impossible de formater ou téléverser {files}",
15
17
  "language": "Langue | Langues",
16
18
  "paste": "Coller",
@@ -27,6 +29,7 @@
27
29
  "code": "Code",
28
30
  "getCode": "Demander le code",
29
31
  "otpSent": "Code envoyé à {email}",
32
+ "passkey": "Utiliser une passkey",
30
33
  "password": "Mot de passe",
31
34
  "signin": "Se connecter"
32
35
  }
@@ -1,6 +1,5 @@
1
1
  {
2
2
  "2fa": "Two-Factor Authentication",
3
- "authentication": "Authentication",
4
3
  "authenticatorApp": "Authenticator app",
5
4
  "changeEmail": "Change email address",
6
5
  "changePassword": "Change password",
@@ -1,6 +1,5 @@
1
1
  {
2
2
  "2fa": "Authentification à deux facteurs",
3
- "authentication": "Authentification",
4
3
  "authenticatorApp": "Application d'authentification",
5
4
  "changeEmail": "Modifier l'adresse email",
6
5
  "changePassword": "Modifier le mot de passe",
package/nuxt.config.ts CHANGED
@@ -27,6 +27,7 @@ export default defineNuxtConfig({
27
27
  routes: {
28
28
  // limit: max requests per IP, window: in seconds
29
29
  '/api/log': { limit: 30, window: 60 },
30
+ '/api/auth/options': { limit: 10, window: 60 },
30
31
  '/api/auth/password': { limit: 5, window: 60 },
31
32
  '/api/auth/otp/get': { limit: 5, window: 900 },
32
33
  '/api/auth/email/get': { limit: 5, window: 900 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxibase",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Nuxt layer with auth, files, logs and backups",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "engines": {
34
- "node": ">=22"
34
+ "node": "^24.15.0 || >=26"
35
35
  },
36
36
  "packageManager": "pnpm@12.4.2+sha512.08adc6613180275c7c9edada39dcf08c9c61ad4e7eaf330a4f3461f102b0f907423454d117f98e72d47fef0616070644d7bffc973a6a57f5090a6d7c368b07c9",
37
37
  "scripts": {