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.
- package/README.md +269 -2
- package/docker/Dockerfile +22 -0
- package/docker/Dockerfile.dockerignore +25 -0
- package/docker/compose.yaml +19 -0
- package/layers/base/app/app.vue +22 -0
- package/layers/base/app/components/AuthCheck.vue +316 -0
- package/layers/base/app/components/DataExplorer.vue +38 -0
- package/layers/base/app/components/LocaleSwitcher.vue +28 -0
- package/layers/base/app/components/TheConfirmation.vue +48 -0
- package/layers/base/app/components/TheLoading.vue +36 -0
- package/layers/base/app/components/TheNotifications.vue +71 -0
- package/layers/base/app/components/files/FilesDelete.vue +33 -0
- package/layers/base/app/components/files/FilesUpload.vue +85 -0
- package/layers/base/app/error.vue +28 -0
- package/layers/base/app/layouts/default.vue +15 -0
- package/layers/base/app/layouts/simple.vue +3 -0
- package/layers/base/app/middleware/admin.ts +15 -0
- package/layers/base/app/middleware/authenticated.ts +10 -0
- package/layers/base/app/middleware/mfa.global.ts +8 -0
- package/layers/base/app/pages/admin/logs.vue +364 -0
- package/layers/base/app/pages/signin.vue +121 -0
- package/layers/base/app/pages/user/auth.vue +509 -0
- package/layers/base/app/plugins/error.ts +21 -0
- package/layers/base/app/plugins/session.ts +22 -0
- package/layers/base/app/stores/app.ts +100 -0
- package/layers/base/app/utils/clipboard.ts +6 -0
- package/layers/base/app/utils/files.ts +95 -0
- package/layers/base/app/utils/image.ts +33 -0
- package/layers/base/nuxt.config.ts +146 -0
- package/layers/base/public/images/auth/microsoft.svg +1 -0
- package/layers/base/public/robots.txt +2 -0
- package/layers/base/server/api/admin/logs.get.ts +49 -0
- package/layers/base/server/api/auth/anonymous.post.ts +31 -0
- package/layers/base/server/api/auth/email/get.post.ts +46 -0
- package/layers/base/server/api/auth/email/verify.post.ts +46 -0
- package/layers/base/server/api/auth/index.get.ts +10 -0
- package/layers/base/server/api/auth/index.post.ts +19 -0
- package/layers/base/server/api/auth/options.post.ts +26 -0
- package/layers/base/server/api/auth/otp/get.post.ts +74 -0
- package/layers/base/server/api/auth/otp/verify.post.ts +70 -0
- package/layers/base/server/api/auth/password.post.ts +42 -0
- package/layers/base/server/api/auth/refresh.delete.ts +4 -0
- package/layers/base/server/api/auth/refresh.post.ts +5 -0
- package/layers/base/server/api/auth/totp/confirm.post.ts +26 -0
- package/layers/base/server/api/auth/totp/disable.post.ts +33 -0
- package/layers/base/server/api/auth/totp/generate.get.ts +6 -0
- package/layers/base/server/api/auth/totp/verify.post.ts +36 -0
- package/layers/base/server/api/auth/webauthn/authenticate.post.ts +39 -0
- package/layers/base/server/api/auth/webauthn/index.delete.ts +43 -0
- package/layers/base/server/api/auth/webauthn/index.post.ts +17 -0
- package/layers/base/server/api/auth/webauthn/register.post.ts +49 -0
- package/layers/base/server/api/files/[...path].get.ts +37 -0
- package/layers/base/server/api/files/chunk.post.ts +36 -0
- package/layers/base/server/api/files/copy.post.ts +22 -0
- package/layers/base/server/api/files/index.delete.ts +12 -0
- package/layers/base/server/api/files/index.get.ts +19 -0
- package/layers/base/server/api/files/index.post.ts +15 -0
- package/layers/base/server/api/log.post.ts +20 -0
- package/layers/base/server/database/defaults/access.ts +14 -0
- package/layers/base/server/database/defaults/db.ts +5 -0
- package/layers/base/server/database/defaults/relations.ts +5 -0
- package/layers/base/server/database/relations.ts +35 -0
- package/layers/base/server/database/schema.ts +66 -0
- package/layers/base/server/middleware/0.rateLimit.ts +21 -0
- package/layers/base/server/middleware/1.session.ts +14 -0
- package/layers/base/server/middleware/2.mfa.ts +34 -0
- package/layers/base/server/middleware/3.admin.ts +4 -0
- package/layers/base/server/plugins/session.ts +9 -0
- package/layers/base/server/routes/auth/microsoft.get.ts +21 -0
- package/layers/base/server/routes/dev/email/[template].ts +15 -0
- package/layers/base/server/tasks/backup.ts +42 -0
- package/layers/base/server/tasks/clean.ts +39 -0
- package/layers/base/server/utils/access.ts +8 -0
- package/layers/base/server/utils/auth.ts +207 -0
- package/layers/base/server/utils/backup.ts +98 -0
- package/layers/base/server/utils/db.ts +3 -0
- package/layers/base/server/utils/email.ts +138 -0
- package/layers/base/server/utils/files.ts +751 -0
- package/layers/base/server/utils/ip.ts +15 -0
- package/layers/base/server/utils/log.ts +31 -0
- package/layers/base/server/utils/rateLimit.ts +69 -0
- package/layers/base/server/utils/schemas.ts +8 -0
- package/layers/base/server/utils/text.ts +4 -0
- package/layers/base/server/utils/time.ts +3 -0
- package/layers/base/shared/types/auth.d.ts +22 -0
- package/layers/base/shared/utils/logs.ts +9 -0
- package/layers/base/shared/utils/url.ts +5 -0
- package/locales/en.json +33 -0
- package/locales/fr.json +33 -0
- package/locales/pages/admin-logs/en.json +1 -0
- package/locales/pages/admin-logs/fr.json +1 -0
- package/locales/pages/index/en.json +1 -0
- package/locales/pages/index/fr.json +1 -0
- package/locales/pages/signin/en.json +5 -0
- package/locales/pages/signin/fr.json +5 -0
- package/locales/pages/user-auth/en.json +22 -0
- package/locales/pages/user-auth/fr.json +22 -0
- package/nuxt.config.ts +54 -0
- package/package.json +92 -4
- package/scripts/db/migrate.ts +9 -0
- package/scripts/db/push-server.sh +45 -0
- package/scripts/db/restore.sh +26 -0
- package/scripts/deploy.sh +34 -0
- package/scripts/env.sh +20 -0
- package/scripts/remote/switch.sh +48 -0
- package/scripts/rollback.sh +12 -0
- package/server/assets/emails/base.html +43 -0
- package/server/assets/emails/en/base.html +1 -0
- package/server/assets/emails/en/emailChange.html +4 -0
- package/server/assets/emails/en/emailChanged.html +2 -0
- package/server/assets/emails/en/otp.html +10 -0
- package/server/assets/emails/fr/base.html +1 -0
- package/server/assets/emails/fr/emailChange.html +4 -0
- package/server/assets/emails/fr/emailChanged.html +2 -0
- 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,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,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,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
|
+
})
|