maxibase 1.0.0 → 1.1.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 +25 -4
- package/docker/Dockerfile +3 -3
- package/docker/compose.yaml +2 -2
- package/layers/base/app/components/AuthCheck.vue +89 -50
- 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/database/defaults/db.ts +9 -1
- package/layers/base/server/database/relations.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 +3 -3
|
@@ -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
|
}
|
|
@@ -2,4 +2,12 @@ import { drizzle } from 'drizzle-orm/node-postgres'
|
|
|
2
2
|
import { relations } from '#database/relations'
|
|
3
3
|
import { authRelations } from '../relations'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// defineRelations gives every table an entry, empty ones included: only the project's tables
|
|
6
|
+
// with relations override the layer's, so an empty entry never erases them
|
|
7
|
+
const own = Object.fromEntries(
|
|
8
|
+
Object.entries(relations).filter(([, table]) => Object.keys(table.relations).length),
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
export const db = drizzle(useRuntimeConfig().db, {
|
|
12
|
+
relations: { ...relations, ...authRelations, ...own } as typeof relations & typeof authRelations,
|
|
13
|
+
})
|
|
@@ -2,7 +2,7 @@ import * as schema from '#server/database/schema'
|
|
|
2
2
|
import { defineRelationsPart } from 'drizzle-orm'
|
|
3
3
|
|
|
4
4
|
// Built from the project schema, so they also apply to a table the project overrides.
|
|
5
|
-
//
|
|
5
|
+
// A project entry for one of these tables replaces the layer's, see defaults/db.ts.
|
|
6
6
|
export const authRelations = defineRelationsPart(schema, (r) => ({
|
|
7
7
|
auth: {
|
|
8
8
|
refreshTokens: r.many.refreshTokens({
|
|
@@ -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.0",
|
|
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": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"db:seed": "npx jiti ./server/database/seed.ts",
|
|
45
45
|
"db:generate": "drizzle-kit generate",
|
|
46
46
|
"db:push": "drizzle-kit push",
|
|
47
|
-
"db:push-
|
|
47
|
+
"db:push-prod": "./scripts/db/push-server.sh",
|
|
48
48
|
"db:restore": "./scripts/db/restore.sh",
|
|
49
49
|
"db:migrate": "npx jiti ./scripts/db/migrate.ts",
|
|
50
50
|
"db:studio": "drizzle-kit studio",
|