maxibase 0.0.0-stage → 1.0.1

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 +270 -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 +13 -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,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,4 @@
1
+ export default defineEventHandler(async (event) => {
2
+ if (!event.path.startsWith('/api/admin/')) return
3
+ await isAdmin(event)
4
+ })
@@ -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,8 @@
1
+ import type { H3Event } from 'h3'
2
+
3
+ export async function isAdmin(event: H3Event) {
4
+ const { user } = await requireUserSession(event)
5
+ if (user.role === 'admin') return true
6
+
7
+ throw createError({ statusCode: 403, message: 'Insufficient permissions' })
8
+ }
@@ -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,3 @@
1
+ import { db } from '#database/db'
2
+
3
+ export { db }
@@ -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('&', '&amp;')
28
+ .replaceAll('<', '&lt;')
29
+ .replaceAll('>', '&gt;')
30
+ .replaceAll('"', '&quot;')
31
+ .replaceAll("'", '&#39;')
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
+ }