maxibase 1.1.1 → 1.2.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/layers/base/app/pages/signin.vue +1 -1
- package/layers/base/app/plugins/session.ts +19 -12
- package/layers/base/server/api/auth/email/get.post.ts +1 -1
- package/layers/base/server/api/auth/otp/get.post.ts +1 -1
- package/layers/base/server/api/auth/otp/verify.post.ts +1 -1
- package/layers/base/server/api/auth/totp/confirm.post.ts +1 -1
- package/layers/base/server/api/auth/totp/verify.post.ts +1 -1
- package/layers/base/server/tasks/clean.ts +22 -28
- package/layers/base/server/utils/backup.ts +14 -0
- package/layers/base/server/utils/email.ts +28 -13
- package/layers/base/server/utils/schemas.ts +3 -0
- package/nuxt.config.ts +2 -2
- package/package.json +1 -1
|
@@ -5,18 +5,25 @@ export default defineNuxtPlugin(async () => {
|
|
|
5
5
|
const { session, clear } = useUserSession()
|
|
6
6
|
if (!session.value?.user) return
|
|
7
7
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
const rotate = async () => {
|
|
9
|
+
const lastRefreshed = parseInt(localStorage.getItem('rotate-token-at') || '0')
|
|
10
|
+
if (lastRefreshed) {
|
|
11
|
+
const refreshAfter = lastRefreshed + useRuntimeConfig().public.refreshToken.rotateAfter * 1000
|
|
12
|
+
const { expiresAt } = session.value!
|
|
13
|
+
if ((expiresAt !== undefined && expiresAt < Date.now()) || refreshAfter > Date.now()) return
|
|
14
|
+
try {
|
|
15
|
+
await $fetch('/api/auth/refresh', { method: 'POST' })
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if ((error as FetchError).response?.status !== 401) return
|
|
18
|
+
await clear()
|
|
19
|
+
return
|
|
20
|
+
}
|
|
19
21
|
}
|
|
22
|
+
localStorage.setItem('rotate-token-at', Date.now().toString())
|
|
20
23
|
}
|
|
21
|
-
|
|
24
|
+
|
|
25
|
+
// Tabs restored together would all send the same token: the first one rotates it and the
|
|
26
|
+
// 401 of the others deletes the cookie it just got. One tab rotates, the others share its cookie.
|
|
27
|
+
if (!navigator.locks) return rotate()
|
|
28
|
+
await navigator.locks.request('rotate-token', { ifAvailable: true }, (lock) => lock && rotate())
|
|
22
29
|
})
|
|
@@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
|
|
|
34
34
|
})
|
|
35
35
|
|
|
36
36
|
try {
|
|
37
|
-
|
|
37
|
+
await sendEmailTemplate('emailChange', locale, { otp }, email)
|
|
38
38
|
} catch (error) {
|
|
39
39
|
await storage.removeItem(key)
|
|
40
40
|
throw createError({
|
|
@@ -8,7 +8,7 @@ export default defineEventHandler(async (event) => {
|
|
|
8
8
|
z.object({
|
|
9
9
|
email: emailSchema,
|
|
10
10
|
password: passwordSchema().optional(),
|
|
11
|
-
otp:
|
|
11
|
+
otp: otpSchema.optional(),
|
|
12
12
|
token: z.string().length(6),
|
|
13
13
|
}).parse,
|
|
14
14
|
)
|
|
@@ -6,35 +6,29 @@ export default defineTask({
|
|
|
6
6
|
async run() {
|
|
7
7
|
const config = useRuntimeConfig()
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
await cleanTempUploads()
|
|
9
|
+
const steps: [string, () => Promise<unknown>][] = [['backups', cleanOldBackups]]
|
|
10
|
+
if (config.s3.privateBucket) steps.push(['S3 backups', cleanOldS3Backups])
|
|
11
|
+
steps.push(['logs', cleanLogs], ['uploads', () => cleanTempUploads()])
|
|
25
12
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
13
|
+
const results = await Promise.allSettled(steps.map(([, step]) => step()))
|
|
14
|
+
|
|
15
|
+
let success = true
|
|
16
|
+
for (const [i, result] of results.entries()) {
|
|
17
|
+
if (result.status === 'fulfilled') continue
|
|
18
|
+
success = false
|
|
19
|
+
const error = result.reason
|
|
20
|
+
await log(
|
|
21
|
+
`Cleaning ${steps[i]![0]} failed: ${error?.message}`,
|
|
22
|
+
{ stack: error?.stack, cause: error?.cause },
|
|
23
|
+
'clean task',
|
|
24
|
+
'error',
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
result: {
|
|
30
|
+
success,
|
|
31
|
+
},
|
|
38
32
|
}
|
|
39
33
|
},
|
|
40
34
|
})
|
|
@@ -96,3 +96,17 @@ export async function cleanOldBackups() {
|
|
|
96
96
|
}),
|
|
97
97
|
)
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
export async function cleanOldS3Backups() {
|
|
101
|
+
const config = useRuntimeConfig()
|
|
102
|
+
const backups = await listFromS3('backups', true)
|
|
103
|
+
const retentionMs = daysToMilliseconds(config.backup.retentionDays)
|
|
104
|
+
const cutoffDate = new Date(Date.now() - retentionMs)
|
|
105
|
+
await Promise.all(
|
|
106
|
+
backups.map((b) => {
|
|
107
|
+
if (b.updatedAt && b.updatedAt < cutoffDate) {
|
|
108
|
+
return deleteFromS3(`backups/${b.name}`, true)
|
|
109
|
+
}
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -1,23 +1,33 @@
|
|
|
1
1
|
import nodemailer from 'nodemailer'
|
|
2
2
|
import type { Transporter } from 'nodemailer'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
export type SmtpConfig = {
|
|
5
|
+
host: string
|
|
6
|
+
port: number | string
|
|
7
|
+
user: string
|
|
8
|
+
pass: string
|
|
9
|
+
from: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const transporters = new Map<string, Transporter>()
|
|
5
13
|
|
|
6
|
-
function useTransporter() {
|
|
7
|
-
const
|
|
14
|
+
function useTransporter(smtp: SmtpConfig) {
|
|
15
|
+
const key = JSON.stringify([smtp.host, smtp.port, smtp.user, smtp.pass])
|
|
16
|
+
let transporter = transporters.get(key)
|
|
8
17
|
if (!transporter) {
|
|
9
|
-
const port = Number(
|
|
18
|
+
const port = Number(smtp.port)
|
|
10
19
|
transporter = nodemailer.createTransport({
|
|
11
|
-
host:
|
|
20
|
+
host: smtp.host,
|
|
12
21
|
port,
|
|
13
22
|
secure: port === 465,
|
|
14
23
|
requireTLS: port !== 465,
|
|
15
24
|
pool: true,
|
|
16
25
|
auth: {
|
|
17
|
-
user:
|
|
18
|
-
pass:
|
|
26
|
+
user: smtp.user,
|
|
27
|
+
pass: smtp.pass,
|
|
19
28
|
},
|
|
20
29
|
})
|
|
30
|
+
transporters.set(key, transporter)
|
|
21
31
|
}
|
|
22
32
|
return transporter
|
|
23
33
|
}
|
|
@@ -39,12 +49,15 @@ export async function sendEmail(
|
|
|
39
49
|
attachments?: any[],
|
|
40
50
|
bcc?: string,
|
|
41
51
|
params = {},
|
|
52
|
+
replyTo?: string,
|
|
53
|
+
smtp?: SmtpConfig,
|
|
42
54
|
) {
|
|
43
|
-
const config = useRuntimeConfig()
|
|
44
|
-
const mail = await useTransporter().sendMail({
|
|
45
|
-
from: config.
|
|
55
|
+
const config = smtp ?? (useRuntimeConfig().smtp as SmtpConfig)
|
|
56
|
+
const mail = await useTransporter(config).sendMail({
|
|
57
|
+
from: config.from,
|
|
46
58
|
to,
|
|
47
59
|
bcc,
|
|
60
|
+
replyTo,
|
|
48
61
|
subject: fillTemplate(subject, params, false),
|
|
49
62
|
html: await buildEmail(html, locale, params),
|
|
50
63
|
attachments,
|
|
@@ -73,9 +86,11 @@ export async function sendEmailTemplate(
|
|
|
73
86
|
to: string,
|
|
74
87
|
attachments?: any[],
|
|
75
88
|
bcc?: string,
|
|
89
|
+
replyTo?: string,
|
|
90
|
+
smtp?: SmtpConfig,
|
|
76
91
|
) {
|
|
77
92
|
const { subject, html } = await buildEmailTemplate(templateId, locale)
|
|
78
|
-
return sendEmail(to, subject, html, locale, attachments, bcc, params)
|
|
93
|
+
return sendEmail(to, subject, html, locale, attachments, bcc, params, replyTo, smtp)
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
export async function buildEmailTemplate(templateId: string, locale: string = 'en') {
|
|
@@ -120,8 +135,8 @@ function inlineStyles(html: string): string {
|
|
|
120
135
|
}
|
|
121
136
|
|
|
122
137
|
function appPlaceholders() {
|
|
123
|
-
const
|
|
124
|
-
return { appName:
|
|
138
|
+
const { name, url, logo } = useRuntimeConfig().public
|
|
139
|
+
return { appName: name, url, logo: logo ? (URL.parse(logo, url)?.href ?? logo) : logo }
|
|
125
140
|
}
|
|
126
141
|
|
|
127
142
|
// {{value}} is escaped, {{{value}}} is inserted as raw html. Reach for the raw
|
|
@@ -2,6 +2,9 @@ import { z } from 'zod'
|
|
|
2
2
|
|
|
3
3
|
export const emailSchema = z.string().trim().toLowerCase().pipe(z.email())
|
|
4
4
|
|
|
5
|
+
// The emailed 6-digit code, or the magic-link token (32 bytes in base64url, 43 characters)
|
|
6
|
+
export const otpSchema = z.string().length(6).or(z.string().min(32).max(64))
|
|
7
|
+
|
|
5
8
|
export const passwordSchema = () => {
|
|
6
9
|
const { min, max } = useRuntimeConfig().public.password
|
|
7
10
|
return z.string().trim().min(min).max(max)
|
package/nuxt.config.ts
CHANGED
|
@@ -13,7 +13,7 @@ export default defineNuxtConfig({
|
|
|
13
13
|
lang: 'en',
|
|
14
14
|
},
|
|
15
15
|
title: process.env.NUXT_PUBLIC_NAME,
|
|
16
|
-
link: [],
|
|
16
|
+
link: [{ rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' }],
|
|
17
17
|
meta: [{ name: 'theme-color', content: '#000000' }],
|
|
18
18
|
},
|
|
19
19
|
},
|
|
@@ -21,7 +21,7 @@ export default defineNuxtConfig({
|
|
|
21
21
|
public: {
|
|
22
22
|
url: 'http://localhost:3000',
|
|
23
23
|
name: 'Nuxt base',
|
|
24
|
-
logo: '
|
|
24
|
+
logo: '/logo.svg',
|
|
25
25
|
},
|
|
26
26
|
rateLimit: {
|
|
27
27
|
routes: {
|