maxibase 0.0.0-stage → 1.0.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 (115) hide show
  1. package/README.md +269 -2
  2. package/docker/Dockerfile +22 -0
  3. package/docker/Dockerfile.dockerignore +25 -0
  4. package/docker/compose.yaml +19 -0
  5. package/layers/base/app/app.vue +22 -0
  6. package/layers/base/app/components/AuthCheck.vue +316 -0
  7. package/layers/base/app/components/DataExplorer.vue +38 -0
  8. package/layers/base/app/components/LocaleSwitcher.vue +28 -0
  9. package/layers/base/app/components/TheConfirmation.vue +48 -0
  10. package/layers/base/app/components/TheLoading.vue +36 -0
  11. package/layers/base/app/components/TheNotifications.vue +71 -0
  12. package/layers/base/app/components/files/FilesDelete.vue +33 -0
  13. package/layers/base/app/components/files/FilesUpload.vue +85 -0
  14. package/layers/base/app/error.vue +28 -0
  15. package/layers/base/app/layouts/default.vue +15 -0
  16. package/layers/base/app/layouts/simple.vue +3 -0
  17. package/layers/base/app/middleware/admin.ts +15 -0
  18. package/layers/base/app/middleware/authenticated.ts +10 -0
  19. package/layers/base/app/middleware/mfa.global.ts +8 -0
  20. package/layers/base/app/pages/admin/logs.vue +364 -0
  21. package/layers/base/app/pages/signin.vue +121 -0
  22. package/layers/base/app/pages/user/auth.vue +509 -0
  23. package/layers/base/app/plugins/error.ts +21 -0
  24. package/layers/base/app/plugins/session.ts +22 -0
  25. package/layers/base/app/stores/app.ts +100 -0
  26. package/layers/base/app/utils/clipboard.ts +6 -0
  27. package/layers/base/app/utils/files.ts +95 -0
  28. package/layers/base/app/utils/image.ts +33 -0
  29. package/layers/base/nuxt.config.ts +146 -0
  30. package/layers/base/public/images/auth/microsoft.svg +1 -0
  31. package/layers/base/public/robots.txt +2 -0
  32. package/layers/base/server/api/admin/logs.get.ts +49 -0
  33. package/layers/base/server/api/auth/anonymous.post.ts +31 -0
  34. package/layers/base/server/api/auth/email/get.post.ts +46 -0
  35. package/layers/base/server/api/auth/email/verify.post.ts +46 -0
  36. package/layers/base/server/api/auth/index.get.ts +10 -0
  37. package/layers/base/server/api/auth/index.post.ts +19 -0
  38. package/layers/base/server/api/auth/options.post.ts +26 -0
  39. package/layers/base/server/api/auth/otp/get.post.ts +74 -0
  40. package/layers/base/server/api/auth/otp/verify.post.ts +70 -0
  41. package/layers/base/server/api/auth/password.post.ts +42 -0
  42. package/layers/base/server/api/auth/refresh.delete.ts +4 -0
  43. package/layers/base/server/api/auth/refresh.post.ts +5 -0
  44. package/layers/base/server/api/auth/totp/confirm.post.ts +26 -0
  45. package/layers/base/server/api/auth/totp/disable.post.ts +33 -0
  46. package/layers/base/server/api/auth/totp/generate.get.ts +6 -0
  47. package/layers/base/server/api/auth/totp/verify.post.ts +36 -0
  48. package/layers/base/server/api/auth/webauthn/authenticate.post.ts +39 -0
  49. package/layers/base/server/api/auth/webauthn/index.delete.ts +43 -0
  50. package/layers/base/server/api/auth/webauthn/index.post.ts +17 -0
  51. package/layers/base/server/api/auth/webauthn/register.post.ts +49 -0
  52. package/layers/base/server/api/files/[...path].get.ts +37 -0
  53. package/layers/base/server/api/files/chunk.post.ts +36 -0
  54. package/layers/base/server/api/files/copy.post.ts +22 -0
  55. package/layers/base/server/api/files/index.delete.ts +12 -0
  56. package/layers/base/server/api/files/index.get.ts +19 -0
  57. package/layers/base/server/api/files/index.post.ts +15 -0
  58. package/layers/base/server/api/log.post.ts +20 -0
  59. package/layers/base/server/database/defaults/access.ts +14 -0
  60. package/layers/base/server/database/defaults/db.ts +5 -0
  61. package/layers/base/server/database/defaults/relations.ts +5 -0
  62. package/layers/base/server/database/relations.ts +35 -0
  63. package/layers/base/server/database/schema.ts +66 -0
  64. package/layers/base/server/middleware/0.rateLimit.ts +21 -0
  65. package/layers/base/server/middleware/1.session.ts +14 -0
  66. package/layers/base/server/middleware/2.mfa.ts +34 -0
  67. package/layers/base/server/middleware/3.admin.ts +4 -0
  68. package/layers/base/server/plugins/session.ts +9 -0
  69. package/layers/base/server/routes/auth/microsoft.get.ts +21 -0
  70. package/layers/base/server/routes/dev/email/[template].ts +15 -0
  71. package/layers/base/server/tasks/backup.ts +42 -0
  72. package/layers/base/server/tasks/clean.ts +39 -0
  73. package/layers/base/server/utils/access.ts +8 -0
  74. package/layers/base/server/utils/auth.ts +207 -0
  75. package/layers/base/server/utils/backup.ts +98 -0
  76. package/layers/base/server/utils/db.ts +3 -0
  77. package/layers/base/server/utils/email.ts +138 -0
  78. package/layers/base/server/utils/files.ts +751 -0
  79. package/layers/base/server/utils/ip.ts +15 -0
  80. package/layers/base/server/utils/log.ts +31 -0
  81. package/layers/base/server/utils/rateLimit.ts +69 -0
  82. package/layers/base/server/utils/schemas.ts +8 -0
  83. package/layers/base/server/utils/text.ts +4 -0
  84. package/layers/base/server/utils/time.ts +3 -0
  85. package/layers/base/shared/types/auth.d.ts +22 -0
  86. package/layers/base/shared/utils/logs.ts +9 -0
  87. package/layers/base/shared/utils/url.ts +5 -0
  88. package/locales/en.json +33 -0
  89. package/locales/fr.json +33 -0
  90. package/locales/pages/admin-logs/en.json +1 -0
  91. package/locales/pages/admin-logs/fr.json +1 -0
  92. package/locales/pages/index/en.json +1 -0
  93. package/locales/pages/index/fr.json +1 -0
  94. package/locales/pages/signin/en.json +5 -0
  95. package/locales/pages/signin/fr.json +5 -0
  96. package/locales/pages/user-auth/en.json +22 -0
  97. package/locales/pages/user-auth/fr.json +22 -0
  98. package/nuxt.config.ts +54 -0
  99. package/package.json +92 -4
  100. package/scripts/db/migrate.ts +9 -0
  101. package/scripts/db/push-server.sh +45 -0
  102. package/scripts/db/restore.sh +26 -0
  103. package/scripts/deploy.sh +34 -0
  104. package/scripts/env.sh +20 -0
  105. package/scripts/remote/switch.sh +48 -0
  106. package/scripts/rollback.sh +12 -0
  107. package/server/assets/emails/base.html +43 -0
  108. package/server/assets/emails/en/base.html +1 -0
  109. package/server/assets/emails/en/emailChange.html +4 -0
  110. package/server/assets/emails/en/emailChanged.html +2 -0
  111. package/server/assets/emails/en/otp.html +10 -0
  112. package/server/assets/emails/fr/base.html +1 -0
  113. package/server/assets/emails/fr/emailChange.html +4 -0
  114. package/server/assets/emails/fr/emailChanged.html +2 -0
  115. package/server/assets/emails/fr/otp.html +10 -0
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod'
2
+
3
+ function badCredentials() {
4
+ return createError({
5
+ status: 401,
6
+ message: 'Bad credentials',
7
+ })
8
+ }
9
+
10
+ export default defineEventHandler(async (event) => {
11
+ const { email, password } = await readValidatedBody(
12
+ event,
13
+ z.object({
14
+ email: emailSchema,
15
+ password: passwordSchema(),
16
+ }).parse,
17
+ )
18
+
19
+ const user = await getAuth(event, email).catch((error: any) => {
20
+ if (error?.statusCode === 404) return null
21
+ throw error
22
+ })
23
+
24
+ if (!user?.password) {
25
+ // Burn comparable time so the timing does not leak what the status hides.
26
+ await hashPassword(password)
27
+ throw badCredentials()
28
+ }
29
+
30
+ if (!(await verifyPassword(user.password, password))) {
31
+ throw badCredentials()
32
+ }
33
+
34
+ if (user.totp) {
35
+ throw createError({
36
+ status: 401,
37
+ message: 'TOTP required',
38
+ })
39
+ }
40
+
41
+ return setSession(event, user)
42
+ })
@@ -0,0 +1,4 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const session = await requireRecentAuth(event)
3
+ await revokeAllUserTokens(session.user.id)
4
+ })
@@ -0,0 +1,5 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const { user, refreshToken } = await verifyRefreshToken(event)
3
+ await revokeRefreshToken(refreshToken.token)
4
+ await createRefreshToken(user.id, event)
5
+ })
@@ -0,0 +1,26 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { auth } from '#server/database/schema'
3
+ import { z } from 'zod'
4
+ import { verify } from 'otplib'
5
+
6
+ const bodySchema = z.object({
7
+ secret: z.string().min(32),
8
+ token: z.string().length(6),
9
+ })
10
+
11
+ export default defineEventHandler(async (event) => {
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
+
20
+ const { user } = await requireRecentAuth(event)
21
+
22
+ if (await verify({ secret, token })) {
23
+ await db.update(auth).set({ totp: secret }).where(eq(auth.id, user.id))
24
+ await setSession(event, user, false) // refresh requiresMfaSetup
25
+ } else throw createError({ status: 400, message: 'Invalid code.' })
26
+ })
@@ -0,0 +1,33 @@
1
+ import { eq } from 'drizzle-orm/sql/expressions/conditions'
2
+ import { auth, credentials } from '#server/database/schema'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const session = await requireRecentAuth(event)
6
+ const userId = session.user.id
7
+
8
+ await db.transaction(async (tx) => {
9
+ const [locked] = await tx
10
+ .select({ totp: auth.totp })
11
+ .from(auth)
12
+ .where(eq(auth.id, userId))
13
+ .for('update')
14
+ if (!locked) {
15
+ throw createError({ status: 404, message: 'User not found' })
16
+ }
17
+ if (!locked.totp) {
18
+ throw createError({ status: 400, message: 'TOTP is not enabled.' })
19
+ }
20
+
21
+ if (useRuntimeConfig().forceMfa) {
22
+ const remaining = await tx
23
+ .select({ id: credentials.id })
24
+ .from(credentials)
25
+ .where(eq(credentials.userId, userId))
26
+ if (!remaining.length) {
27
+ throw createError({ status: 400, message: 'Keep at least one second factor.' })
28
+ }
29
+ }
30
+
31
+ await tx.update(auth).set({ totp: null }).where(eq(auth.id, userId))
32
+ })
33
+ })
@@ -0,0 +1,6 @@
1
+ import { generateSecret } from 'otplib'
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ await requireRecentAuth(event)
5
+ return { secret: generateSecret() }
6
+ })
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod'
2
+ import { verify } from 'otplib'
3
+ import { verifyOTP } from '../otp/verify.post'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { email, password, otp, token } = await readValidatedBody(
7
+ event,
8
+ z.object({
9
+ email: emailSchema,
10
+ password: passwordSchema().optional(),
11
+ otp: z.string().length(6).or(z.string().min(32)).optional(),
12
+ token: z.string().length(6),
13
+ }).parse,
14
+ )
15
+ if (!email || !(otp || password) || !token) {
16
+ throw createError({
17
+ status: 400,
18
+ message: 'Missing required fields',
19
+ })
20
+ }
21
+
22
+ const user = await getAuth(event, email)
23
+
24
+ if (otp) await verifyOTP(email, otp)
25
+ else if (!user.password || !(await verifyPassword(user.password, password!))) {
26
+ throw createError({ status: 400, message: 'Invalid password.' })
27
+ }
28
+
29
+ if (!user.totp) {
30
+ throw createError({ status: 400, message: 'TOTP is not enabled.' })
31
+ }
32
+
33
+ if (await verify({ secret: user.totp, token })) {
34
+ await setSession(event, user)
35
+ } else throw createError({ status: 400, message: 'Invalid TOTP.' })
36
+ })
@@ -0,0 +1,39 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { auth } from '#server/database/schema'
3
+
4
+ export default defineWebAuthnAuthenticateEventHandler({
5
+ async storeChallenge(event, challenge, attemptId) {
6
+ await useStorage('auth').setItem(`attempt:${attemptId}`, challenge)
7
+ },
8
+ async getChallenge(event, attemptId) {
9
+ const challenge = await useStorage('auth').getItem(`attempt:${attemptId}`)
10
+
11
+ await useStorage('auth').removeItem(`attempt:${attemptId}`)
12
+
13
+ if (!challenge) throw createError({ status: 400, message: 'Challenge expired' })
14
+
15
+ return challenge as string
16
+ },
17
+ async allowCredentials(event, userName) {
18
+ const user = await getAuth(event, userName, true)
19
+ return (user as any).credentials || []
20
+ },
21
+ async getCredential(event, credentialId) {
22
+ const credential = await db.query.credentials.findFirst({
23
+ where: {
24
+ id: credentialId,
25
+ },
26
+ })
27
+
28
+ if (!credential) throw createError({ status: 400, message: 'Credential not found' })
29
+ return credential as any
30
+ },
31
+ async onSuccess(event, { credential }) {
32
+ const user = await db
33
+ .select()
34
+ .from(auth)
35
+ .where(eq(auth.id, credential.userId as string))
36
+ .then((result) => result[0])
37
+ await setSession(event, user!)
38
+ },
39
+ })
@@ -0,0 +1,43 @@
1
+ import { and, eq } from 'drizzle-orm'
2
+ import { z } from 'zod'
3
+ import { auth, credentials } from '#server/database/schema'
4
+
5
+ const bodySchema = z.object({
6
+ credentialId: z.string().min(1),
7
+ })
8
+
9
+ export default defineEventHandler(async (event) => {
10
+ const { credentialId } = await readValidatedBody(event, bodySchema.parse)
11
+ const session = await requireRecentAuth(event)
12
+ const userId = session.user.id
13
+
14
+ if (!useRuntimeConfig().forceMfa) {
15
+ await db
16
+ .delete(credentials)
17
+ .where(and(eq(credentials.id, credentialId), eq(credentials.userId, userId)))
18
+ return
19
+ }
20
+
21
+ await db.transaction(async (tx) => {
22
+ const [locked] = await tx
23
+ .select({ totp: auth.totp })
24
+ .from(auth)
25
+ .where(eq(auth.id, userId))
26
+ .for('update')
27
+ if (!locked) {
28
+ throw createError({ status: 404, message: 'User not found' })
29
+ }
30
+
31
+ const remaining = await tx
32
+ .select({ id: credentials.id })
33
+ .from(credentials)
34
+ .where(eq(credentials.userId, userId))
35
+ if (!locked.totp && remaining.length <= 1) {
36
+ throw createError({ status: 400, message: 'Keep at least one second factor.' })
37
+ }
38
+
39
+ await tx
40
+ .delete(credentials)
41
+ .where(and(eq(credentials.id, credentialId), eq(credentials.userId, userId)))
42
+ })
43
+ })
@@ -0,0 +1,17 @@
1
+ import { and, eq } from 'drizzle-orm'
2
+ import { z } from 'zod'
3
+ import { credentials } from '#server/database/schema'
4
+
5
+ const bodySchema = z.object({
6
+ name: z.string().min(1).max(32),
7
+ credentialId: z.string().min(1),
8
+ })
9
+
10
+ export default defineEventHandler(async (event) => {
11
+ const { name, credentialId } = await readValidatedBody(event, bodySchema.parse)
12
+ const session = await requireRecentAuth(event)
13
+ await db
14
+ .update(credentials)
15
+ .set({ name })
16
+ .where(and(eq(credentials.id, credentialId), eq(credentials.userId, session.user.id)))
17
+ })
@@ -0,0 +1,49 @@
1
+ import { z } from 'zod'
2
+ import { credentials } from '#server/database/schema'
3
+
4
+ const registerHandler = defineWebAuthnRegisterEventHandler({
5
+ async storeChallenge(event, challenge, attemptId) {
6
+ await useStorage('auth').setItem(`attempt:${attemptId}`, challenge)
7
+ },
8
+ async getChallenge(event, attemptId) {
9
+ const challenge = await useStorage('auth').getItem(`attempt:${attemptId}`)
10
+
11
+ await useStorage('auth').removeItem(`attempt:${attemptId}`)
12
+
13
+ if (!challenge) throw createError({ status: 400, message: 'Challenge expired' })
14
+
15
+ return challenge as string
16
+ },
17
+ async validateUser(userBody, event) {
18
+ const session = await requireUserSession(event)
19
+ const parsed = z
20
+ .object({
21
+ userName: emailSchema,
22
+ })
23
+ .safeParse(userBody)
24
+
25
+ if (!parsed.success || !session.user?.email || session.user.email !== parsed.data.userName) {
26
+ throw createError({ status: 400, message: 'Email incorrect' })
27
+ }
28
+
29
+ return parsed.data
30
+ },
31
+ async onSuccess(event, { credential }) {
32
+ const session = await requireUserSession(event)
33
+ await db.insert(credentials).values({
34
+ name: new Date().toISOString().substring(0, 10),
35
+ userId: session.user.id,
36
+ id: credential.id,
37
+ publicKey: credential.publicKey,
38
+ counter: credential.counter,
39
+ backedUp: credential.backedUp,
40
+ transports: credential.transports as any[],
41
+ })
42
+ await setSession(event, session.user, false) // refresh requiresMfaSetup
43
+ },
44
+ })
45
+
46
+ export default defineEventHandler(async (event) => {
47
+ await requireRecentAuth(event)
48
+ return registerHandler(event)
49
+ })
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod'
2
+
3
+ const querySchema = z.object({
4
+ isPrivate: z
5
+ .enum(['true', 'false'])
6
+ .transform((v) => v === 'true')
7
+ .optional(),
8
+ maxAge: z.coerce.number().optional(),
9
+ serverCache: z.string().optional(),
10
+ expireIn: z.coerce.number().optional(),
11
+ redirect: z
12
+ .enum(['true', 'false'])
13
+ .transform((v) => v === 'true')
14
+ .optional(),
15
+ })
16
+
17
+ export default defineEventHandler((event) => {
18
+ const path = event.context.params?.path || ''
19
+ const query = getQuery(event)
20
+ const { isPrivate = false, maxAge, serverCache, expireIn, redirect } = querySchema.parse(query)
21
+
22
+ let cache = `public, max-age=${maxAge || 31536000}`
23
+ if (maxAge === 0) {
24
+ cache = 'no-store, no-cache'
25
+ } else if (isPrivate) {
26
+ cache = `private, max-age=${maxAge || 3600}`
27
+ }
28
+ setHeader(event, 'Cache-Control', cache)
29
+ return getFile(
30
+ event,
31
+ decodeURIComponent(path),
32
+ isPrivate,
33
+ serverCache as RequestCache,
34
+ expireIn,
35
+ redirect,
36
+ )
37
+ })
@@ -0,0 +1,36 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const body = await readMultipartFormData(event)
3
+ if (!body) throw createError({ status: 400, message: 'No data' })
4
+
5
+ const get = (name: string) =>
6
+ body
7
+ .find((p) => p.name === name)
8
+ ?.data.toString()
9
+ .trim()
10
+
11
+ const uploadId = get('uploadId')
12
+ const chunkIndex = Number(get('chunkIndex'))
13
+ const totalChunks = Number(get('totalChunks'))
14
+ const filename = get('filename')
15
+ const type = get('type') || 'application/octet-stream'
16
+ const path = get('path') || 'files'
17
+ const isPrivate = get('isPrivate') !== 'false'
18
+ const parts = get('parts') ? JSON.parse(get('parts') || '[]') : []
19
+ const chunkPart = body.find((p) => p.name === 'chunk')
20
+
21
+ if (isNaN(chunkIndex) || isNaN(totalChunks) || !filename || !chunkPart) {
22
+ throw createError({ status: 400, message: 'Invalid chunk upload' })
23
+ }
24
+
25
+ return uploadChunk(event, {
26
+ uploadId,
27
+ chunkIndex,
28
+ totalChunks,
29
+ filename,
30
+ type,
31
+ chunk: chunkPart.data,
32
+ path,
33
+ isPrivate,
34
+ parts,
35
+ })
36
+ })
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod'
2
+
3
+ const bodySchema = z.object({
4
+ src: z.string().min(1),
5
+ dest: z.string().min(1),
6
+ isPrivate: z.boolean().optional(),
7
+ deleteSrc: z.boolean().optional(),
8
+ isFolder: z.boolean().optional(),
9
+ })
10
+
11
+ export default defineEventHandler(async (event) => {
12
+ const {
13
+ src,
14
+ dest,
15
+ isPrivate,
16
+ deleteSrc,
17
+ isFolder = !src.includes('.'),
18
+ } = await readValidatedBody(event, bodySchema.parse)
19
+ if (isFolder) await copyFiles(event, src, dest, isPrivate, deleteSrc)
20
+ else await copyFile(event, src, dest, isPrivate, deleteSrc)
21
+ return { success: true }
22
+ })
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod'
2
+
3
+ const bodySchema = z.object({
4
+ path: z.string().min(1),
5
+ isPrivate: z.boolean().optional(),
6
+ })
7
+
8
+ export default defineEventHandler(async (event) => {
9
+ const { path, isPrivate } = await readValidatedBody(event, bodySchema.parse)
10
+ await deletePath(event, path, isPrivate)
11
+ return { success: true }
12
+ })
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod'
2
+
3
+ const querySchema = z.object({
4
+ path: z
5
+ .string()
6
+ .min(1)
7
+ .refine((p) => !p.includes('.')),
8
+ isPrivate: z
9
+ .enum(['true', 'false'])
10
+ .transform((v) => v === 'true')
11
+ .optional(),
12
+ })
13
+
14
+ export default defineEventHandler(async (event) => {
15
+ const query = getQuery(event)
16
+
17
+ const { path, isPrivate = false } = querySchema.parse(query)
18
+ return listFolder(event, path, isPrivate)
19
+ })
@@ -0,0 +1,15 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const body = await readMultipartFormData(event)
3
+ if (!body) {
4
+ throw createError({ status: 400, message: 'No file uploaded' })
5
+ }
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
+
12
+ return await Promise.all(
13
+ body.filter((p) => p.name === 'file').map((file) => uploadFile(event, file, path, isPrivate)),
14
+ )
15
+ })
@@ -0,0 +1,20 @@
1
+ import z from 'zod'
2
+
3
+ const MAX_DATA_LENGTH = 10000
4
+
5
+ const bodySchema = z.object({
6
+ summary: z.string().max(1000),
7
+ data: z
8
+ .unknown()
9
+ .optional()
10
+ .refine((v) => v === undefined || (JSON.stringify(v)?.length ?? 0) <= MAX_DATA_LENGTH, {
11
+ message: 'data too large',
12
+ }),
13
+ origin: z.string().max(500).optional(),
14
+ type: z.enum(['info', 'warn', 'error', 'debug']).catch('info').optional(),
15
+ })
16
+
17
+ export default defineEventHandler(async (event) => {
18
+ const { summary, data, origin, type } = await readValidatedBody(event, bodySchema.parse)
19
+ await log(summary, data, origin, type, event)
20
+ })
@@ -0,0 +1,14 @@
1
+ import type { H3Event } from 'h3'
2
+
3
+ // Users reach their own folder u/{id}, admins every folder
4
+ export async function checkFileAccess(event: H3Event, path: string) {
5
+ const { user } = await requireUserSession(event)
6
+ if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
7
+ const root = `u/${user.id}`
8
+ if (path.startsWith(root) && (path[root.length] === '/' || path.length === root.length)) {
9
+ return true
10
+ }
11
+ if (user.role === 'admin') return true
12
+
13
+ throw createError({ statusCode: 403, message: 'Insufficient permissions' })
14
+ }
@@ -0,0 +1,5 @@
1
+ import { drizzle } from 'drizzle-orm/node-postgres'
2
+ import { relations } from '#database/relations'
3
+ import { authRelations } from '../relations'
4
+
5
+ export const db = drizzle(useRuntimeConfig().db, { relations: { ...relations, ...authRelations } })
@@ -0,0 +1,5 @@
1
+ import * as schema from '#server/database/schema'
2
+ import { defineRelationsPart } from 'drizzle-orm'
3
+
4
+ // No relations of the project's own, every table is still queryable with db.query
5
+ export const relations = defineRelationsPart(schema)
@@ -0,0 +1,35 @@
1
+ import * as schema from '#server/database/schema'
2
+ import { defineRelationsPart } from 'drizzle-orm'
3
+
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.
6
+ export const authRelations = defineRelationsPart(schema, (r) => ({
7
+ auth: {
8
+ refreshTokens: r.many.refreshTokens({
9
+ from: r.auth.id,
10
+ to: r.refreshTokens.userId,
11
+ }),
12
+ credentials: r.many.credentials({
13
+ from: r.auth.id,
14
+ to: r.credentials.userId,
15
+ }),
16
+ },
17
+ refreshTokens: {
18
+ auth: r.one.auth({
19
+ from: r.refreshTokens.userId,
20
+ to: r.auth.id,
21
+ }),
22
+ },
23
+ credentials: {
24
+ auth: r.one.auth({
25
+ from: r.credentials.userId,
26
+ to: r.auth.id,
27
+ }),
28
+ },
29
+ logs: {
30
+ auth: r.one.auth({
31
+ from: r.logs.userId,
32
+ to: r.auth.id,
33
+ }),
34
+ },
35
+ }))
@@ -0,0 +1,66 @@
1
+ import type { WebAuthnCredential } from '#auth-utils'
2
+ import {
3
+ snakeCase,
4
+ text,
5
+ integer,
6
+ timestamp,
7
+ uuid,
8
+ boolean,
9
+ primaryKey,
10
+ pgEnum,
11
+ jsonb,
12
+ bigint,
13
+ index,
14
+ } from 'drizzle-orm/pg-core'
15
+
16
+ export const authSchema = snakeCase.schema('auth')
17
+
18
+ export const appRoleEnum = pgEnum('app_role', ['admin', 'user'])
19
+ export const auth = authSchema.table('users', {
20
+ id: uuid().primaryKey().defaultRandom(),
21
+ email: text().unique(),
22
+ password: text(),
23
+ createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
24
+ totp: text(),
25
+ role: appRoleEnum().notNull().default('user'),
26
+ })
27
+
28
+ export const refreshTokens = authSchema.table('refresh_tokens', {
29
+ token: text().primaryKey(),
30
+ userId: uuid()
31
+ .notNull()
32
+ .references(() => auth.id, { onDelete: 'cascade' }),
33
+ createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
34
+ })
35
+
36
+ export const credentials = authSchema.table(
37
+ 'credentials',
38
+ {
39
+ userId: uuid()
40
+ .notNull()
41
+ .references(() => auth.id, { onDelete: 'cascade' }),
42
+ id: text().unique().notNull(),
43
+ name: text().notNull(),
44
+ publicKey: text().notNull(),
45
+ counter: integer().notNull(),
46
+ backedUp: boolean().notNull(),
47
+ transports: text().array().notNull().$type<WebAuthnCredential['transports']>(),
48
+ },
49
+ (table) => [primaryKey({ columns: [table.userId, table.id] })],
50
+ )
51
+
52
+ export const logs = authSchema.table(
53
+ 'logs',
54
+ {
55
+ id: bigint({ mode: 'number' }).generatedByDefaultAsIdentity().primaryKey(),
56
+ userId: uuid().references(() => auth.id, { onDelete: 'cascade' }),
57
+ type: text().notNull().default('info'),
58
+ origin: text(),
59
+ summary: text(),
60
+ data: jsonb(),
61
+ time: timestamp({ withTimezone: true }).notNull().defaultNow(),
62
+ ipAddress: text(),
63
+ userAgent: text(),
64
+ },
65
+ (table) => [index('logs_time_idx').on(table.time)],
66
+ )
@@ -0,0 +1,21 @@
1
+ import type { RateLimitRule } from '../utils/rateLimit'
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ const config = useRuntimeConfig()
5
+ if (!config.rateLimit.enabled) return
6
+
7
+ const path = getRequestURL(event).pathname
8
+ const routes = config.rateLimit.routes as Record<string, RateLimitRule>
9
+
10
+ let matchedRoute = ''
11
+ let matchedRule: RateLimitRule | undefined
12
+ for (const [route, rule] of Object.entries(routes)) {
13
+ if ((path === route || path.startsWith(route + '/')) && route.length > matchedRoute.length) {
14
+ matchedRoute = route
15
+ matchedRule = rule
16
+ }
17
+ }
18
+ if (!matchedRule) return
19
+
20
+ await enforceRateLimit(event, matchedRoute, matchedRule)
21
+ })
@@ -0,0 +1,14 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const existingToken = getCookie(event, 'refresh_token')
3
+ if (!existingToken) return
4
+
5
+ const session = await getUserSession(event)
6
+ if (session.user) return
7
+
8
+ try {
9
+ const { user } = await verifyRefreshToken(event)
10
+
11
+ const sessionData = await setSession(event, user, false)
12
+ session.user = sessionData?.user // to update the session on SSR
13
+ } catch {}
14
+ })