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,34 @@
|
|
|
1
|
+
// a session flagged requiresMfaSetup is only allowed to enroll a second factor
|
|
2
|
+
const ENROLLMENT_ROUTES = [
|
|
3
|
+
'/api/_auth/session', // read and clear the session
|
|
4
|
+
'/api/auth', // GET only, lists the credentials shown on /user/auth
|
|
5
|
+
'/api/auth/webauthn/register',
|
|
6
|
+
'/api/auth/totp/generate',
|
|
7
|
+
'/api/auth/totp/confirm',
|
|
8
|
+
'/api/auth/refresh', // DELETE, sign out from all devices
|
|
9
|
+
'/api/log', // client error reporting, rate limited
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
export default defineEventHandler(async (event) => {
|
|
13
|
+
if (!useRuntimeConfig().forceMfa) return
|
|
14
|
+
if (!event.path.startsWith('/api/')) return
|
|
15
|
+
|
|
16
|
+
const session = await getUserSession(event)
|
|
17
|
+
if (!session.user?.email) return
|
|
18
|
+
|
|
19
|
+
let { requiresMfaSetup } = session.user
|
|
20
|
+
if (requiresMfaSetup === undefined) {
|
|
21
|
+
requiresMfaSetup = (await mfaSetupFlag(session.user))?.requiresMfaSetup ?? false
|
|
22
|
+
await replaceUserSession(event, {
|
|
23
|
+
...session,
|
|
24
|
+
user: { ...session.user, requiresMfaSetup },
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
if (!requiresMfaSetup) return
|
|
28
|
+
|
|
29
|
+
const path = event.path.split('?')[0]!
|
|
30
|
+
if (path.startsWith('/api/auth/otp/') || path === '/api/auth/password') return // re-authentication
|
|
31
|
+
if (ENROLLMENT_ROUTES.includes(path) && !(path === '/api/auth' && event.method !== 'GET')) return
|
|
32
|
+
|
|
33
|
+
throw createError({ status: 403, message: 'MFA setup required' })
|
|
34
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export default defineNitroPlugin(() => {
|
|
2
|
+
sessionHooks.hook('clear', async (session, event) => {
|
|
3
|
+
const refreshToken = getCookie(event, 'refresh_token')
|
|
4
|
+
if (refreshToken) {
|
|
5
|
+
deleteCookie(event, 'refresh_token')
|
|
6
|
+
await revokeRefreshToken(refreshToken)
|
|
7
|
+
}
|
|
8
|
+
})
|
|
9
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
}
|
|
9
|
+
|
|
10
|
+
const auth = await getAuth(event, user.mail).catch((error) => {
|
|
11
|
+
const config = useRuntimeConfig()
|
|
12
|
+
|
|
13
|
+
if (error.statusCode === 404 && config.autoSignup) {
|
|
14
|
+
return createAuth({ email: user.mail })
|
|
15
|
+
}
|
|
16
|
+
throw error
|
|
17
|
+
})
|
|
18
|
+
await setSession(event, auth)
|
|
19
|
+
return sendRedirect(event, '/')
|
|
20
|
+
},
|
|
21
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export default defineEventHandler(async (event) => {
|
|
2
|
+
if (process.env.NODE_ENV === 'production') {
|
|
3
|
+
throw createError({
|
|
4
|
+
statusCode: 404,
|
|
5
|
+
message: 'Not found',
|
|
6
|
+
})
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const templateId = event.context.params?.template || ''
|
|
10
|
+
const query = getQuery(event)
|
|
11
|
+
const locale = (query.locale as string) || 'en'
|
|
12
|
+
|
|
13
|
+
const template = (await buildEmailTemplate(templateId, locale)).html
|
|
14
|
+
return buildEmail(template, locale, query)
|
|
15
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createReadStream } from 'fs'
|
|
2
|
+
|
|
3
|
+
export default defineTask({
|
|
4
|
+
meta: {
|
|
5
|
+
name: 'backup',
|
|
6
|
+
description: 'Backup DB',
|
|
7
|
+
},
|
|
8
|
+
async run() {
|
|
9
|
+
const timestamp = new Date().toISOString()
|
|
10
|
+
try {
|
|
11
|
+
const config = useRuntimeConfig()
|
|
12
|
+
const agePublicKey = config.backup.agePublicKey
|
|
13
|
+
const backupName = `backups/backup-${timestamp}.dump${agePublicKey ? '.age' : ''}`
|
|
14
|
+
|
|
15
|
+
await createDatabaseBackup(backupName, config.db, config.backup.dumpArgs, agePublicKey)
|
|
16
|
+
if (config.s3.privateBucket) {
|
|
17
|
+
await uploadStreamToS3(
|
|
18
|
+
backupName,
|
|
19
|
+
createReadStream(backupName),
|
|
20
|
+
'application/octet-stream',
|
|
21
|
+
'private',
|
|
22
|
+
true,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
await log('Backup created', { backupName }, 'backup task', 'info')
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
result: {
|
|
29
|
+
success: true,
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
} catch (error: any) {
|
|
33
|
+
await log(error.message, { stack: error.stack, cause: error.cause }, 'backup task', 'error')
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
result: {
|
|
37
|
+
success: false,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export default defineTask({
|
|
2
|
+
meta: {
|
|
3
|
+
name: 'clean',
|
|
4
|
+
description: 'Clean old backups and logs',
|
|
5
|
+
},
|
|
6
|
+
async run() {
|
|
7
|
+
const config = useRuntimeConfig()
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
await cleanOldBackups()
|
|
11
|
+
if (config.s3.privateBucket) {
|
|
12
|
+
const backups = await listFromS3('backups', true)
|
|
13
|
+
const retentionMs = daysToMilliseconds(config.backup.retentionDays)
|
|
14
|
+
const cutoffDate = new Date(Date.now() - retentionMs)
|
|
15
|
+
await Promise.all(
|
|
16
|
+
backups.map((b) => {
|
|
17
|
+
if (b.updatedAt && b.updatedAt < cutoffDate) {
|
|
18
|
+
return deleteFromS3(`backups/${b.name}`, true)
|
|
19
|
+
}
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
await cleanLogs()
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
result: {
|
|
27
|
+
success: true,
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
} catch (error: any) {
|
|
31
|
+
await log(error.message, { stack: error.stack, cause: error.cause }, 'clean task', 'error')
|
|
32
|
+
return {
|
|
33
|
+
result: {
|
|
34
|
+
success: false,
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { auth, refreshTokens } from '#server/database/schema'
|
|
2
|
+
import { eq, lte } from 'drizzle-orm'
|
|
3
|
+
import { randomBytes } from 'crypto'
|
|
4
|
+
import type { H3Event } from 'h3'
|
|
5
|
+
|
|
6
|
+
type AuthUser = typeof auth.$inferSelect
|
|
7
|
+
type SessionUser = Pick<AuthUser, 'id'> & Partial<Pick<AuthUser, 'email' | 'role'>>
|
|
8
|
+
|
|
9
|
+
export async function mfaSetupFlag(user: SessionUser) {
|
|
10
|
+
const config = useRuntimeConfig()
|
|
11
|
+
if (!config.forceMfa || !user.email) return
|
|
12
|
+
|
|
13
|
+
const row = await db.query.auth.findFirst({
|
|
14
|
+
where: { id: user.id },
|
|
15
|
+
with: { credentials: true },
|
|
16
|
+
})
|
|
17
|
+
return { requiresMfaSetup: !row?.totp && !row?.credentials.length }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function createAuth(user: Pick<AuthUser, 'email'>) {
|
|
21
|
+
const insertedUsers = await db
|
|
22
|
+
.insert(auth)
|
|
23
|
+
.values({
|
|
24
|
+
email: user.email?.trim().toLowerCase(),
|
|
25
|
+
})
|
|
26
|
+
.returning()
|
|
27
|
+
if (!insertedUsers[0]) {
|
|
28
|
+
throw createError({
|
|
29
|
+
status: 500,
|
|
30
|
+
message: 'User creation failed',
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
return insertedUsers[0]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function setAuth(user: Pick<AuthUser, 'id' | 'email'>) {
|
|
37
|
+
const insertedUsers = await db
|
|
38
|
+
.update(auth)
|
|
39
|
+
.set({
|
|
40
|
+
email: user.email?.trim().toLowerCase(),
|
|
41
|
+
})
|
|
42
|
+
.where(eq(auth.id, user.id))
|
|
43
|
+
.returning()
|
|
44
|
+
if (!insertedUsers[0]) {
|
|
45
|
+
throw createError({
|
|
46
|
+
status: 500,
|
|
47
|
+
message: 'User update failed',
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
return insertedUsers[0]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function getAuth(event: H3Event, email?: string, credentials = false) {
|
|
54
|
+
const id = email ? undefined : (await getUserSession(event)).user?.id
|
|
55
|
+
// an undefined filter is dropped by drizzle, which would match an arbitrary user
|
|
56
|
+
if (!email && !id) {
|
|
57
|
+
throw createError({
|
|
58
|
+
status: 401,
|
|
59
|
+
message: 'Not authenticated',
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const where = email ? { email: email.trim().toLowerCase() } : { id }
|
|
64
|
+
const user = await db.query.auth.findFirst({
|
|
65
|
+
where,
|
|
66
|
+
with: {
|
|
67
|
+
credentials,
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
if (!user) {
|
|
71
|
+
throw createError({
|
|
72
|
+
status: 404,
|
|
73
|
+
message: 'User not found',
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
return user
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function setSession(event: H3Event, user: SessionUser, refresh = true) {
|
|
80
|
+
if (refresh) {
|
|
81
|
+
if (useRuntimeConfig().session.unique) {
|
|
82
|
+
await revokeAllUserTokens(user.id)
|
|
83
|
+
} else {
|
|
84
|
+
const existingToken = getCookie(event, 'refresh_token')
|
|
85
|
+
if (existingToken) {
|
|
86
|
+
await revokeRefreshToken(existingToken)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
await createRefreshToken(user.id, event)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sessionMaxAge = useRuntimeConfig().session.maxAge
|
|
93
|
+
const authenticatedAt = refresh
|
|
94
|
+
? Date.now()
|
|
95
|
+
: ((await getUserSession(event)).authenticatedAt ?? 0)
|
|
96
|
+
|
|
97
|
+
// replace, not set: setUserSession merges with defu, so an absent
|
|
98
|
+
// requiresMfaSetup would never clear a previously flagged session
|
|
99
|
+
return replaceUserSession(event, {
|
|
100
|
+
user: {
|
|
101
|
+
id: user.id,
|
|
102
|
+
email: user.email?.trim().toLowerCase() ?? undefined,
|
|
103
|
+
role: user.role || 'user',
|
|
104
|
+
isAnonymous: !user.email,
|
|
105
|
+
...(await mfaSetupFlag(user)),
|
|
106
|
+
},
|
|
107
|
+
authenticatedAt,
|
|
108
|
+
expiresAt: sessionMaxAge ? Date.now() + sessionMaxAge * 1000 : undefined,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function requireRecentAuth(event: H3Event) {
|
|
113
|
+
const session = await requireUserSession(event)
|
|
114
|
+
|
|
115
|
+
if (session.user.isAnonymous || session.user.requiresMfaSetup) return session
|
|
116
|
+
|
|
117
|
+
const maxAge = useRuntimeConfig().public.recentAuth.maxAge * 1000
|
|
118
|
+
if (!session.authenticatedAt || Date.now() - session.authenticatedAt > maxAge) {
|
|
119
|
+
throw createError({
|
|
120
|
+
status: 403,
|
|
121
|
+
statusMessage: 'reauth_required',
|
|
122
|
+
message: 'Please verify your identity again to continue.',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return session
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function generateRefreshToken(): string {
|
|
130
|
+
return randomBytes(32).toString('base64url')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function createRefreshToken(userId: string, event: H3Event) {
|
|
134
|
+
const token = generateRefreshToken()
|
|
135
|
+
|
|
136
|
+
await db.insert(refreshTokens).values({
|
|
137
|
+
userId,
|
|
138
|
+
token,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const config = useRuntimeConfig()
|
|
142
|
+
setCookie(event, 'refresh_token', token, {
|
|
143
|
+
httpOnly: true,
|
|
144
|
+
secure: true,
|
|
145
|
+
sameSite: 'strict',
|
|
146
|
+
maxAge: config.refreshToken.maxAge,
|
|
147
|
+
path: '/',
|
|
148
|
+
})
|
|
149
|
+
return token
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function verifyRefreshToken(event: H3Event) {
|
|
153
|
+
const existingToken = getCookie(event, 'refresh_token')
|
|
154
|
+
if (!existingToken) {
|
|
155
|
+
throw createError({
|
|
156
|
+
status: 401,
|
|
157
|
+
message: 'No token provided',
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const refreshToken = await db.query.refreshTokens.findFirst({ where: { token: existingToken } })
|
|
162
|
+
if (!refreshToken) {
|
|
163
|
+
deleteCookie(event, 'refresh_token')
|
|
164
|
+
throw createError({
|
|
165
|
+
status: 401,
|
|
166
|
+
message: 'No token found',
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const user = await db.query.auth.findFirst({
|
|
171
|
+
where: { id: refreshToken.userId },
|
|
172
|
+
})
|
|
173
|
+
if (!user) {
|
|
174
|
+
deleteCookie(event, 'refresh_token')
|
|
175
|
+
throw createError({
|
|
176
|
+
status: 401,
|
|
177
|
+
message: 'No user found',
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const config = useRuntimeConfig()
|
|
182
|
+
const createdAt = refreshToken.createdAt.getTime()
|
|
183
|
+
const creationExpiredAt = Date.now() - config.refreshToken.maxAge * 1000
|
|
184
|
+
|
|
185
|
+
if (createdAt < creationExpiredAt) {
|
|
186
|
+
await revokeExpiredRefreshToken(new Date(creationExpiredAt))
|
|
187
|
+
deleteCookie(event, 'refresh_token')
|
|
188
|
+
throw createError({
|
|
189
|
+
status: 401,
|
|
190
|
+
message: 'Token expired',
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { refreshToken, user }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function revokeRefreshToken(token: string) {
|
|
198
|
+
await db.delete(refreshTokens).where(eq(refreshTokens.token, token))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function revokeExpiredRefreshToken(creationExpiredAt: Date) {
|
|
202
|
+
await db.delete(refreshTokens).where(lte(refreshTokens.createdAt, creationExpiredAt))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function revokeAllUserTokens(userId: string) {
|
|
206
|
+
await db.delete(refreshTokens).where(eq(refreshTokens.userId, userId))
|
|
207
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'child_process'
|
|
2
|
+
import fs from 'fs/promises'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
|
|
5
|
+
// pg_dump takes the connection string in argv, where `ps` shows it to every process on
|
|
6
|
+
// the host, so move the password into the child's environment instead.
|
|
7
|
+
function splitCredentials(dbUrl: string) {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(dbUrl)
|
|
10
|
+
if (!url.password) return { conn: dbUrl, env: {} }
|
|
11
|
+
const password = decodeURIComponent(url.password)
|
|
12
|
+
url.password = ''
|
|
13
|
+
return { conn: url.toString(), env: { PGPASSWORD: password } }
|
|
14
|
+
} catch {
|
|
15
|
+
return { conn: dbUrl, env: {} } // a libpq keyword/value string, leave it alone
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function waitForExit(child: ChildProcess, name: string) {
|
|
20
|
+
let stderr = ''
|
|
21
|
+
child.stderr?.on('data', (chunk) => {
|
|
22
|
+
stderr = (stderr + chunk).slice(-2000)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return new Promise<void>((resolve, reject) => {
|
|
26
|
+
child.on('error', (e: NodeJS.ErrnoException) =>
|
|
27
|
+
reject(e.code === 'ENOENT' ? new Error(`${name} is not installed`) : e),
|
|
28
|
+
)
|
|
29
|
+
child.on('close', (code, signal) => {
|
|
30
|
+
if (code === 0) return resolve()
|
|
31
|
+
const how = signal ? `signal ${signal}` : `exit code ${code}`
|
|
32
|
+
reject(new Error(`${name} failed (${how})${stderr ? `: ${stderr.trim()}` : ''}`))
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function createDatabaseBackup(
|
|
38
|
+
backupName: string,
|
|
39
|
+
dbUrl: string,
|
|
40
|
+
dumpArgs: string = '-F c',
|
|
41
|
+
agePublicKey?: string,
|
|
42
|
+
) {
|
|
43
|
+
const { conn, env } = splitCredentials(dbUrl)
|
|
44
|
+
const args = [...dumpArgs.split(' ').filter(Boolean), conn]
|
|
45
|
+
const options = { env: { ...process.env, ...env } }
|
|
46
|
+
|
|
47
|
+
if (!agePublicKey) {
|
|
48
|
+
const dump = spawn('pg_dump', [...args, '-f', backupName], {
|
|
49
|
+
...options,
|
|
50
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
51
|
+
})
|
|
52
|
+
return waitForExit(dump, 'pg_dump')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// age needs only the public key, so the server can write backups it cannot read back.
|
|
56
|
+
// Piping keeps the plaintext dump off the disk entirely.
|
|
57
|
+
const dump = spawn('pg_dump', args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
58
|
+
const encrypt = spawn('age', ['-r', agePublicKey, '-o', backupName], {
|
|
59
|
+
stdio: ['pipe', 'ignore', 'pipe'],
|
|
60
|
+
})
|
|
61
|
+
dump.stdout!.on('error', () => {}) // the EPIPE that follows the kills below
|
|
62
|
+
encrypt.stdin!.on('error', () => {})
|
|
63
|
+
dump.stdout!.pipe(encrypt.stdin!)
|
|
64
|
+
|
|
65
|
+
// Either one dying leaves the other blocked on a pipe nobody touches again.
|
|
66
|
+
dump.on('close', (code) => code !== 0 && encrypt.kill())
|
|
67
|
+
encrypt.on('close', (code) => code !== 0 && dump.kill())
|
|
68
|
+
|
|
69
|
+
// Kept in the order they happened: the first is the cause, any second is just the
|
|
70
|
+
// peer reporting the pipe we broke.
|
|
71
|
+
const errors: Error[] = []
|
|
72
|
+
const collect = (child: ChildProcess, name: string) =>
|
|
73
|
+
waitForExit(child, name).catch((e) => void errors.push(e))
|
|
74
|
+
await Promise.all([collect(dump, 'pg_dump'), collect(encrypt, 'age')])
|
|
75
|
+
|
|
76
|
+
if (errors.length) {
|
|
77
|
+
await fs.rm(backupName, { force: true }) // a truncated .age is not a backup
|
|
78
|
+
throw errors[0]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function cleanOldBackups() {
|
|
83
|
+
const backupDir = path.resolve(process.cwd(), 'backups')
|
|
84
|
+
const files = await fs.readdir(backupDir)
|
|
85
|
+
const now = Date.now()
|
|
86
|
+
const config = useRuntimeConfig()
|
|
87
|
+
const retentionMs = daysToMilliseconds(config.backup.retentionDays)
|
|
88
|
+
|
|
89
|
+
await Promise.all(
|
|
90
|
+
files.map(async (file) => {
|
|
91
|
+
const filePath = path.join(backupDir, file)
|
|
92
|
+
const stats = await fs.stat(filePath)
|
|
93
|
+
if (now - stats.mtimeMs > retentionMs) {
|
|
94
|
+
await fs.unlink(filePath) // Delete old backup
|
|
95
|
+
}
|
|
96
|
+
}),
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer'
|
|
2
|
+
import type { Transporter } from 'nodemailer'
|
|
3
|
+
|
|
4
|
+
let transporter: Transporter | null = null
|
|
5
|
+
|
|
6
|
+
function useTransporter() {
|
|
7
|
+
const config = useRuntimeConfig()
|
|
8
|
+
if (!transporter) {
|
|
9
|
+
const port = Number(config.smtp.port)
|
|
10
|
+
transporter = nodemailer.createTransport({
|
|
11
|
+
host: config.smtp.host,
|
|
12
|
+
port,
|
|
13
|
+
secure: port === 465,
|
|
14
|
+
requireTLS: port !== 465,
|
|
15
|
+
pool: true,
|
|
16
|
+
auth: {
|
|
17
|
+
user: config.smtp.user,
|
|
18
|
+
pass: config.smtp.pass,
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
return transporter
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function escapeHtml(value: unknown) {
|
|
26
|
+
return String(value)
|
|
27
|
+
.replaceAll('&', '&')
|
|
28
|
+
.replaceAll('<', '<')
|
|
29
|
+
.replaceAll('>', '>')
|
|
30
|
+
.replaceAll('"', '"')
|
|
31
|
+
.replaceAll("'", ''')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function sendEmail(
|
|
35
|
+
to: string,
|
|
36
|
+
subject: string,
|
|
37
|
+
html: string,
|
|
38
|
+
locale: string = 'en',
|
|
39
|
+
attachments?: any[],
|
|
40
|
+
bcc?: string,
|
|
41
|
+
params = {},
|
|
42
|
+
) {
|
|
43
|
+
const config = useRuntimeConfig()
|
|
44
|
+
const mail = await useTransporter().sendMail({
|
|
45
|
+
from: config.smtp.from,
|
|
46
|
+
to,
|
|
47
|
+
bcc,
|
|
48
|
+
subject: fillTemplate(subject, params, false),
|
|
49
|
+
html: await buildEmail(html, locale, params),
|
|
50
|
+
attachments,
|
|
51
|
+
})
|
|
52
|
+
return mail
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function getEmailAsset(key: string) {
|
|
56
|
+
return ((await useStorage('assets:server').getItem(`emails/${key}`)) ??
|
|
57
|
+
(await useStorage('assets:base').getItem(`emails/${key}`))) as string | null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function buildEmail(html: string, locale: string = 'en', params = {}) {
|
|
61
|
+
const base = (await getEmailAsset('base.html')) as string
|
|
62
|
+
const localeBase = await getEmailAsset(`${locale}/base.html`)
|
|
63
|
+
return fillTemplate(
|
|
64
|
+
inlineStyles(base.replace('{{content}}', localeBase?.replace('{{content}}', html) || html)),
|
|
65
|
+
params,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function sendEmailTemplate(
|
|
70
|
+
templateId: string,
|
|
71
|
+
locale: string = 'en',
|
|
72
|
+
params: Record<string, any> = {},
|
|
73
|
+
to: string,
|
|
74
|
+
attachments?: any[],
|
|
75
|
+
bcc?: string,
|
|
76
|
+
) {
|
|
77
|
+
const { subject, html } = await buildEmailTemplate(templateId, locale)
|
|
78
|
+
return sendEmail(to, subject, html, locale, attachments, bcc, params)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function buildEmailTemplate(templateId: string, locale: string = 'en') {
|
|
82
|
+
const template =
|
|
83
|
+
(await getEmailAsset(`${locale}/${templateId}.html`)) ??
|
|
84
|
+
(await getEmailAsset(`en/${templateId}.html`))
|
|
85
|
+
if (!template) {
|
|
86
|
+
throw new Error(`Email template not found: ${locale}/${templateId}`)
|
|
87
|
+
}
|
|
88
|
+
const match = template.match(/<title>(.*?)<\/title>/i)
|
|
89
|
+
const subject = match ? (match[1] as string) : '{{appName}}'
|
|
90
|
+
return {
|
|
91
|
+
subject,
|
|
92
|
+
html: template,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getClasses(html: string) {
|
|
97
|
+
const classes: Record<string, string> = {}
|
|
98
|
+
const style = html
|
|
99
|
+
.replaceAll(/\n/g, '')
|
|
100
|
+
.replaceAll(/\s{2,}/g, ' ')
|
|
101
|
+
.replace(/.*<style>/, '')
|
|
102
|
+
.replace(/<\/style>.*/, '')
|
|
103
|
+
style.split('}').forEach((rule) => {
|
|
104
|
+
const [selectors, declarations] = rule.split('{')
|
|
105
|
+
if (!selectors || !declarations) return
|
|
106
|
+
classes[selectors.trim().replace(/^\./, '')] = declarations.trim()
|
|
107
|
+
})
|
|
108
|
+
return classes
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function inlineStyles(html: string): string {
|
|
112
|
+
const classes = getClasses(html)
|
|
113
|
+
return html.replace(/class="(.*?)"/g, (_, className) => {
|
|
114
|
+
const styles = className
|
|
115
|
+
.split(' ')
|
|
116
|
+
.map((name: string) => classes[name] || '')
|
|
117
|
+
.join('; ')
|
|
118
|
+
return `style="${styles}"`
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function appPlaceholders() {
|
|
123
|
+
const config = useRuntimeConfig()
|
|
124
|
+
return { appName: config.public.name, url: config.public.url, logo: config.public.logo }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// {{value}} is escaped, {{{value}}} is inserted as raw html. Reach for the raw
|
|
128
|
+
// form only for markup we build ourselves, never for a user-controlled value.
|
|
129
|
+
export function fillTemplate(html: string, params = {}, escape = true) {
|
|
130
|
+
const context: Record<string, any> = { ...appPlaceholders(), ...params }
|
|
131
|
+
return html.replace(/{{{(\w+)}}}|{{(\w+)}}/g, (match, rawKey?: string, key?: string) => {
|
|
132
|
+
const name = rawKey ?? key
|
|
133
|
+
if (!name) return match
|
|
134
|
+
const value = context[name]
|
|
135
|
+
if (value === undefined || value === null) return ''
|
|
136
|
+
return escape && !rawKey ? escapeHtml(value) : String(value)
|
|
137
|
+
})
|
|
138
|
+
}
|