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.
@@ -56,7 +56,7 @@ async function signInAnonymously() {
56
56
  :src="config.public.logo"
57
57
  :alt="config.public.name"
58
58
  height="42"
59
- class="fg"
59
+ style="display: block"
60
60
  />
61
61
  <span v-else>{{ config.public.name }}</span>
62
62
  </I18nLink>
@@ -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 lastRefreshed = parseInt(localStorage.getItem('rotate-token-at') || '0')
9
- if (lastRefreshed) {
10
- const refreshAfter = lastRefreshed + useRuntimeConfig().public.refreshToken.rotateAfter * 1000
11
- const { expiresAt } = session.value
12
- if ((expiresAt !== undefined && expiresAt < Date.now()) || refreshAfter > Date.now()) return
13
- try {
14
- await $fetch('/api/auth/refresh', { method: 'POST' })
15
- } catch (error) {
16
- if ((error as FetchError).response?.status !== 401) return
17
- await clear()
18
- return
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
- localStorage.setItem('rotate-token-at', Date.now().toString())
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
- return await sendEmailTemplate('emailChange', locale, { otp }, email)
37
+ await sendEmailTemplate('emailChange', locale, { otp }, email)
38
38
  } catch (error) {
39
39
  await storage.removeItem(key)
40
40
  throw createError({
@@ -43,7 +43,7 @@ export default defineEventHandler(async (event) => {
43
43
  })
44
44
 
45
45
  try {
46
- return await sendEmailTemplate(
46
+ await sendEmailTemplate(
47
47
  'otp',
48
48
  locale,
49
49
  {
@@ -4,7 +4,7 @@ import type { OTP } from './get.post'
4
4
 
5
5
  const bodySchema = z.object({
6
6
  email: emailSchema,
7
- otp: z.string().length(6).or(z.string().min(32)),
7
+ otp: otpSchema,
8
8
  })
9
9
 
10
10
  export default defineEventHandler(async (event) => {
@@ -4,7 +4,7 @@ import { z } from 'zod'
4
4
  import { verify } from 'otplib'
5
5
 
6
6
  const bodySchema = z.object({
7
- secret: z.string().min(32),
7
+ secret: z.string().min(32).max(64), // generateSecret gives 32 base32 characters
8
8
  token: z.string().length(6),
9
9
  })
10
10
 
@@ -8,7 +8,7 @@ export default defineEventHandler(async (event) => {
8
8
  z.object({
9
9
  email: emailSchema,
10
10
  password: passwordSchema().optional(),
11
- otp: z.string().length(6).or(z.string().min(32)).optional(),
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
- 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
- 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
- return {
27
- result: {
28
- success: true,
29
- },
30
- }
31
- } catch (error: any) {
32
- await log(error.message, { stack: error.stack, cause: error.cause }, 'clean task', 'error')
33
- return {
34
- result: {
35
- success: false,
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
- let transporter: Transporter | null = null
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 config = useRuntimeConfig()
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(config.smtp.port)
18
+ const port = Number(smtp.port)
10
19
  transporter = nodemailer.createTransport({
11
- host: config.smtp.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: config.smtp.user,
18
- pass: config.smtp.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.smtp.from,
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 config = useRuntimeConfig()
124
- return { appName: config.public.name, url: config.public.url, logo: config.public.logo }
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: 'https://placehold.co/200x100?text=logo',
24
+ logo: '/logo.svg',
25
25
  },
26
26
  rateLimit: {
27
27
  routes: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxibase",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Nuxt layer with auth, files, logs and backups",
5
5
  "keywords": [
6
6
  "nuxt",