maxibase 1.0.1 → 1.1.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.
- package/README.md +20 -0
- package/docker/Dockerfile +3 -3
- package/layers/base/app/components/AuthCheck.vue +91 -52
- package/layers/base/app/components/files/FilesUpload.vue +9 -0
- package/layers/base/app/pages/user/auth.vue +3 -3
- package/layers/base/app/stores/app.ts +4 -9
- package/layers/base/nuxt.config.ts +4 -3
- package/layers/base/server/api/admin/logs.get.ts +1 -2
- package/layers/base/server/api/auth/otp/get.post.ts +0 -6
- package/layers/base/server/api/auth/otp/verify.post.ts +37 -39
- package/layers/base/server/api/auth/password.post.ts +2 -7
- package/layers/base/server/api/auth/refresh.post.ts +2 -2
- package/layers/base/server/api/auth/totp/confirm.post.ts +1 -7
- package/layers/base/server/api/auth/totp/verify.post.ts +15 -6
- package/layers/base/server/api/auth/webauthn/authenticate.post.ts +8 -2
- package/layers/base/server/api/files/[...path].get.ts +23 -14
- package/layers/base/server/api/files/chunk.post.ts +25 -4
- package/layers/base/server/api/files/index.get.ts +1 -3
- package/layers/base/server/api/files/index.post.ts +1 -1
- package/layers/base/server/middleware/0.rateLimit.ts +1 -1
- package/layers/base/server/routes/auth/microsoft.get.ts +30 -17
- package/layers/base/server/tasks/clean.ts +2 -1
- package/layers/base/server/utils/auth.ts +27 -5
- package/layers/base/server/utils/backup.ts +1 -1
- package/layers/base/server/utils/files.ts +129 -14
- package/layers/base/server/utils/rateLimit.ts +7 -2
- package/locales/en.json +3 -0
- package/locales/fr.json +3 -0
- package/locales/pages/user-auth/en.json +0 -1
- package/locales/pages/user-auth/fr.json +0 -1
- package/nuxt.config.ts +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,6 +10,21 @@ Setup your env file by editing the `.env.example` file and rename it to `.env`.
|
|
|
10
10
|
|
|
11
11
|
Use the route `http://localhost:3000/dev/email/[templateId]` to see the email while you edit. You can put the locale or any parameter in the query.
|
|
12
12
|
|
|
13
|
+
### Microsoft sign-in
|
|
14
|
+
|
|
15
|
+
In your Azure app registration, go to Token configuration → Add optional claim → ID and add
|
|
16
|
+
`email` and `xms_edov`. Without them every Microsoft sign-in is refused.
|
|
17
|
+
|
|
18
|
+
A Microsoft account signs in to the user with the same email, so the email is only trusted
|
|
19
|
+
when `xms_edov` says its domain is verified in the account's tenant. Any tenant can write any
|
|
20
|
+
address in a user's `mail`, so trusting it unverified lets them sign in as anyone.
|
|
21
|
+
|
|
22
|
+
The tenant defaults to `organizations`: any work or school account, no personal ones. Set
|
|
23
|
+
`NUXT_OAUTH_MICROSOFT_TENANT=common` to also accept personal accounts.
|
|
24
|
+
|
|
25
|
+
`/auth/microsoft` signs in with the Microsoft account already signed in, link to
|
|
26
|
+
`/auth/microsoft?prompt=select_account` to let the user pick another one.
|
|
27
|
+
|
|
13
28
|
## Deployment
|
|
14
29
|
|
|
15
30
|
### On your server
|
|
@@ -39,6 +54,9 @@ location /files/ {
|
|
|
39
54
|
# Optional: Hide S3 headers for security
|
|
40
55
|
proxy_hide_header x-amz-request-id;
|
|
41
56
|
proxy_hide_header x-amz-id-2;
|
|
57
|
+
|
|
58
|
+
add_header X-Content-Type-Options nosniff always;
|
|
59
|
+
add_header Content-Security-Policy sandbox always;
|
|
42
60
|
}
|
|
43
61
|
```
|
|
44
62
|
|
|
@@ -47,6 +65,8 @@ location /files/ {
|
|
|
47
65
|
```
|
|
48
66
|
location /files/ {
|
|
49
67
|
alias /home/debian/project/volumes/files/public/;
|
|
68
|
+
add_header X-Content-Type-Options nosniff always;
|
|
69
|
+
add_header Content-Security-Policy sandbox always;
|
|
50
70
|
}
|
|
51
71
|
```
|
|
52
72
|
|
package/docker/Dockerfile
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
FROM node:
|
|
1
|
+
FROM node:26-alpine AS build
|
|
2
2
|
|
|
3
|
-
RUN
|
|
3
|
+
RUN npm install -g corepack && corepack enable
|
|
4
4
|
|
|
5
5
|
WORKDIR /app
|
|
6
6
|
|
|
@@ -12,7 +12,7 @@ COPY . ./
|
|
|
12
12
|
|
|
13
13
|
RUN pnpm build
|
|
14
14
|
|
|
15
|
-
FROM node:
|
|
15
|
+
FROM node:26-alpine
|
|
16
16
|
WORKDIR /app
|
|
17
17
|
|
|
18
18
|
RUN apk add --no-cache postgresql-client age
|
|
@@ -112,15 +112,25 @@ function handleError(error: any) {
|
|
|
112
112
|
appStore.notify(error.data?.message || 'Bad credentials', 'error')
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
const signInForm = useTemplateRef<HTMLFormElement>('signInForm')
|
|
116
|
+
let signInTimer: ReturnType<typeof setTimeout> | undefined
|
|
117
|
+
|
|
118
|
+
watch(totp, (value) => {
|
|
119
|
+
const digits = value.replace(/\s/g, '')
|
|
120
|
+
if (digits !== value) totp.value = digits
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Paste, change and submit can fire together: wait for the pasted value, then sign in once
|
|
115
124
|
function signIn() {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
clearTimeout(signInTimer)
|
|
126
|
+
signInTimer = setTimeout(() => {
|
|
127
|
+
if (!email.value || isLoading.value) return
|
|
128
|
+
// paste and change bypass the browser's check, which only guards the submit
|
|
129
|
+
if (!signInForm.value?.reportValidity()) return
|
|
119
130
|
if (totp.value) signInWithTOTP()
|
|
120
|
-
else if (!password.value) requestOtp()
|
|
121
131
|
else if (password.value.length === 6) signInWithOtp()
|
|
122
132
|
else signInWithPassword()
|
|
123
|
-
}, 100)
|
|
133
|
+
}, 100)
|
|
124
134
|
}
|
|
125
135
|
|
|
126
136
|
const { authenticate } = useWebAuthn({
|
|
@@ -162,9 +172,11 @@ async function getSignInOptions(email: string) {
|
|
|
162
172
|
}
|
|
163
173
|
}
|
|
164
174
|
|
|
165
|
-
|
|
175
|
+
const pasteFields = { password, totp }
|
|
176
|
+
|
|
177
|
+
async function pasteTo(field: keyof typeof pasteFields) {
|
|
166
178
|
try {
|
|
167
|
-
|
|
179
|
+
pasteFields[field].value = (await navigator.clipboard.readText()).trim()
|
|
168
180
|
signIn()
|
|
169
181
|
} catch (e) {
|
|
170
182
|
appStore.notify($t('authCheck.clipboardPermission') as string, 'error')
|
|
@@ -188,6 +200,7 @@ onMounted(async () => {
|
|
|
188
200
|
<slot name="header"></slot>
|
|
189
201
|
<form
|
|
190
202
|
v-if="optionsFetched"
|
|
203
|
+
ref="signInForm"
|
|
191
204
|
class="flex-column g2"
|
|
192
205
|
@submit.prevent="signIn"
|
|
193
206
|
>
|
|
@@ -197,52 +210,69 @@ onMounted(async () => {
|
|
|
197
210
|
:value="email"
|
|
198
211
|
autocomplete="username email"
|
|
199
212
|
/>
|
|
200
|
-
<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
<
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
213
|
+
<div>
|
|
214
|
+
<label for="password">
|
|
215
|
+
{{
|
|
216
|
+
options.hasPassword && !otpRequested ? $t('authCheck.password') : $t('authCheck.code')
|
|
217
|
+
}}
|
|
218
|
+
</label>
|
|
219
|
+
<div class="group flex-row">
|
|
220
|
+
<input
|
|
221
|
+
id="password"
|
|
222
|
+
v-model.trim="password"
|
|
223
|
+
:type="showPassword ? 'text' : 'password'"
|
|
224
|
+
:disabled="isLoading"
|
|
225
|
+
autocomplete="current-password"
|
|
226
|
+
:pattern="
|
|
227
|
+
otpRequested
|
|
228
|
+
? '[0-9]{6}'
|
|
229
|
+
: `[0-9]{6}|.{${config.public.password.min},${config.public.password.max}}`
|
|
230
|
+
"
|
|
231
|
+
required
|
|
232
|
+
@paste="signIn"
|
|
233
|
+
/>
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
class="flex-center fg"
|
|
237
|
+
@click="showPassword = !showPassword"
|
|
238
|
+
>
|
|
239
|
+
<Icon :name="showPassword ? 'lucide:eye-off' : 'lucide:eye'" />
|
|
240
|
+
</button>
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
class="flex-center fg"
|
|
244
|
+
:title="$t('paste') as string"
|
|
245
|
+
@click="pasteTo('password')"
|
|
246
|
+
>
|
|
247
|
+
<Icon name="lucide:clipboard-paste" />
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
234
250
|
</div>
|
|
235
251
|
<div v-if="options.hasTOTP">
|
|
236
252
|
<label for="totp">{{ $t('authCheck.authenticatorCode') }}</label>
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
253
|
+
<div class="group flex-row">
|
|
254
|
+
<input
|
|
255
|
+
id="totp"
|
|
256
|
+
v-model.trim="totp"
|
|
257
|
+
type="text"
|
|
258
|
+
:disabled="isLoading || !password"
|
|
259
|
+
autocomplete="one-time-code"
|
|
260
|
+
inputmode="numeric"
|
|
261
|
+
pattern="[0-9]{6}"
|
|
262
|
+
required
|
|
263
|
+
@paste="signIn"
|
|
264
|
+
@change="signIn"
|
|
265
|
+
/>
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
class="flex-center fg"
|
|
269
|
+
:title="$t('paste') as string"
|
|
270
|
+
:disabled="isLoading || !password"
|
|
271
|
+
@click="pasteTo('totp')"
|
|
272
|
+
>
|
|
273
|
+
<Icon name="lucide:clipboard-paste" />
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
246
276
|
</div>
|
|
247
277
|
<button
|
|
248
278
|
type="submit"
|
|
@@ -251,6 +281,15 @@ onMounted(async () => {
|
|
|
251
281
|
>
|
|
252
282
|
{{ $t('authCheck.signin') }}
|
|
253
283
|
</button>
|
|
284
|
+
<button
|
|
285
|
+
v-if="options.hasPasskey"
|
|
286
|
+
type="button"
|
|
287
|
+
:disabled="isLoading"
|
|
288
|
+
class="flex-center g1 w mt1 bg bg-border"
|
|
289
|
+
@click="signInWithPasskey"
|
|
290
|
+
>
|
|
291
|
+
{{ $t('authCheck.passkey') }}
|
|
292
|
+
</button>
|
|
254
293
|
<button
|
|
255
294
|
v-if="!otpRequested"
|
|
256
295
|
type="button"
|
|
@@ -310,7 +349,7 @@ onMounted(async () => {
|
|
|
310
349
|
|
|
311
350
|
<style scoped>
|
|
312
351
|
.signin {
|
|
313
|
-
|
|
314
|
-
|
|
352
|
+
box-sizing: border-box;
|
|
353
|
+
width: min(400px, 100vw - (var(--g-padding-l) + var(--g-padding-r)) * var(--g-padding-ratio2));
|
|
315
354
|
}
|
|
316
355
|
</style>
|
|
@@ -47,6 +47,15 @@ async function uploadFile(event: Event) {
|
|
|
47
47
|
appStore.notify('formatFailed', 'error', false, { files: failed.join(', ') })
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
const maxSize = Number(useRuntimeConfig().public.files.maxSize)
|
|
51
|
+
const tooLarge = files.filter((file) => file.size > maxSize * 1024 * 1024)
|
|
52
|
+
if (tooLarge.length) {
|
|
53
|
+
appStore.notify('fileTooLarge', 'error', false, {
|
|
54
|
+
files: tooLarge.map((file) => file.name).join(', '),
|
|
55
|
+
max: maxSize,
|
|
56
|
+
})
|
|
57
|
+
files = files.filter((file) => !tooLarge.includes(file))
|
|
58
|
+
}
|
|
50
59
|
if (files.length) {
|
|
51
60
|
let response = await uploadFiles(files, props.path, props.isPrivate, (p) => {
|
|
52
61
|
progress.value = p
|
|
@@ -38,7 +38,7 @@ const { $getLocale, $t } = useI18n()
|
|
|
38
38
|
const isMfaSetup = computed(() => user.value?.requiresMfaSetup === true)
|
|
39
39
|
|
|
40
40
|
async function checkAuth() {
|
|
41
|
-
return
|
|
41
|
+
return appStore.checkAuth()
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
const isCurrentEmail = computed(() => {
|
|
@@ -424,14 +424,14 @@ onMounted(getAuth)
|
|
|
424
424
|
/>
|
|
425
425
|
<button
|
|
426
426
|
type="button"
|
|
427
|
-
class="bg danger-text flex"
|
|
427
|
+
class="bg danger-text flex-center"
|
|
428
428
|
:disabled="!canRemovePasskey"
|
|
429
429
|
:title="canRemovePasskey ? '' : $t('lastFactor')"
|
|
430
430
|
@click="deletePasskey(credential.id)"
|
|
431
431
|
>
|
|
432
432
|
<Icon name="lucide:trash" />
|
|
433
433
|
</button>
|
|
434
|
-
<button class="flex fg"><Icon name="lucide:save" /></button>
|
|
434
|
+
<button class="flex-center fg"><Icon name="lucide:save" /></button>
|
|
435
435
|
</div>
|
|
436
436
|
</form>
|
|
437
437
|
<button
|
|
@@ -15,7 +15,7 @@ export const useAppStore = defineStore('app', () => {
|
|
|
15
15
|
reject: (value?: any) => any
|
|
16
16
|
} | null>(null)
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const { session } = useUserSession()
|
|
19
19
|
const authPromise = ref()
|
|
20
20
|
|
|
21
21
|
function confirm(message?: string) {
|
|
@@ -32,19 +32,14 @@ export const useAppStore = defineStore('app', () => {
|
|
|
32
32
|
|
|
33
33
|
async function checkAuth(): Promise<boolean> {
|
|
34
34
|
const reverifyAfter = useRuntimeConfig().public.recentAuth.maxAge * 1000
|
|
35
|
-
if (Date.now() -
|
|
35
|
+
if (Date.now() - (session.value?.authenticatedAt ?? 0) < reverifyAfter) {
|
|
36
36
|
return true
|
|
37
37
|
}
|
|
38
38
|
return new Promise<boolean>((resolve) => {
|
|
39
39
|
authPromise.value = { resolve }
|
|
40
|
+
}).finally(() => {
|
|
41
|
+
authPromise.value = null
|
|
40
42
|
})
|
|
41
|
-
.then((verified) => {
|
|
42
|
-
if (verified) authVerifiedAt.value = Date.now()
|
|
43
|
-
return verified
|
|
44
|
-
})
|
|
45
|
-
.finally(() => {
|
|
46
|
-
authPromise.value = null
|
|
47
|
-
})
|
|
48
43
|
}
|
|
49
44
|
|
|
50
45
|
function setLoading(state: boolean) {
|
|
@@ -27,6 +27,7 @@ export default defineNuxtConfig({
|
|
|
27
27
|
files: {
|
|
28
28
|
url: '/files',
|
|
29
29
|
chunkSize: 5, // in MB
|
|
30
|
+
maxSize: 100, // in MB
|
|
30
31
|
},
|
|
31
32
|
},
|
|
32
33
|
session: {
|
|
@@ -38,7 +39,7 @@ export default defineNuxtConfig({
|
|
|
38
39
|
microsoft: {
|
|
39
40
|
clientId: '',
|
|
40
41
|
clientSecret: '',
|
|
41
|
-
tenant: '
|
|
42
|
+
tenant: 'organizations',
|
|
42
43
|
},
|
|
43
44
|
},
|
|
44
45
|
refreshToken: {
|
|
@@ -66,7 +67,7 @@ export default defineNuxtConfig({
|
|
|
66
67
|
secretAccessKey: '',
|
|
67
68
|
},
|
|
68
69
|
backup: {
|
|
69
|
-
dumpArgs: '-F c',
|
|
70
|
+
dumpArgs: '-F c --exclude-table-data=auth.refresh_tokens',
|
|
70
71
|
retentionDays: 30,
|
|
71
72
|
agePublicKey: '', // age public key to encrypt backups
|
|
72
73
|
},
|
|
@@ -88,7 +89,7 @@ export default defineNuxtConfig({
|
|
|
88
89
|
},
|
|
89
90
|
scheduledTasks: {
|
|
90
91
|
'0 3 * * *': 'backup',
|
|
91
|
-
'0 2
|
|
92
|
+
'0 2 * * *': 'clean',
|
|
92
93
|
},
|
|
93
94
|
storage: {
|
|
94
95
|
auth: {
|
|
@@ -9,8 +9,7 @@ const querySchema = z.object({
|
|
|
9
9
|
})
|
|
10
10
|
|
|
11
11
|
export default defineEventHandler(async (event) => {
|
|
12
|
-
const
|
|
13
|
-
const { from, to, search, type, limit } = querySchema.parse(query)
|
|
12
|
+
const { from, to, search, type, limit } = await getValidatedQuery(event, querySchema.parse)
|
|
14
13
|
const text = prepareStringForSearch(search)
|
|
15
14
|
|
|
16
15
|
const rows = await db.query.logs.findMany({
|
|
@@ -22,12 +22,6 @@ export default defineEventHandler(async (event) => {
|
|
|
22
22
|
goto,
|
|
23
23
|
path = 'signin',
|
|
24
24
|
} = await readValidatedBody(event, bodySchema.parse)
|
|
25
|
-
if (!email || typeof email !== 'string') {
|
|
26
|
-
throw createError({
|
|
27
|
-
status: 400,
|
|
28
|
-
message: 'Email is required',
|
|
29
|
-
})
|
|
30
|
-
}
|
|
31
25
|
|
|
32
26
|
const storage = useStorage('auth')
|
|
33
27
|
const record = await storage.getItem<OTP>(email)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import type { H3Event } from 'h3'
|
|
2
3
|
import type { OTP } from './get.post'
|
|
3
4
|
|
|
4
5
|
const bodySchema = z.object({
|
|
@@ -8,47 +9,44 @@ const bodySchema = z.object({
|
|
|
8
9
|
|
|
9
10
|
export default defineEventHandler(async (event) => {
|
|
10
11
|
const { email, otp } = await readValidatedBody(event, bodySchema.parse)
|
|
11
|
-
if (!email || !otp) {
|
|
12
|
-
throw createError({
|
|
13
|
-
status: 400,
|
|
14
|
-
message: 'Email and OTP are required',
|
|
15
|
-
})
|
|
16
|
-
}
|
|
17
|
-
await verifyOTP(email, otp)
|
|
18
12
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
id: session.user.id,
|
|
25
|
-
email,
|
|
26
|
-
}
|
|
27
|
-
await setAuth(user)
|
|
28
|
-
}
|
|
29
|
-
return await setSession(event, user)
|
|
30
|
-
}
|
|
13
|
+
const existing = await getAuth(event, email, true).catch((error) => {
|
|
14
|
+
if (error.statusCode !== 404) throw error
|
|
15
|
+
return null
|
|
16
|
+
})
|
|
17
|
+
if (existing) requireSecondFactor(existing)
|
|
31
18
|
|
|
32
|
-
|
|
33
|
-
const config = useRuntimeConfig()
|
|
19
|
+
await verifyOTP(email, otp)
|
|
34
20
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
throw error
|
|
39
|
-
})
|
|
40
|
-
if (user.totp) {
|
|
41
|
-
throw createError({
|
|
42
|
-
status: 401,
|
|
43
|
-
message: 'TOTP required',
|
|
44
|
-
})
|
|
45
|
-
}
|
|
46
|
-
await setSession(event, user)
|
|
21
|
+
const user = existing ?? (await signUp(event, email))
|
|
22
|
+
requireSecondFactor(user) // an anonymous account can already have a TOTP
|
|
23
|
+
return setSession(event, user)
|
|
47
24
|
})
|
|
48
25
|
|
|
26
|
+
async function signUp(event: H3Event, email: string) {
|
|
27
|
+
const session = await getUserSession(event)
|
|
28
|
+
if (session?.user?.isAnonymous) return setAuth({ id: session.user.id, email })
|
|
29
|
+
if (useRuntimeConfig().autoSignup) return createAuth({ email })
|
|
30
|
+
throw createError({ status: 404, message: 'User not found' })
|
|
31
|
+
}
|
|
32
|
+
|
|
49
33
|
const MAX_OTP_ATTEMPTS = 3
|
|
50
34
|
|
|
51
|
-
|
|
35
|
+
const verifying = new Set<string>()
|
|
36
|
+
|
|
37
|
+
export async function verifyOTP(email: string, otp: string, consume = true): Promise<void> {
|
|
38
|
+
if (verifying.has(email)) {
|
|
39
|
+
throw createError({ status: 429, message: 'Verification already in progress.' })
|
|
40
|
+
}
|
|
41
|
+
verifying.add(email)
|
|
42
|
+
try {
|
|
43
|
+
await checkOTP(email, otp, consume)
|
|
44
|
+
} finally {
|
|
45
|
+
verifying.delete(email)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function checkOTP(email: string, otp: string, consume: boolean) {
|
|
52
50
|
const storage = useStorage('auth')
|
|
53
51
|
const record = await storage.getItem<OTP>(email)
|
|
54
52
|
if (!record) {
|
|
@@ -59,12 +57,12 @@ export async function verifyOTP(email: string, otp: string): Promise<void> {
|
|
|
59
57
|
throw createError({ status: 400, message: 'Request a new OTP.' })
|
|
60
58
|
}
|
|
61
59
|
|
|
60
|
+
record.attempts++
|
|
61
|
+
await storage.setItem(email, record)
|
|
62
|
+
|
|
62
63
|
const hash = otp.length > 6 ? record.token : record.otp
|
|
63
|
-
if (hash
|
|
64
|
-
await storage.removeItem(email)
|
|
65
|
-
} else {
|
|
66
|
-
record.attempts++
|
|
67
|
-
await storage.setItem(email, record)
|
|
64
|
+
if (!hash || !(await verifyPassword(hash, otp))) {
|
|
68
65
|
throw createError({ status: 400, message: 'Invalid OTP.' })
|
|
69
66
|
}
|
|
67
|
+
if (consume) await storage.removeItem(email)
|
|
70
68
|
}
|
|
@@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
|
|
16
16
|
}).parse,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
-
const user = await getAuth(event, email).catch((error: any) => {
|
|
19
|
+
const user = await getAuth(event, email, true).catch((error: any) => {
|
|
20
20
|
if (error?.statusCode === 404) return null
|
|
21
21
|
throw error
|
|
22
22
|
})
|
|
@@ -31,12 +31,7 @@ export default defineEventHandler(async (event) => {
|
|
|
31
31
|
throw badCredentials()
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
throw createError({
|
|
36
|
-
status: 401,
|
|
37
|
-
message: 'TOTP required',
|
|
38
|
-
})
|
|
39
|
-
}
|
|
34
|
+
requireSecondFactor(user)
|
|
40
35
|
|
|
41
36
|
return setSession(event, user)
|
|
42
37
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export default defineEventHandler(async (event) => {
|
|
2
|
-
const { user
|
|
3
|
-
await revokeRefreshToken(
|
|
2
|
+
const { user } = await verifyRefreshToken(event)
|
|
3
|
+
await revokeRefreshToken(getCookie(event, 'refresh_token')!) // verified above, so it is set
|
|
4
4
|
await createRefreshToken(user.id, event)
|
|
5
5
|
})
|
|
@@ -10,16 +10,10 @@ const bodySchema = z.object({
|
|
|
10
10
|
|
|
11
11
|
export default defineEventHandler(async (event) => {
|
|
12
12
|
const { secret, token } = await readValidatedBody(event, bodySchema.parse)
|
|
13
|
-
if (!secret || !token) {
|
|
14
|
-
throw createError({
|
|
15
|
-
status: 400,
|
|
16
|
-
message: 'Missing required fields',
|
|
17
|
-
})
|
|
18
|
-
}
|
|
19
13
|
|
|
20
14
|
const { user } = await requireRecentAuth(event)
|
|
21
15
|
|
|
22
|
-
if (await verify({ secret, token })) {
|
|
16
|
+
if ((await verify({ secret, token })).valid) {
|
|
23
17
|
await db.update(auth).set({ totp: secret }).where(eq(auth.id, user.id))
|
|
24
18
|
await setSession(event, user, false) // refresh requiresMfaSetup
|
|
25
19
|
} else throw createError({ status: 400, message: 'Invalid code.' })
|
|
@@ -12,25 +12,34 @@ export default defineEventHandler(async (event) => {
|
|
|
12
12
|
token: z.string().length(6),
|
|
13
13
|
}).parse,
|
|
14
14
|
)
|
|
15
|
-
if (!
|
|
15
|
+
if (!otp && !password) {
|
|
16
16
|
throw createError({
|
|
17
17
|
status: 400,
|
|
18
18
|
message: 'Missing required fields',
|
|
19
19
|
})
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const user = await getAuth(event, email)
|
|
22
|
+
const user = await getAuth(event, email).catch((error: any) => {
|
|
23
|
+
if (error?.statusCode === 404) return null
|
|
24
|
+
throw error
|
|
25
|
+
})
|
|
23
26
|
|
|
24
|
-
if (otp) await verifyOTP(email, otp)
|
|
25
|
-
else if (!user
|
|
27
|
+
if (otp) await verifyOTP(email, otp, false)
|
|
28
|
+
else if (!user?.password) {
|
|
29
|
+
await hashPassword(password!)
|
|
30
|
+
throw createError({ status: 400, message: 'Invalid password.' })
|
|
31
|
+
} else if (!(await verifyPassword(user.password, password!))) {
|
|
26
32
|
throw createError({ status: 400, message: 'Invalid password.' })
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
if (!user
|
|
35
|
+
if (!user?.totp) {
|
|
30
36
|
throw createError({ status: 400, message: 'TOTP is not enabled.' })
|
|
31
37
|
}
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
await enforceRateLimit(event, 'totp', { limit: 5, window: 900 }, user.id)
|
|
40
|
+
|
|
41
|
+
if ((await verify({ secret: user.totp, token })).valid) {
|
|
42
|
+
if (otp) await useStorage('auth').removeItem(email)
|
|
34
43
|
await setSession(event, user)
|
|
35
44
|
} else throw createError({ status: 400, message: 'Invalid TOTP.' })
|
|
36
45
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { eq } from 'drizzle-orm'
|
|
2
|
-
import { auth } from '#server/database/schema'
|
|
2
|
+
import { auth, credentials } from '#server/database/schema'
|
|
3
3
|
|
|
4
4
|
export default defineWebAuthnAuthenticateEventHandler({
|
|
5
5
|
async storeChallenge(event, challenge, attemptId) {
|
|
@@ -28,7 +28,13 @@ export default defineWebAuthnAuthenticateEventHandler({
|
|
|
28
28
|
if (!credential) throw createError({ status: 400, message: 'Credential not found' })
|
|
29
29
|
return credential as any
|
|
30
30
|
},
|
|
31
|
-
async onSuccess(event, { credential }) {
|
|
31
|
+
async onSuccess(event, { credential, authenticationInfo }) {
|
|
32
|
+
// the next assertion must report a higher counter, which exposes a cloned authenticator
|
|
33
|
+
await db
|
|
34
|
+
.update(credentials)
|
|
35
|
+
.set({ counter: authenticationInfo.newCounter })
|
|
36
|
+
.where(eq(credentials.id, credential.id))
|
|
37
|
+
|
|
32
38
|
const user = await db
|
|
33
39
|
.select()
|
|
34
40
|
.from(auth)
|
|
@@ -5,19 +5,26 @@ const querySchema = z.object({
|
|
|
5
5
|
.enum(['true', 'false'])
|
|
6
6
|
.transform((v) => v === 'true')
|
|
7
7
|
.optional(),
|
|
8
|
-
maxAge: z.coerce.number().optional(),
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
maxAge: z.coerce.number().int().min(0).optional(),
|
|
9
|
+
// only-if-cached is left out, fetch throws on it outside same-origin mode
|
|
10
|
+
serverCache: z.enum(['default', 'no-store', 'reload', 'no-cache', 'force-cache']).optional(),
|
|
11
|
+
// S3 refuses to presign a URL for longer than 7 days
|
|
12
|
+
expireIn: z.coerce.number().int().positive().max(604800).optional(),
|
|
11
13
|
redirect: z
|
|
12
14
|
.enum(['true', 'false'])
|
|
13
15
|
.transform((v) => v === 'true')
|
|
14
16
|
.optional(),
|
|
15
17
|
})
|
|
16
18
|
|
|
17
|
-
export default defineEventHandler((event) => {
|
|
19
|
+
export default defineEventHandler(async (event) => {
|
|
18
20
|
const path = event.context.params?.path || ''
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
+
const {
|
|
22
|
+
isPrivate = false,
|
|
23
|
+
maxAge,
|
|
24
|
+
serverCache,
|
|
25
|
+
expireIn,
|
|
26
|
+
redirect,
|
|
27
|
+
} = await getValidatedQuery(event, querySchema.parse)
|
|
21
28
|
|
|
22
29
|
let cache = `public, max-age=${maxAge || 31536000}`
|
|
23
30
|
if (maxAge === 0) {
|
|
@@ -26,12 +33,14 @@ export default defineEventHandler((event) => {
|
|
|
26
33
|
cache = `private, max-age=${maxAge || 3600}`
|
|
27
34
|
}
|
|
28
35
|
setHeader(event, 'Cache-Control', cache)
|
|
29
|
-
return getFile(
|
|
30
|
-
event,
|
|
31
|
-
decodeURIComponent(path),
|
|
32
|
-
isPrivate,
|
|
33
|
-
serverCache as RequestCache,
|
|
34
|
-
expireIn,
|
|
35
|
-
redirect,
|
|
36
|
-
)
|
|
36
|
+
return getFile(event, decodePath(path), isPrivate, serverCache, expireIn, redirect)
|
|
37
37
|
})
|
|
38
|
+
|
|
39
|
+
// h3 decodes the path but keeps %25 encoded, so a % in a name needs this second pass
|
|
40
|
+
function decodePath(path: string) {
|
|
41
|
+
try {
|
|
42
|
+
return decodeURIComponent(path)
|
|
43
|
+
} catch {
|
|
44
|
+
throw createError({ status: 400, message: 'Invalid path' }) // a % not followed by hex
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -1,5 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
const partsSchema = z.array(
|
|
4
|
+
z.object({
|
|
5
|
+
ETag: z.string().optional(), // absent on local uploads
|
|
6
|
+
PartNumber: z.number().int().positive(),
|
|
7
|
+
}),
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
function invalidChunkUpload() {
|
|
11
|
+
return createError({ status: 400, message: 'Invalid chunk upload' })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseParts(value?: string) {
|
|
15
|
+
try {
|
|
16
|
+
return partsSchema.parse(JSON.parse(value || '[]'))
|
|
17
|
+
} catch {
|
|
18
|
+
throw invalidChunkUpload()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
1
22
|
export default defineEventHandler(async (event) => {
|
|
2
|
-
const body = await
|
|
23
|
+
const body = await readUploadBody(event, Number(useRuntimeConfig().public.files.chunkSize) || 1)
|
|
3
24
|
if (!body) throw createError({ status: 400, message: 'No data' })
|
|
4
25
|
|
|
5
26
|
const get = (name: string) =>
|
|
@@ -15,11 +36,11 @@ export default defineEventHandler(async (event) => {
|
|
|
15
36
|
const type = get('type') || 'application/octet-stream'
|
|
16
37
|
const path = get('path') || 'files'
|
|
17
38
|
const isPrivate = get('isPrivate') !== 'false'
|
|
18
|
-
const parts =
|
|
39
|
+
const parts = parseParts(get('parts'))
|
|
19
40
|
const chunkPart = body.find((p) => p.name === 'chunk')
|
|
20
41
|
|
|
21
|
-
if (
|
|
22
|
-
throw
|
|
42
|
+
if (!Number.isInteger(chunkIndex) || !Number.isInteger(totalChunks) || !filename || !chunkPart) {
|
|
43
|
+
throw invalidChunkUpload()
|
|
23
44
|
}
|
|
24
45
|
|
|
25
46
|
return uploadChunk(event, {
|
|
@@ -12,8 +12,6 @@ const querySchema = z.object({
|
|
|
12
12
|
})
|
|
13
13
|
|
|
14
14
|
export default defineEventHandler(async (event) => {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
const { path, isPrivate = false } = querySchema.parse(query)
|
|
15
|
+
const { path, isPrivate = false } = await getValidatedQuery(event, querySchema.parse)
|
|
18
16
|
return listFolder(event, path, isPrivate)
|
|
19
17
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export default defineEventHandler(async (event) => {
|
|
2
|
-
const body = await
|
|
2
|
+
const body = await readUploadBody(event, Number(useRuntimeConfig().public.files.maxSize))
|
|
3
3
|
if (!body) {
|
|
4
4
|
throw createError({ status: 400, message: 'No file uploaded' })
|
|
5
5
|
}
|
|
@@ -4,7 +4,7 @@ export default defineEventHandler(async (event) => {
|
|
|
4
4
|
const config = useRuntimeConfig()
|
|
5
5
|
if (!config.rateLimit.enabled) return
|
|
6
6
|
|
|
7
|
-
const path =
|
|
7
|
+
const path = event.path.split('?')[0]!
|
|
8
8
|
const routes = config.rateLimit.routes as Record<string, RateLimitRule>
|
|
9
9
|
|
|
10
10
|
let matchedRoute = ''
|
|
@@ -1,21 +1,34 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
9
2
|
|
|
10
|
-
|
|
11
|
-
const config = useRuntimeConfig()
|
|
3
|
+
const scope = ['openid', 'email', 'User.Read']
|
|
12
4
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
5
|
+
async function onSuccess(event: H3Event, { tokens }: { tokens: any }) {
|
|
6
|
+
const claims = JSON.parse(Buffer.from(tokens.id_token.split('.')[1], 'base64url').toString())
|
|
7
|
+
if (!claims.email || ![true, '1'].includes(claims.xms_edov)) {
|
|
8
|
+
throw createError({
|
|
9
|
+
status: 401,
|
|
10
|
+
message: 'This Microsoft account has no verified email address',
|
|
17
11
|
})
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const auth = await getAuth(event, claims.email).catch((error) => {
|
|
15
|
+
const config = useRuntimeConfig()
|
|
16
|
+
|
|
17
|
+
if (error.statusCode === 404 && config.autoSignup) {
|
|
18
|
+
return createAuth({ email: claims.email })
|
|
19
|
+
}
|
|
20
|
+
throw error
|
|
21
|
+
})
|
|
22
|
+
await setSession(event, auth)
|
|
23
|
+
return sendRedirect(event, '/')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const signIn = defineOAuthMicrosoftEventHandler({ config: { scope }, onSuccess })
|
|
27
|
+
const selectAccount = defineOAuthMicrosoftEventHandler({
|
|
28
|
+
config: { scope, authorizationParams: { prompt: 'select_account' } },
|
|
29
|
+
onSuccess,
|
|
21
30
|
})
|
|
31
|
+
|
|
32
|
+
export default defineEventHandler((event) =>
|
|
33
|
+
getQuery(event).prompt === 'select_account' ? selectAccount(event) : signIn(event),
|
|
34
|
+
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default defineTask({
|
|
2
2
|
meta: {
|
|
3
3
|
name: 'clean',
|
|
4
|
-
description: 'Clean old backups and
|
|
4
|
+
description: 'Clean old backups, logs and unfinished uploads',
|
|
5
5
|
},
|
|
6
6
|
async run() {
|
|
7
7
|
const config = useRuntimeConfig()
|
|
@@ -21,6 +21,7 @@ export default defineTask({
|
|
|
21
21
|
)
|
|
22
22
|
}
|
|
23
23
|
await cleanLogs()
|
|
24
|
+
await cleanTempUploads()
|
|
24
25
|
|
|
25
26
|
return {
|
|
26
27
|
result: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { auth, refreshTokens } from '#server/database/schema'
|
|
2
2
|
import { eq, lte } from 'drizzle-orm'
|
|
3
|
-
import { randomBytes } from 'crypto'
|
|
3
|
+
import { createHash, randomBytes } from 'crypto'
|
|
4
4
|
import type { H3Event } from 'h3'
|
|
5
5
|
|
|
6
6
|
type AuthUser = typeof auth.$inferSelect
|
|
@@ -17,6 +17,21 @@ export async function mfaSetupFlag(user: SessionUser) {
|
|
|
17
17
|
return { requiresMfaSetup: !row?.totp && !row?.credentials.length }
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function requireSecondFactor(user: Pick<AuthUser, 'totp'> & { credentials?: unknown[] }) {
|
|
21
|
+
if (user.totp) {
|
|
22
|
+
throw createError({
|
|
23
|
+
status: 401,
|
|
24
|
+
message: 'TOTP required',
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
if (useRuntimeConfig().forceMfa && user.credentials?.length) {
|
|
28
|
+
throw createError({
|
|
29
|
+
status: 401,
|
|
30
|
+
message: 'Passkey required',
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
export async function createAuth(user: Pick<AuthUser, 'email'>) {
|
|
21
36
|
const insertedUsers = await db
|
|
22
37
|
.insert(auth)
|
|
@@ -112,7 +127,7 @@ export async function setSession(event: H3Event, user: SessionUser, refresh = tr
|
|
|
112
127
|
export async function requireRecentAuth(event: H3Event) {
|
|
113
128
|
const session = await requireUserSession(event)
|
|
114
129
|
|
|
115
|
-
if (session.user.isAnonymous
|
|
130
|
+
if (session.user.isAnonymous) return session
|
|
116
131
|
|
|
117
132
|
const maxAge = useRuntimeConfig().public.recentAuth.maxAge * 1000
|
|
118
133
|
if (!session.authenticatedAt || Date.now() - session.authenticatedAt > maxAge) {
|
|
@@ -130,12 +145,16 @@ export function generateRefreshToken(): string {
|
|
|
130
145
|
return randomBytes(32).toString('base64url')
|
|
131
146
|
}
|
|
132
147
|
|
|
148
|
+
export function hashRefreshToken(token: string) {
|
|
149
|
+
return createHash('sha256').update(token).digest('base64url')
|
|
150
|
+
}
|
|
151
|
+
|
|
133
152
|
export async function createRefreshToken(userId: string, event: H3Event) {
|
|
134
153
|
const token = generateRefreshToken()
|
|
135
154
|
|
|
136
155
|
await db.insert(refreshTokens).values({
|
|
137
156
|
userId,
|
|
138
|
-
token,
|
|
157
|
+
token: hashRefreshToken(token),
|
|
139
158
|
})
|
|
140
159
|
|
|
141
160
|
const config = useRuntimeConfig()
|
|
@@ -158,7 +177,9 @@ export async function verifyRefreshToken(event: H3Event) {
|
|
|
158
177
|
})
|
|
159
178
|
}
|
|
160
179
|
|
|
161
|
-
const refreshToken = await db.query.refreshTokens.findFirst({
|
|
180
|
+
const refreshToken = await db.query.refreshTokens.findFirst({
|
|
181
|
+
where: { token: hashRefreshToken(existingToken) },
|
|
182
|
+
})
|
|
162
183
|
if (!refreshToken) {
|
|
163
184
|
deleteCookie(event, 'refresh_token')
|
|
164
185
|
throw createError({
|
|
@@ -194,8 +215,9 @@ export async function verifyRefreshToken(event: H3Event) {
|
|
|
194
215
|
return { refreshToken, user }
|
|
195
216
|
}
|
|
196
217
|
|
|
218
|
+
// Takes the token from the cookie, never the stored value
|
|
197
219
|
export async function revokeRefreshToken(token: string) {
|
|
198
|
-
await db.delete(refreshTokens).where(eq(refreshTokens.token, token))
|
|
220
|
+
await db.delete(refreshTokens).where(eq(refreshTokens.token, hashRefreshToken(token)))
|
|
199
221
|
}
|
|
200
222
|
|
|
201
223
|
export async function revokeExpiredRefreshToken(creationExpiredAt: Date) {
|
|
@@ -37,7 +37,7 @@ function waitForExit(child: ChildProcess, name: string) {
|
|
|
37
37
|
export async function createDatabaseBackup(
|
|
38
38
|
backupName: string,
|
|
39
39
|
dbUrl: string,
|
|
40
|
-
dumpArgs: string
|
|
40
|
+
dumpArgs: string,
|
|
41
41
|
agePublicKey?: string,
|
|
42
42
|
) {
|
|
43
43
|
const { conn, env } = splitCredentials(dbUrl)
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
appendFile,
|
|
9
9
|
rename,
|
|
10
10
|
readFile as fsReadFile,
|
|
11
|
+
copyFile as fsCopyFile,
|
|
11
12
|
} from 'fs/promises'
|
|
12
13
|
import {
|
|
13
14
|
S3Client,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
UploadPartCommand,
|
|
22
23
|
CompleteMultipartUploadCommand,
|
|
23
24
|
AbortMultipartUploadCommand,
|
|
25
|
+
ListMultipartUploadsCommand,
|
|
24
26
|
} from '@aws-sdk/client-s3'
|
|
25
27
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
26
28
|
import type { CompletedPart } from '@aws-sdk/client-s3'
|
|
@@ -31,6 +33,21 @@ import type { Readable } from 'stream'
|
|
|
31
33
|
import type { H3Event, MultiPartData } from 'h3'
|
|
32
34
|
import { createHash } from 'crypto'
|
|
33
35
|
|
|
36
|
+
const MB = 1024 * 1024
|
|
37
|
+
|
|
38
|
+
function fileTooLarge() {
|
|
39
|
+
const { maxSize } = useRuntimeConfig().public.files
|
|
40
|
+
return createError({ statusCode: 413, message: `File too large, max ${maxSize} MB` })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readUploadBody(event: H3Event, maxMB: number) {
|
|
44
|
+
await requireUserSession(event)
|
|
45
|
+
const length = getRequestHeader(event, 'content-length')
|
|
46
|
+
if (!length) throw createError({ statusCode: 411, message: 'Content-Length required' })
|
|
47
|
+
if (Number(length) > (maxMB + 1) * MB) throw fileTooLarge()
|
|
48
|
+
return readMultipartFormData(event)
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
export async function uploadFile(
|
|
35
52
|
event: H3Event,
|
|
36
53
|
file: MultiPartData,
|
|
@@ -39,9 +56,7 @@ export async function uploadFile(
|
|
|
39
56
|
) {
|
|
40
57
|
await checkFileAccess(event, path)
|
|
41
58
|
path = getSecurePath(path, isPrivate)
|
|
42
|
-
|
|
43
|
-
path = join(path, basename(file.filename))
|
|
44
|
-
}
|
|
59
|
+
path = appendFilename(path, file.filename)
|
|
45
60
|
|
|
46
61
|
let url
|
|
47
62
|
if (useS3()) {
|
|
@@ -73,9 +88,7 @@ export async function uploadStream(
|
|
|
73
88
|
) {
|
|
74
89
|
await checkFileAccess(event, path)
|
|
75
90
|
path = getSecurePath(path, isPrivate)
|
|
76
|
-
|
|
77
|
-
path = join(path, basename(file.filename))
|
|
78
|
-
}
|
|
91
|
+
path = appendFilename(path, file.filename)
|
|
79
92
|
|
|
80
93
|
if (useS3()) {
|
|
81
94
|
return uploadStreamToS3(
|
|
@@ -135,12 +148,15 @@ export async function uploadChunk(
|
|
|
135
148
|
if (chunkIndex < 0 || chunkIndex >= totalChunks || totalChunks < 1) {
|
|
136
149
|
throw createError({ statusCode: 400, message: 'Invalid chunk index' })
|
|
137
150
|
}
|
|
151
|
+
const { chunkSize, maxSize } = useRuntimeConfig().public.files
|
|
152
|
+
const chunkBytes = (Number(chunkSize) || 1) * MB
|
|
153
|
+
if (totalChunks > Math.ceil((Number(maxSize) * MB) / chunkBytes)) {
|
|
154
|
+
throw fileTooLarge()
|
|
155
|
+
}
|
|
138
156
|
|
|
139
157
|
await checkFileAccess(event, path)
|
|
140
158
|
path = getSecurePath(path, isPrivate)
|
|
141
|
-
|
|
142
|
-
path = join(path, basename(filename))
|
|
143
|
-
}
|
|
159
|
+
path = appendFilename(path, filename)
|
|
144
160
|
|
|
145
161
|
if (useS3()) {
|
|
146
162
|
if (!uploadId) {
|
|
@@ -190,9 +206,69 @@ export async function uploadChunk(
|
|
|
190
206
|
return getFileURL(path, isPrivate)
|
|
191
207
|
}
|
|
192
208
|
|
|
209
|
+
const TEMP_UPLOAD_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
|
210
|
+
|
|
211
|
+
// Removes chunked uploads that were started but never finished
|
|
212
|
+
export async function cleanTempUploads(maxAgeMs = TEMP_UPLOAD_MAX_AGE_MS) {
|
|
213
|
+
const cutoff = Date.now() - maxAgeMs
|
|
214
|
+
|
|
215
|
+
const tempDir = join(process.cwd(), 'files', 'temp')
|
|
216
|
+
const uploadIds = await readdir(tempDir).catch(() => [])
|
|
217
|
+
await Promise.all(
|
|
218
|
+
uploadIds.map(async (uploadId) => {
|
|
219
|
+
const uploadDir = join(tempDir, uploadId)
|
|
220
|
+
const stats = await stat(uploadDir)
|
|
221
|
+
if (!stats.isDirectory()) return
|
|
222
|
+
// A retried chunk overwrites its file without touching the folder's mtime
|
|
223
|
+
const chunks = await readdir(uploadDir)
|
|
224
|
+
const chunkTimes = await Promise.all(
|
|
225
|
+
chunks.map(async (chunk) => (await stat(join(uploadDir, chunk))).mtimeMs),
|
|
226
|
+
)
|
|
227
|
+
if (Math.max(stats.mtimeMs, ...chunkTimes) < cutoff) {
|
|
228
|
+
await rm(uploadDir, { recursive: true, force: true })
|
|
229
|
+
}
|
|
230
|
+
}),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
const client = useS3()
|
|
234
|
+
if (!client) return
|
|
235
|
+
|
|
236
|
+
const config = useRuntimeConfig()
|
|
237
|
+
const buckets = [...new Set([config.s3.privateBucket, config.s3.publicBucket])].filter(Boolean)
|
|
238
|
+
for (const Bucket of buckets) {
|
|
239
|
+
let KeyMarker: string | undefined
|
|
240
|
+
let UploadIdMarker: string | undefined
|
|
241
|
+
do {
|
|
242
|
+
const response = await client.send(
|
|
243
|
+
new ListMultipartUploadsCommand({ Bucket, KeyMarker, UploadIdMarker }),
|
|
244
|
+
)
|
|
245
|
+
await Promise.all(
|
|
246
|
+
(response.Uploads || [])
|
|
247
|
+
.filter((upload) => upload.Initiated && upload.Initiated.getTime() < cutoff)
|
|
248
|
+
.map((upload) =>
|
|
249
|
+
client.send(
|
|
250
|
+
new AbortMultipartUploadCommand({
|
|
251
|
+
Bucket,
|
|
252
|
+
Key: upload.Key,
|
|
253
|
+
UploadId: upload.UploadId,
|
|
254
|
+
}),
|
|
255
|
+
),
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
KeyMarker = response.IsTruncated ? response.NextKeyMarker : undefined
|
|
259
|
+
UploadIdMarker = response.IsTruncated ? response.NextUploadIdMarker : undefined
|
|
260
|
+
} while (KeyMarker)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// A name can hold %, # or ?, which would end or break the URL unencoded
|
|
265
|
+
function encodePath(path: string) {
|
|
266
|
+
return path.split('/').map(encodeURIComponent).join('/')
|
|
267
|
+
}
|
|
268
|
+
|
|
193
269
|
export function getFileURL(path: string, isPrivate = true) {
|
|
194
270
|
const config = useRuntimeConfig()
|
|
195
|
-
const url = removeRoot(path, isPrivate)
|
|
271
|
+
const url = encodePath(removeRoot(path, isPrivate))
|
|
196
272
|
|
|
197
273
|
if (isPrivate) {
|
|
198
274
|
return config.public.url + join('/', 'api/files', url) + '?isPrivate=true'
|
|
@@ -235,6 +311,8 @@ export async function getFile(
|
|
|
235
311
|
) {
|
|
236
312
|
await checkFileAccess(event, path)
|
|
237
313
|
path = getSecurePath(path, isPrivate)
|
|
314
|
+
setHeader(event, 'X-Content-Type-Options', 'nosniff')
|
|
315
|
+
setHeader(event, 'Content-Security-Policy', 'sandbox')
|
|
238
316
|
|
|
239
317
|
const range = getRequestHeader(event, 'range')
|
|
240
318
|
|
|
@@ -299,6 +377,8 @@ export async function getFile(
|
|
|
299
377
|
export async function deletePath(event: H3Event, path: string, isPrivate = true) {
|
|
300
378
|
await checkFileAccess(event, path)
|
|
301
379
|
path = getSecurePath(path, isPrivate)
|
|
380
|
+
// An empty path resolves to the root folder, deleting it would wipe every file
|
|
381
|
+
if (path === getSecurePath('', isPrivate)) throw invalidPath()
|
|
302
382
|
if (useS3()) await deleteFromS3(path, isPrivate)
|
|
303
383
|
else {
|
|
304
384
|
const localPath = join(process.cwd(), path)
|
|
@@ -371,13 +451,19 @@ export async function copyFile(
|
|
|
371
451
|
await checkFileAccess(event, dest)
|
|
372
452
|
src = getSecurePath(src, isPrivate)
|
|
373
453
|
dest = getSecurePath(dest, isPrivate)
|
|
454
|
+
// A copy onto itself would truncate the file, and a move would then delete it
|
|
455
|
+
if (src === dest) return
|
|
374
456
|
|
|
375
457
|
if (useS3()) return copyFromS3(src, dest, isPrivate, deleteSrc)
|
|
376
458
|
|
|
377
459
|
const srcPath = join(process.cwd(), src)
|
|
378
460
|
const destPath = join(process.cwd(), dest)
|
|
461
|
+
const stats = await stat(srcPath).catch(() => null)
|
|
462
|
+
if (!stats?.isFile()) {
|
|
463
|
+
throw createError({ statusCode: 404, message: 'File not found' })
|
|
464
|
+
}
|
|
379
465
|
await mkdir(dirname(destPath), { recursive: true })
|
|
380
|
-
await
|
|
466
|
+
await fsCopyFile(srcPath, destPath)
|
|
381
467
|
|
|
382
468
|
if (deleteSrc) {
|
|
383
469
|
await deletePath(event, removeRoot(src, isPrivate), isPrivate)
|
|
@@ -412,8 +498,11 @@ export function getSecurePath(path: string, isPrivate = true) {
|
|
|
412
498
|
const root = isPrivate ? config.filesPrivateFolder : config.filesPublicFolder
|
|
413
499
|
let normalizedPath = path.replace(/^\/+/, '')
|
|
414
500
|
|
|
415
|
-
if (
|
|
416
|
-
|
|
501
|
+
if (
|
|
502
|
+
normalizedPath.includes('..') ||
|
|
503
|
+
normalizedPath.split('/').some((segment) => segment === '.')
|
|
504
|
+
) {
|
|
505
|
+
throw invalidPath()
|
|
417
506
|
}
|
|
418
507
|
|
|
419
508
|
normalizedPath = join('/', root, removeRoot(normalizedPath, isPrivate))
|
|
@@ -421,6 +510,32 @@ export function getSecurePath(path: string, isPrivate = true) {
|
|
|
421
510
|
return normalizedPath.replace(/^\//, '').replace(/\/$/, '')
|
|
422
511
|
}
|
|
423
512
|
|
|
513
|
+
function invalidPath() {
|
|
514
|
+
return createError({ statusCode: 400, message: 'Invalid path' })
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function appendFilename(path: string, filename?: string) {
|
|
518
|
+
if (!filename) return path
|
|
519
|
+
if (filename.includes('..')) throw invalidPath()
|
|
520
|
+
filename = cleanFilename(filename)
|
|
521
|
+
if (/[^/]\.[^.]+$/.test(path) || basename(path) === filename) return path
|
|
522
|
+
return join(path, filename)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Keeps letters (accents too), digits, . - and _, anything else becomes a single -.
|
|
526
|
+
// NFC, because macOS sends é as e + accent and the same name would not match.
|
|
527
|
+
export function cleanFilename(filename: string) {
|
|
528
|
+
const name = basename(filename)
|
|
529
|
+
.normalize('NFC')
|
|
530
|
+
.replace(/[^\p{L}\p{M}\p{N}._-]+/gu, '-')
|
|
531
|
+
.replace(/-*\.-*/g, '.')
|
|
532
|
+
.replace(/\.{2,}/g, '.')
|
|
533
|
+
.replace(/-{2,}/g, '-')
|
|
534
|
+
.replace(/^-+|[-.]+$/g, '')
|
|
535
|
+
// Nothing left of the name, or a hidden file: keep the extension behind a visible name
|
|
536
|
+
return !name || name.startsWith('.') ? `file${name}` : name
|
|
537
|
+
}
|
|
538
|
+
|
|
424
539
|
export function removeRoot(path: string, isPrivate = true) {
|
|
425
540
|
const config = useRuntimeConfig()
|
|
426
541
|
const root = isPrivate ? config.filesPrivateFolder : config.filesPublicFolder
|
|
@@ -733,7 +848,7 @@ export function getS3Key(path: string) {
|
|
|
733
848
|
|
|
734
849
|
function getS3URL(path: string) {
|
|
735
850
|
const config = useRuntimeConfig()
|
|
736
|
-
return `${config.public.files.url}/${getS3Key(path)}`
|
|
851
|
+
return `${config.public.files.url}/${encodePath(getS3Key(path))}`
|
|
737
852
|
}
|
|
738
853
|
|
|
739
854
|
async function getS3SignedUrl(key: string, expiresIn = 3600, isPrivate = true): Promise<string> {
|
|
@@ -27,9 +27,14 @@ async function getId(event: H3Event): Promise<string> {
|
|
|
27
27
|
return getClientIP(event)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export async function enforceRateLimit(
|
|
30
|
+
export async function enforceRateLimit(
|
|
31
|
+
event: H3Event,
|
|
32
|
+
route: string,
|
|
33
|
+
rule: RateLimitRule,
|
|
34
|
+
id?: string,
|
|
35
|
+
) {
|
|
31
36
|
const config = useRuntimeConfig()
|
|
32
|
-
|
|
37
|
+
id ??= await getId(event)
|
|
33
38
|
const key = `${route}:${id}`
|
|
34
39
|
const now = Date.now()
|
|
35
40
|
|
package/locales/en.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"add": "Add",
|
|
3
3
|
"areYouSure": "Are you sure?",
|
|
4
|
+
"authentication": "Authentication",
|
|
4
5
|
"cancel": "Cancel",
|
|
5
6
|
"confirm": "Confirm",
|
|
6
7
|
"continue": "Continue",
|
|
@@ -11,6 +12,7 @@
|
|
|
11
12
|
"disable": "Disable",
|
|
12
13
|
"email": "Email",
|
|
13
14
|
"enable": "Enable",
|
|
15
|
+
"fileTooLarge": "Too large, max {max} MB: {files}",
|
|
14
16
|
"formatFailed": "Could not format or upload {files}",
|
|
15
17
|
"language": "Language | Languages",
|
|
16
18
|
"paste": "Paste",
|
|
@@ -27,6 +29,7 @@
|
|
|
27
29
|
"code": "Code",
|
|
28
30
|
"getCode": "Get code",
|
|
29
31
|
"otpSent": "Code sent to {email}",
|
|
32
|
+
"passkey": "Use a passkey",
|
|
30
33
|
"password": "Password",
|
|
31
34
|
"signin": "Sign In"
|
|
32
35
|
}
|
package/locales/fr.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"add": "Ajouter",
|
|
3
3
|
"areYouSure": "Êtes-vous sûr ?",
|
|
4
|
+
"authentication": "Authentification",
|
|
4
5
|
"cancel": "Annuler",
|
|
5
6
|
"confirm": "Confirmer",
|
|
6
7
|
"continue": "Continuer",
|
|
@@ -11,6 +12,7 @@
|
|
|
11
12
|
"disable": "Désactiver",
|
|
12
13
|
"email": "Email",
|
|
13
14
|
"enable": "Activer",
|
|
15
|
+
"fileTooLarge": "Trop volumineux, max {max} Mo : {files}",
|
|
14
16
|
"formatFailed": "Impossible de formater ou téléverser {files}",
|
|
15
17
|
"language": "Langue | Langues",
|
|
16
18
|
"paste": "Coller",
|
|
@@ -27,6 +29,7 @@
|
|
|
27
29
|
"code": "Code",
|
|
28
30
|
"getCode": "Demander le code",
|
|
29
31
|
"otpSent": "Code envoyé à {email}",
|
|
32
|
+
"passkey": "Utiliser une passkey",
|
|
30
33
|
"password": "Mot de passe",
|
|
31
34
|
"signin": "Se connecter"
|
|
32
35
|
}
|
package/nuxt.config.ts
CHANGED
|
@@ -27,6 +27,7 @@ export default defineNuxtConfig({
|
|
|
27
27
|
routes: {
|
|
28
28
|
// limit: max requests per IP, window: in seconds
|
|
29
29
|
'/api/log': { limit: 30, window: 60 },
|
|
30
|
+
'/api/auth/options': { limit: 10, window: 60 },
|
|
30
31
|
'/api/auth/password': { limit: 5, window: 60 },
|
|
31
32
|
'/api/auth/otp/get': { limit: 5, window: 900 },
|
|
32
33
|
'/api/auth/email/get': { limit: 5, window: 900 },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maxibase",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Nuxt layer with auth, files, logs and backups",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nuxt",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"engines": {
|
|
34
|
-
"node": ">=
|
|
34
|
+
"node": "^24.15.0 || >=26"
|
|
35
35
|
},
|
|
36
36
|
"packageManager": "pnpm@12.4.2+sha512.08adc6613180275c7c9edada39dcf08c9c61ad4e7eaf330a4f3461f102b0f907423454d117f98e72d47fef0616070644d7bffc973a6a57f5090a6d7c368b07c9",
|
|
37
37
|
"scripts": {
|