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 +13 -0
- package/layers/base/nuxt.config.ts +4 -0
- package/layers/base/server/api/auth/anonymous.post.ts +4 -1
- package/layers/base/server/api/auth/otp/verify.post.ts +1 -1
- package/layers/base/server/database/schema.ts +4 -0
- package/layers/base/server/middleware/1.session.ts +1 -0
- package/layers/base/server/routes/auth/microsoft.get.ts +1 -1
- package/layers/base/server/tasks/clean.ts +2 -1
- package/layers/base/server/utils/auth.ts +94 -2
- package/layers/base/server/utils/files.ts +14 -0
- package/package.json +1 -1
- package/server/assets/emails/en/inactiveDeletion.html +12 -0
- package/server/assets/emails/fr/inactiveDeletion.html +12 -0
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
|
|
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', {
|
|
@@ -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
|
|
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
|
@@ -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>
|