maxibase 1.0.0 → 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 (35) hide show
  1. package/README.md +25 -4
  2. package/docker/Dockerfile +3 -3
  3. package/docker/compose.yaml +2 -2
  4. package/layers/base/app/components/AuthCheck.vue +89 -50
  5. package/layers/base/app/components/files/FilesUpload.vue +9 -0
  6. package/layers/base/app/pages/user/auth.vue +3 -3
  7. package/layers/base/app/stores/app.ts +4 -9
  8. package/layers/base/nuxt.config.ts +4 -3
  9. package/layers/base/server/api/admin/logs.get.ts +1 -2
  10. package/layers/base/server/api/auth/otp/get.post.ts +0 -6
  11. package/layers/base/server/api/auth/otp/verify.post.ts +37 -39
  12. package/layers/base/server/api/auth/password.post.ts +2 -7
  13. package/layers/base/server/api/auth/refresh.post.ts +2 -2
  14. package/layers/base/server/api/auth/totp/confirm.post.ts +1 -7
  15. package/layers/base/server/api/auth/totp/verify.post.ts +15 -6
  16. package/layers/base/server/api/auth/webauthn/authenticate.post.ts +8 -2
  17. package/layers/base/server/api/files/[...path].get.ts +23 -14
  18. package/layers/base/server/api/files/chunk.post.ts +25 -4
  19. package/layers/base/server/api/files/index.get.ts +1 -3
  20. package/layers/base/server/api/files/index.post.ts +1 -1
  21. package/layers/base/server/database/defaults/db.ts +9 -1
  22. package/layers/base/server/database/relations.ts +1 -1
  23. package/layers/base/server/middleware/0.rateLimit.ts +1 -1
  24. package/layers/base/server/routes/auth/microsoft.get.ts +30 -17
  25. package/layers/base/server/tasks/clean.ts +2 -1
  26. package/layers/base/server/utils/auth.ts +27 -5
  27. package/layers/base/server/utils/backup.ts +1 -1
  28. package/layers/base/server/utils/files.ts +129 -14
  29. package/layers/base/server/utils/rateLimit.ts +7 -2
  30. package/locales/en.json +3 -0
  31. package/locales/fr.json +3 -0
  32. package/locales/pages/user-auth/en.json +0 -1
  33. package/locales/pages/user-auth/fr.json +0 -1
  34. package/nuxt.config.ts +1 -0
  35. package/package.json +3 -3
@@ -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
  }
@@ -2,4 +2,12 @@ import { drizzle } from 'drizzle-orm/node-postgres'
2
2
  import { relations } from '#database/relations'
3
3
  import { authRelations } from '../relations'
4
4
 
5
- export const db = drizzle(useRuntimeConfig().db, { relations: { ...relations, ...authRelations } })
5
+ // defineRelations gives every table an entry, empty ones included: only the project's tables
6
+ // with relations override the layer's, so an empty entry never erases them
7
+ const own = Object.fromEntries(
8
+ Object.entries(relations).filter(([, table]) => Object.keys(table.relations).length),
9
+ )
10
+
11
+ export const db = drizzle(useRuntimeConfig().db, {
12
+ relations: { ...relations, ...authRelations, ...own } as typeof relations & typeof authRelations,
13
+ })
@@ -2,7 +2,7 @@ import * as schema from '#server/database/schema'
2
2
  import { defineRelationsPart } from 'drizzle-orm'
3
3
 
4
4
  // Built from the project schema, so they also apply to a table the project overrides.
5
- // Spread after the project's relations, so they replace any the project defines on these tables.
5
+ // A project entry for one of these tables replaces the layer's, see defaults/db.ts.
6
6
  export const authRelations = defineRelationsPart(schema, (r) => ({
7
7
  auth: {
8
8
  refreshTokens: r.many.refreshTokens({
@@ -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.0",
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": {
@@ -44,7 +44,7 @@
44
44
  "db:seed": "npx jiti ./server/database/seed.ts",
45
45
  "db:generate": "drizzle-kit generate",
46
46
  "db:push": "drizzle-kit push",
47
- "db:push-server": "./scripts/db/push-server.sh",
47
+ "db:push-prod": "./scripts/db/push-server.sh",
48
48
  "db:restore": "./scripts/db/restore.sh",
49
49
  "db:migrate": "npx jiti ./scripts/db/migrate.ts",
50
50
  "db:studio": "drizzle-kit studio",