maxibase 1.6.0 → 1.7.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 CHANGED
@@ -37,6 +37,19 @@ DELETE FROM auth.credentials WHERE user_id = (SELECT id FROM auth.users WHERE em
37
37
  DELETE FROM auth.recovery_codes WHERE user_id = (SELECT id FROM auth.users WHERE email = 'user@example.com');
38
38
  ```
39
39
 
40
+ ### Deleting inactive users
41
+
42
+ Each user's `last_active_on` is set to the day they last signed in or resumed a session. Set
43
+ `NUXT_INACTIVE_USERS_RETENTION_DAYS` and the nightly `clean` task deletes the users inactive for
44
+ longer, with everything that cascades from them and their `u/{id}` file folders, private and
45
+ public. It is `0` by default, which keeps everyone. A user whose files can't be deleted is kept
46
+ and retried the next night. A deleted user stays in the backups until they age out.
47
+
48
+ `NUXT_INACTIVE_USERS_WARNING_DAYS` (30 by default) emails users that many days before, with
49
+ their deletion date. A failed warning is retried every night but never delays the deletion,
50
+ and signing in again cancels it. When the setting is first turned on, users already past the
51
+ limit are deleted that night without a warning.
52
+
40
53
  ## Deployment
41
54
 
42
55
  ### On your server
@@ -74,6 +74,10 @@ export default defineNuxtConfig({
74
74
  logs: {
75
75
  retentionDays: 90,
76
76
  },
77
+ inactiveUsers: {
78
+ retentionDays: 0, // delete users inactive for this many days, 0 keeps them forever
79
+ warningDays: 30, // email users this many days before deletion, 0 deletes without warning
80
+ },
77
81
  rateLimit: {
78
82
  enabled: true,
79
83
  banMultiplier: 5, // banned after limit × multiplier requests in one window
@@ -16,7 +16,10 @@ export default defineEventHandler(async (event) => {
16
16
  })
17
17
  }
18
18
 
19
- const insertedUsers = await db.insert(auth).values({}).returning()
19
+ const insertedUsers = await db
20
+ .insert(auth)
21
+ .values({ locale: useLocaleServerMiddleware(event).current })
22
+ .returning()
20
23
 
21
24
  if (!insertedUsers[0]) {
22
25
  throw createError({
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
26
26
  async function signUp(event: H3Event, email: string) {
27
27
  const session = await getUserSession(event)
28
28
  if (session?.user?.isAnonymous) return setAuth({ id: session.user.id, email })
29
- if (useRuntimeConfig().autoSignup) return createAuth({ email })
29
+ if (useRuntimeConfig().autoSignup) return createAuth({ email }, event)
30
30
  throw createError({ status: 404, message: 'User not found' })
31
31
  }
32
32
 
@@ -4,6 +4,7 @@ import {
4
4
  text,
5
5
  integer,
6
6
  timestamp,
7
+ date,
7
8
  uuid,
8
9
  boolean,
9
10
  primaryKey,
@@ -23,6 +24,9 @@ export const auth = authSchema.table('users', {
23
24
  createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
24
25
  totp: text(),
25
26
  role: appRoleEnum().notNull().default('user'),
27
+ lastActiveOn: date({ mode: 'date' }).notNull().defaultNow(),
28
+ deletionWarnedOn: date({ mode: 'date' }),
29
+ locale: text(),
26
30
  })
27
31
 
28
32
  export const refreshTokens = authSchema.table('refresh_tokens', {
@@ -10,5 +10,6 @@ export default defineEventHandler(async (event) => {
10
10
 
11
11
  const sessionData = await setSession(event, user, false)
12
12
  session.user = sessionData?.user // to update the session on SSR
13
+ await updateLastActive(user.id)
13
14
  } catch {}
14
15
  })
@@ -15,7 +15,7 @@ async function onSuccess(event: H3Event, { tokens }: { tokens: any }) {
15
15
  const config = useRuntimeConfig()
16
16
 
17
17
  if (error.statusCode === 404 && config.autoSignup) {
18
- return createAuth({ email: claims.email })
18
+ return createAuth({ email: claims.email }, event)
19
19
  }
20
20
  throw error
21
21
  })
@@ -1,7 +1,7 @@
1
1
  export default defineTask({
2
2
  meta: {
3
3
  name: 'clean',
4
- description: 'Clean old backups, logs and unfinished uploads',
4
+ description: 'Clean old backups, logs, unfinished uploads and inactive users',
5
5
  },
6
6
  async run() {
7
7
  const config = useRuntimeConfig()
@@ -9,6 +9,7 @@ export default defineTask({
9
9
  const steps: [string, () => Promise<unknown>][] = [['backups', cleanOldBackups]]
10
10
  if (config.s3.privateBucket) steps.push(['S3 backups', cleanOldS3Backups])
11
11
  steps.push(['logs', cleanLogs], ['uploads', () => cleanTempUploads()])
12
+ if (config.inactiveUsers.retentionDays > 0) steps.push(['inactive users', cleanInactiveUsers])
12
13
 
13
14
  const results = await Promise.allSettled(steps.map(([, step]) => step()))
14
15
 
@@ -1,5 +1,5 @@
1
1
  import { auth, refreshTokens } from '#server/database/schema'
2
- import { eq, lte } from 'drizzle-orm'
2
+ import { and, eq, inArray, isNotNull, isNull, lt, lte, or, sql } from 'drizzle-orm'
3
3
  import { createHash, randomBytes } from 'crypto'
4
4
  import type { H3Event } from 'h3'
5
5
 
@@ -45,11 +45,12 @@ export function requireUserVerification(userVerified: boolean) {
45
45
  }
46
46
  }
47
47
 
48
- export async function createAuth(user: Pick<AuthUser, 'email'>) {
48
+ export async function createAuth(user: Pick<AuthUser, 'email'>, event: H3Event) {
49
49
  const insertedUsers = await db
50
50
  .insert(auth)
51
51
  .values({
52
52
  email: user.email?.trim().toLowerCase(),
53
+ locale: useLocaleServerMiddleware(event).current,
53
54
  })
54
55
  .returning()
55
56
  if (!insertedUsers[0]) {
@@ -162,8 +163,99 @@ export function hashRefreshToken(token: string) {
162
163
  return createHash('sha256').update(token).digest('base64url')
163
164
  }
164
165
 
166
+ export async function updateLastActive(userId: string) {
167
+ await db
168
+ .update(auth)
169
+ .set({ lastActiveOn: sql`current_date` })
170
+ .where(and(eq(auth.id, userId), lt(auth.lastActiveOn, sql`current_date`)))
171
+ }
172
+
173
+ export async function cleanInactiveUsers() {
174
+ const { retentionDays, warningDays } = useRuntimeConfig().inactiveUsers
175
+ const inactive = lt(auth.lastActiveOn, sql`current_date - ${retentionDays}::int`)
176
+
177
+ const users = await db.select({ id: auth.id }).from(auth).where(inactive)
178
+ const ids: string[] = []
179
+ for (const { id } of users) {
180
+ await deleteUserFiles(id).then(
181
+ () => ids.push(id),
182
+ () => {},
183
+ )
184
+ }
185
+
186
+ if (ids.length) {
187
+ const deleted = await db
188
+ .delete(auth)
189
+ .where(and(inArray(auth.id, ids), inactive))
190
+ .returning({ id: auth.id })
191
+ await log(
192
+ `Deleted ${deleted.length} users inactive for ${retentionDays} days`,
193
+ undefined,
194
+ 'clean task',
195
+ )
196
+ }
197
+
198
+ const failedWarnings = warningDays > 0 ? await warnInactiveUsers(retentionDays, warningDays) : 0
199
+
200
+ const errors = []
201
+ if (users.length > ids.length) {
202
+ errors.push(`files of ${users.length - ids.length} users could not be deleted`)
203
+ }
204
+ if (failedWarnings) errors.push(`${failedWarnings} warnings failed to send`)
205
+ if (errors.length) throw new Error(`Inactive users: ${errors.join(', ')}`)
206
+ }
207
+
208
+ async function warnInactiveUsers(retentionDays: number, warningDays: number) {
209
+ const warnAfterDays = Math.max(retentionDays - warningDays, 0)
210
+
211
+ const users = await db
212
+ .select({
213
+ id: auth.id,
214
+ email: auth.email,
215
+ locale: auth.locale,
216
+ lastActiveOn: auth.lastActiveOn,
217
+ })
218
+ .from(auth)
219
+ .where(
220
+ and(
221
+ lt(auth.lastActiveOn, sql`current_date - ${warnAfterDays}::int`),
222
+ isNotNull(auth.email),
223
+ or(isNull(auth.deletionWarnedOn), lte(auth.deletionWarnedOn, auth.lastActiveOn)),
224
+ ),
225
+ )
226
+
227
+ let failed = 0
228
+ for (const user of users) {
229
+ // The first nightly run where lastActiveOn < current_date - retentionDays
230
+ const deletionOn = new Date(user.lastActiveOn.getTime() + daysToMilliseconds(retentionDays + 1))
231
+ const locale = user.locale ?? 'en'
232
+ const date = new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(
233
+ deletionOn,
234
+ )
235
+ try {
236
+ await sendEmailTemplate('inactiveDeletion', locale, { date }, user.email!)
237
+ await db
238
+ .update(auth)
239
+ .set({ deletionWarnedOn: sql`current_date` })
240
+ .where(eq(auth.id, user.id))
241
+ } catch {
242
+ failed++
243
+ }
244
+ }
245
+
246
+ if (users.length > failed) {
247
+ await log(
248
+ `Warned ${users.length - failed} inactive users of their deletion`,
249
+ undefined,
250
+ 'clean task',
251
+ )
252
+ }
253
+ return failed
254
+ }
255
+
165
256
  export async function createRefreshToken(userId: string, event: H3Event) {
166
257
  const token = generateRefreshToken()
258
+ await updateLastActive(userId)
167
259
 
168
260
  await db.insert(refreshTokens).values({
169
261
  userId,
@@ -386,6 +386,20 @@ export async function getFile(
386
386
 
387
387
  export async function deletePath(event: H3Event, path: string, isPrivate = true) {
388
388
  await checkFileAccess(event, path)
389
+ await removePath(path, isPrivate)
390
+ }
391
+
392
+ // The u/{id} folders of the default checkFileAccess, private and public
393
+ export async function deleteUserFiles(userId: string) {
394
+ const { s3 } = useRuntimeConfig()
395
+ for (const isPrivate of [true, false]) {
396
+ if (useS3() && !(isPrivate ? s3.privateBucket : s3.publicBucket)) continue
397
+ await removePath(`u/${userId}`, isPrivate)
398
+ }
399
+ }
400
+
401
+ // No access check, for server code acting on its own
402
+ async function removePath(path: string, isPrivate: boolean) {
389
403
  path = getSecurePath(path, isPrivate)
390
404
  // An empty path resolves to the root folder, deleting it would wipe every file
391
405
  if (path === getSecurePath('', isPrivate)) throw invalidPath()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxibase",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Nuxt layer with auth, files, logs and backups",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -0,0 +1,12 @@
1
+ <title>Your {{appName}} account will be deleted</title>
2
+ <p>
3
+ You haven't used {{appName}} for a long time, so your account and its data will be deleted on
4
+ {{date}}.
5
+ </p>
6
+ <p>To keep them, sign in before then.</p>
7
+ <a
8
+ href="{{url}}"
9
+ class="button"
10
+ >
11
+ Sign in
12
+ </a>
@@ -0,0 +1,12 @@
1
+ <title>Votre compte {{appName}} va être supprimé</title>
2
+ <p>
3
+ Vous n'avez pas utilisé {{appName}} depuis longtemps, votre compte et ses données seront donc
4
+ supprimés le {{date}}.
5
+ </p>
6
+ <p>Pour les conserver, connectez-vous d'ici là.</p>
7
+ <a
8
+ href="{{url}}"
9
+ class="button"
10
+ >
11
+ Se connecter
12
+ </a>