maxibase 1.2.1 → 1.4.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/docker/compose.yaml +1 -1
- package/layers/base/app/components/files/FilesUpload.vue +10 -3
- package/layers/base/app/pages/admin/logs.vue +2 -2
- package/layers/base/app/utils/files.ts +6 -1
- package/layers/base/nuxt.config.ts +3 -1
- package/layers/base/server/api/files/[...path].get.ts +2 -11
- package/layers/base/server/api/files/chunk.post.ts +2 -0
- package/layers/base/server/api/files/copy.post.ts +2 -10
- package/layers/base/server/api/files/index.get.ts +1 -4
- package/layers/base/server/api/files/index.post.ts +9 -1
- package/layers/base/server/api/log.post.ts +1 -1
- package/layers/base/server/utils/files.ts +78 -65
- package/layers/base/server/utils/rateLimit.ts +26 -1
- package/nuxt.config.ts +3 -5
- package/package.json +16 -15
package/docker/compose.yaml
CHANGED
|
@@ -9,6 +9,7 @@ const props = defineProps({
|
|
|
9
9
|
type: Boolean,
|
|
10
10
|
default: true,
|
|
11
11
|
},
|
|
12
|
+
maxAge: Number,
|
|
12
13
|
imageFormat: {
|
|
13
14
|
type: Object as PropType<{
|
|
14
15
|
maxHeight?: number
|
|
@@ -57,9 +58,15 @@ async function uploadFile(event: Event) {
|
|
|
57
58
|
files = files.filter((file) => !tooLarge.includes(file))
|
|
58
59
|
}
|
|
59
60
|
if (files.length) {
|
|
60
|
-
let response = await uploadFiles(
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
let response = await uploadFiles(
|
|
62
|
+
files,
|
|
63
|
+
props.path,
|
|
64
|
+
props.isPrivate,
|
|
65
|
+
(p) => {
|
|
66
|
+
progress.value = p
|
|
67
|
+
},
|
|
68
|
+
props.maxAge,
|
|
69
|
+
)
|
|
63
70
|
emits('uploaded', response)
|
|
64
71
|
appStore.notify('saved', 'success')
|
|
65
72
|
}
|
|
@@ -70,8 +70,8 @@ function formatDay(utcTimestamp: string) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
function originUrl(origin: string | null) {
|
|
73
|
-
const url = origin?.replace(/ .*/, '')
|
|
74
|
-
return url
|
|
73
|
+
const url = origin?.replace(/^client /, '').replace(/ .*/, '')
|
|
74
|
+
return url && /^\/(?![/\\])/.test(url) ? url : null
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
function toggleExpanded(id: number, isOpen: boolean) {
|
|
@@ -3,6 +3,7 @@ export async function uploadFiles(
|
|
|
3
3
|
path: string,
|
|
4
4
|
isPrivate: boolean,
|
|
5
5
|
onProgress?: (progress: number) => void,
|
|
6
|
+
maxAge?: number, // seconds the browser and CDN may cache the file, 0 disables caching
|
|
6
7
|
) {
|
|
7
8
|
let totalSize = files.reduce((sum, f) => sum + f.size, 0)
|
|
8
9
|
|
|
@@ -13,6 +14,7 @@ export async function uploadFiles(
|
|
|
13
14
|
files.forEach((file) => formData.append('file', file))
|
|
14
15
|
formData.append('path', path)
|
|
15
16
|
formData.append('isPrivate', String(isPrivate))
|
|
17
|
+
if (maxAge !== undefined) formData.append('maxAge', String(maxAge))
|
|
16
18
|
urls = await postFiles(formData)
|
|
17
19
|
} else {
|
|
18
20
|
let uploadedSize = 0
|
|
@@ -22,6 +24,7 @@ export async function uploadFiles(
|
|
|
22
24
|
formData.append('file', file)
|
|
23
25
|
formData.append('path', path)
|
|
24
26
|
formData.append('isPrivate', String(isPrivate))
|
|
27
|
+
if (maxAge !== undefined) formData.append('maxAge', String(maxAge))
|
|
25
28
|
urls.push(...(await postFiles(formData)))
|
|
26
29
|
uploadedSize += file.size
|
|
27
30
|
const progress = (uploadedSize / totalSize) * 100
|
|
@@ -32,7 +35,7 @@ export async function uploadFiles(
|
|
|
32
35
|
const progress = (uploadedSize / totalSize) * 100
|
|
33
36
|
onProgress?.(Math.round(progress))
|
|
34
37
|
}
|
|
35
|
-
const url = await uploadInChunks(file, path, isPrivate, onChunkProgress)
|
|
38
|
+
const url = await uploadInChunks(file, path, isPrivate, onChunkProgress, maxAge)
|
|
36
39
|
if (url) urls.push(url)
|
|
37
40
|
}
|
|
38
41
|
}
|
|
@@ -51,6 +54,7 @@ export async function uploadInChunks(
|
|
|
51
54
|
path: string,
|
|
52
55
|
isPrivate: boolean,
|
|
53
56
|
onProgress?: (progress: number) => void,
|
|
57
|
+
maxAge?: number,
|
|
54
58
|
) {
|
|
55
59
|
let uploadId
|
|
56
60
|
let parts: any[] = []
|
|
@@ -70,6 +74,7 @@ export async function uploadInChunks(
|
|
|
70
74
|
formData.append('filename', file.name)
|
|
71
75
|
formData.append('path', path)
|
|
72
76
|
formData.append('isPrivate', String(isPrivate))
|
|
77
|
+
if (maxAge !== undefined) formData.append('maxAge', String(maxAge))
|
|
73
78
|
|
|
74
79
|
result = await $fetch('/api/files/chunk', { method: 'POST', body: formData })
|
|
75
80
|
if (i + 1 < totalChunks) {
|
|
@@ -6,8 +6,6 @@ const querySchema = z.object({
|
|
|
6
6
|
.transform((v) => v === 'true')
|
|
7
7
|
.optional(),
|
|
8
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
9
|
// S3 refuses to presign a URL for longer than 7 days
|
|
12
10
|
expireIn: z.coerce.number().int().positive().max(604800).optional(),
|
|
13
11
|
redirect: z
|
|
@@ -21,19 +19,12 @@ export default defineEventHandler(async (event) => {
|
|
|
21
19
|
const {
|
|
22
20
|
isPrivate = false,
|
|
23
21
|
maxAge,
|
|
24
|
-
serverCache,
|
|
25
22
|
expireIn,
|
|
26
23
|
redirect,
|
|
27
24
|
} = await getValidatedQuery(event, querySchema.parse)
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
cache = 'no-store, no-cache'
|
|
32
|
-
} else if (isPrivate) {
|
|
33
|
-
cache = `private, max-age=${maxAge || 3600}`
|
|
34
|
-
}
|
|
35
|
-
setHeader(event, 'Cache-Control', cache)
|
|
36
|
-
return getFile(event, decodePath(path), isPrivate, serverCache, expireIn, redirect)
|
|
26
|
+
setHeader(event, 'Cache-Control', getCacheControl(isPrivate, maxAge))
|
|
27
|
+
return getFile(event, decodePath(path), isPrivate, expireIn, redirect)
|
|
37
28
|
})
|
|
38
29
|
|
|
39
30
|
// h3 decodes the path but keeps %25 encoded, so a % in a name needs this second pass
|
|
@@ -37,6 +37,7 @@ export default defineEventHandler(async (event) => {
|
|
|
37
37
|
const path = get('path') || 'files'
|
|
38
38
|
const isPrivate = get('isPrivate') !== 'false'
|
|
39
39
|
const parts = parseParts(get('parts'))
|
|
40
|
+
const maxAge = parseMaxAge(get('maxAge'))
|
|
40
41
|
const chunkPart = body.find((p) => p.name === 'chunk')
|
|
41
42
|
|
|
42
43
|
if (!Number.isInteger(chunkIndex) || !Number.isInteger(totalChunks) || !filename || !chunkPart) {
|
|
@@ -53,5 +54,6 @@ export default defineEventHandler(async (event) => {
|
|
|
53
54
|
path,
|
|
54
55
|
isPrivate,
|
|
55
56
|
parts,
|
|
57
|
+
maxAge,
|
|
56
58
|
})
|
|
57
59
|
})
|
|
@@ -5,18 +5,10 @@ const bodySchema = z.object({
|
|
|
5
5
|
dest: z.string().min(1),
|
|
6
6
|
isPrivate: z.boolean().optional(),
|
|
7
7
|
deleteSrc: z.boolean().optional(),
|
|
8
|
-
isFolder: z.boolean().optional(),
|
|
9
8
|
})
|
|
10
9
|
|
|
11
10
|
export default defineEventHandler(async (event) => {
|
|
12
|
-
const {
|
|
13
|
-
|
|
14
|
-
dest,
|
|
15
|
-
isPrivate,
|
|
16
|
-
deleteSrc,
|
|
17
|
-
isFolder = !src.includes('.'),
|
|
18
|
-
} = await readValidatedBody(event, bodySchema.parse)
|
|
19
|
-
if (isFolder) await copyFiles(event, src, dest, isPrivate, deleteSrc)
|
|
20
|
-
else await copyFile(event, src, dest, isPrivate, deleteSrc)
|
|
11
|
+
const { src, dest, isPrivate, deleteSrc } = await readValidatedBody(event, bodySchema.parse)
|
|
12
|
+
await copyFiles(event, src, dest, isPrivate, deleteSrc)
|
|
21
13
|
return { success: true }
|
|
22
14
|
})
|
|
@@ -8,8 +8,16 @@ export default defineEventHandler(async (event) => {
|
|
|
8
8
|
const isPrivatePart = body.find((p) => p.name === 'isPrivate')
|
|
9
9
|
const isPrivate = isPrivatePart ? isPrivatePart.data.toString() === 'true' : true
|
|
10
10
|
const path = pathPart?.data.toString().trim()
|
|
11
|
+
const maxAge = parseMaxAge(
|
|
12
|
+
body
|
|
13
|
+
.find((p) => p.name === 'maxAge')
|
|
14
|
+
?.data.toString()
|
|
15
|
+
.trim(),
|
|
16
|
+
)
|
|
11
17
|
|
|
12
18
|
return await Promise.all(
|
|
13
|
-
body
|
|
19
|
+
body
|
|
20
|
+
.filter((p) => p.name === 'file')
|
|
21
|
+
.map((file) => uploadFile(event, file, path, isPrivate, maxAge)),
|
|
14
22
|
)
|
|
15
23
|
})
|
|
@@ -16,5 +16,5 @@ const bodySchema = z.object({
|
|
|
16
16
|
|
|
17
17
|
export default defineEventHandler(async (event) => {
|
|
18
18
|
const { summary, data, origin, type } = await readValidatedBody(event, bodySchema.parse)
|
|
19
|
-
await log(summary, data, origin, type, event)
|
|
19
|
+
await log(summary, data, origin ? `client ${origin}` : 'client', type, event)
|
|
20
20
|
})
|
|
@@ -32,6 +32,7 @@ import { pipeline } from 'stream/promises'
|
|
|
32
32
|
import type { Readable } from 'stream'
|
|
33
33
|
import type { H3Event, MultiPartData } from 'h3'
|
|
34
34
|
import { createHash } from 'crypto'
|
|
35
|
+
import mime from 'mime'
|
|
35
36
|
|
|
36
37
|
const MB = 1024 * 1024
|
|
37
38
|
|
|
@@ -53,10 +54,10 @@ export async function uploadFile(
|
|
|
53
54
|
file: MultiPartData,
|
|
54
55
|
path = 'files',
|
|
55
56
|
isPrivate = true,
|
|
57
|
+
maxAge?: number, // only stored on S3 objects, local files get their headers from nginx
|
|
56
58
|
) {
|
|
57
59
|
await checkFileAccess(event, path)
|
|
58
|
-
path =
|
|
59
|
-
path = appendFilename(path, file.filename)
|
|
60
|
+
path = getUploadPath(path, isPrivate, file.filename)
|
|
60
61
|
|
|
61
62
|
let url
|
|
62
63
|
if (useS3()) {
|
|
@@ -65,7 +66,7 @@ export async function uploadFile(
|
|
|
65
66
|
s3Key,
|
|
66
67
|
file.data,
|
|
67
68
|
file.type || 'application/octet-stream',
|
|
68
|
-
|
|
69
|
+
getCacheControl(isPrivate, maxAge),
|
|
69
70
|
isPrivate,
|
|
70
71
|
)
|
|
71
72
|
} else {
|
|
@@ -85,17 +86,17 @@ export async function uploadStream(
|
|
|
85
86
|
file: { stream: Readable; filename?: string; type?: string },
|
|
86
87
|
path = 'files',
|
|
87
88
|
isPrivate = true,
|
|
89
|
+
maxAge?: number,
|
|
88
90
|
) {
|
|
89
91
|
await checkFileAccess(event, path)
|
|
90
|
-
path =
|
|
91
|
-
path = appendFilename(path, file.filename)
|
|
92
|
+
path = getUploadPath(path, isPrivate, file.filename)
|
|
92
93
|
|
|
93
94
|
if (useS3()) {
|
|
94
95
|
return uploadStreamToS3(
|
|
95
96
|
getS3Key(path),
|
|
96
97
|
file.stream,
|
|
97
98
|
file.type || 'application/octet-stream',
|
|
98
|
-
|
|
99
|
+
getCacheControl(isPrivate, maxAge),
|
|
99
100
|
isPrivate,
|
|
100
101
|
)
|
|
101
102
|
}
|
|
@@ -133,6 +134,7 @@ export async function uploadChunk(
|
|
|
133
134
|
path,
|
|
134
135
|
isPrivate,
|
|
135
136
|
parts,
|
|
137
|
+
maxAge,
|
|
136
138
|
}: {
|
|
137
139
|
uploadId?: string
|
|
138
140
|
chunkIndex: number
|
|
@@ -143,6 +145,7 @@ export async function uploadChunk(
|
|
|
143
145
|
path: string
|
|
144
146
|
isPrivate: boolean
|
|
145
147
|
parts: CompletedPart[]
|
|
148
|
+
maxAge?: number // only read on the first chunk, when the S3 upload starts
|
|
146
149
|
},
|
|
147
150
|
) {
|
|
148
151
|
if (chunkIndex < 0 || chunkIndex >= totalChunks || totalChunks < 1) {
|
|
@@ -155,12 +158,16 @@ export async function uploadChunk(
|
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
await checkFileAccess(event, path)
|
|
158
|
-
path =
|
|
159
|
-
path = appendFilename(path, filename)
|
|
161
|
+
path = getUploadPath(path, isPrivate, filename)
|
|
160
162
|
|
|
161
163
|
if (useS3()) {
|
|
162
164
|
if (!uploadId) {
|
|
163
|
-
uploadId = (await initMultipartUploadToS3(
|
|
165
|
+
uploadId = (await initMultipartUploadToS3(
|
|
166
|
+
getS3Key(path),
|
|
167
|
+
type,
|
|
168
|
+
isPrivate,
|
|
169
|
+
getCacheControl(isPrivate, maxAge),
|
|
170
|
+
))!
|
|
164
171
|
parts = []
|
|
165
172
|
}
|
|
166
173
|
|
|
@@ -305,7 +312,6 @@ export async function getFile(
|
|
|
305
312
|
event: H3Event,
|
|
306
313
|
path: string,
|
|
307
314
|
isPrivate = false,
|
|
308
|
-
cache: RequestCache = 'no-cache',
|
|
309
315
|
expireIn?: number,
|
|
310
316
|
redirect?: boolean,
|
|
311
317
|
) {
|
|
@@ -324,13 +330,14 @@ export async function getFile(
|
|
|
324
330
|
return sendRedirect(event, url, 302)
|
|
325
331
|
}
|
|
326
332
|
|
|
327
|
-
const response = await fetch(url, {
|
|
333
|
+
const response = await fetch(url, { headers: range ? { range } : undefined })
|
|
328
334
|
|
|
329
335
|
if (response.status === 416) {
|
|
330
336
|
const contentRange = response.headers.get('content-range')
|
|
331
337
|
if (contentRange) setHeader(event, 'Content-Range', contentRange)
|
|
332
338
|
throw createError({ statusCode: 416, message: 'Range not satisfiable' })
|
|
333
339
|
}
|
|
340
|
+
if (response.status === 404) throw createError({ statusCode: 404, message: 'File not found' })
|
|
334
341
|
if (!response.ok) throw createError({ statusCode: 502, message: 'Failed to fetch file' })
|
|
335
342
|
|
|
336
343
|
const passthrough = {
|
|
@@ -354,6 +361,7 @@ export async function getFile(
|
|
|
354
361
|
throw createError({ statusCode: 404, message: 'File not found' })
|
|
355
362
|
}
|
|
356
363
|
|
|
364
|
+
setHeader(event, 'Content-Type', mime.getType(fullPath) || 'application/octet-stream')
|
|
357
365
|
setHeader(event, 'Accept-Ranges', 'bytes')
|
|
358
366
|
|
|
359
367
|
const parsedRange = parseRangeHeader(range, stats.size)
|
|
@@ -417,27 +425,26 @@ export async function listFolder(
|
|
|
417
425
|
path = getSecurePath(path, isPrivate)
|
|
418
426
|
|
|
419
427
|
if (useS3()) return await listFromS3(path, isPrivate)
|
|
428
|
+
return listLocal(path, isPrivate)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function listLocal(path: string, isPrivate: boolean): Promise<ListedFile[]> {
|
|
420
432
|
const localPath = join(process.cwd(), path)
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
url: getFileURL(rootedPath, isPrivate),
|
|
437
|
-
}
|
|
438
|
-
}),
|
|
439
|
-
)
|
|
440
|
-
).flat()
|
|
433
|
+
const stats = await stat(localPath).catch(() => null)
|
|
434
|
+
if (stats?.isFile()) {
|
|
435
|
+
return [
|
|
436
|
+
{
|
|
437
|
+
name: basename(path),
|
|
438
|
+
size: stats.size,
|
|
439
|
+
updatedAt: stats.mtime,
|
|
440
|
+
path: removeRoot(path, isPrivate),
|
|
441
|
+
url: getFileURL(path, isPrivate),
|
|
442
|
+
},
|
|
443
|
+
]
|
|
444
|
+
}
|
|
445
|
+
if (!stats?.isDirectory()) return []
|
|
446
|
+
const names = await readdir(localPath)
|
|
447
|
+
return (await Promise.all(names.map((name) => listLocal(join(path, name), isPrivate)))).flat()
|
|
441
448
|
}
|
|
442
449
|
|
|
443
450
|
export async function copyFile(
|
|
@@ -478,6 +485,7 @@ export async function copyFiles(
|
|
|
478
485
|
deleteSrc = false,
|
|
479
486
|
) {
|
|
480
487
|
const files = await listFolder(event, src, isPrivate)
|
|
488
|
+
if (!files.length) throw createError({ statusCode: 404, message: 'File not found' })
|
|
481
489
|
const normalizedSrc = removeRoot(getSecurePath(src, isPrivate), isPrivate)
|
|
482
490
|
const normalizedDest = removeRoot(getSecurePath(dest, isPrivate), isPrivate)
|
|
483
491
|
return Promise.all(
|
|
@@ -514,11 +522,13 @@ function invalidPath() {
|
|
|
514
522
|
return createError({ statusCode: 400, message: 'Invalid path' })
|
|
515
523
|
}
|
|
516
524
|
|
|
517
|
-
function
|
|
525
|
+
function getUploadPath(path: string, isPrivate: boolean, filename?: string) {
|
|
526
|
+
const isFolder = path.endsWith('/')
|
|
527
|
+
path = getSecurePath(path, isPrivate)
|
|
518
528
|
if (!filename) return path
|
|
519
529
|
if (filename.includes('..')) throw invalidPath()
|
|
520
530
|
filename = cleanFilename(filename)
|
|
521
|
-
if (/[^/]\.[^.]+$/.test(path) || basename(path) === filename) return path
|
|
531
|
+
if (!isFolder && (/[^/]\.[^.]+$/.test(path) || basename(path) === filename)) return path
|
|
522
532
|
return join(path, filename)
|
|
523
533
|
}
|
|
524
534
|
|
|
@@ -563,56 +573,60 @@ export const useS3 = () => {
|
|
|
563
573
|
return s3Client
|
|
564
574
|
}
|
|
565
575
|
|
|
576
|
+
export function getCacheControl(isPrivate: boolean, maxAge?: number) {
|
|
577
|
+
if (maxAge === 0) return 'no-store, no-cache'
|
|
578
|
+
if (isPrivate) return `private, max-age=${maxAge || 3600}`
|
|
579
|
+
return `public, max-age=${maxAge || 31536000}`
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function parseMaxAge(value?: string) {
|
|
583
|
+
if (!value) return undefined
|
|
584
|
+
const maxAge = Number(value)
|
|
585
|
+
if (!Number.isInteger(maxAge) || maxAge < 0) {
|
|
586
|
+
throw createError({ statusCode: 400, message: 'Invalid maxAge' })
|
|
587
|
+
}
|
|
588
|
+
return maxAge
|
|
589
|
+
}
|
|
590
|
+
|
|
566
591
|
export async function uploadToS3(
|
|
567
592
|
key: string,
|
|
568
593
|
body: Buffer,
|
|
569
594
|
contentType: string,
|
|
570
|
-
cache
|
|
595
|
+
cache?: string,
|
|
571
596
|
isPrivate = true,
|
|
572
597
|
) {
|
|
573
598
|
const client = useS3()
|
|
574
599
|
if (!client) return null
|
|
575
600
|
|
|
576
601
|
const config = useRuntimeConfig()
|
|
602
|
+
cache ||= getCacheControl(isPrivate)
|
|
577
603
|
|
|
578
|
-
if (isPrivate) {
|
|
579
|
-
await client.send(
|
|
580
|
-
new PutObjectCommand({
|
|
581
|
-
Bucket: config.s3.privateBucket,
|
|
582
|
-
Key: key,
|
|
583
|
-
Body: body,
|
|
584
|
-
ContentType: contentType,
|
|
585
|
-
CacheControl: cache || 'public, max-age=31536000',
|
|
586
|
-
}),
|
|
587
|
-
)
|
|
588
|
-
|
|
589
|
-
return getS3SignedUrl(key)
|
|
590
|
-
}
|
|
591
604
|
await client.send(
|
|
592
605
|
new PutObjectCommand({
|
|
593
|
-
Bucket: config.s3.publicBucket,
|
|
606
|
+
Bucket: isPrivate ? config.s3.privateBucket : config.s3.publicBucket,
|
|
594
607
|
Key: key,
|
|
595
608
|
Body: body,
|
|
596
609
|
ContentType: contentType,
|
|
597
|
-
CacheControl: cache
|
|
598
|
-
ACL: 'public-read',
|
|
610
|
+
CacheControl: cache,
|
|
611
|
+
ACL: isPrivate ? undefined : 'public-read',
|
|
599
612
|
}),
|
|
600
613
|
)
|
|
601
614
|
|
|
602
|
-
return getS3URL(key)
|
|
615
|
+
return isPrivate ? getFileURL(key) : getS3URL(key)
|
|
603
616
|
}
|
|
604
617
|
|
|
605
618
|
export async function uploadStreamToS3(
|
|
606
619
|
key: string,
|
|
607
620
|
body: Readable,
|
|
608
621
|
contentType: string,
|
|
609
|
-
cache
|
|
622
|
+
cache?: string,
|
|
610
623
|
isPrivate = true,
|
|
611
624
|
) {
|
|
612
625
|
const client = useS3()
|
|
613
626
|
if (!client) return null
|
|
614
627
|
|
|
615
628
|
const config = useRuntimeConfig()
|
|
629
|
+
cache ||= getCacheControl(isPrivate)
|
|
616
630
|
const partSize = 5 * 1024 * 1024 // S3 minimum, only the last part may be smaller
|
|
617
631
|
|
|
618
632
|
let uploadId: string | undefined
|
|
@@ -680,7 +694,7 @@ export async function initMultipartUploadToS3(
|
|
|
680
694
|
Bucket: isPrivate ? config.s3.privateBucket : config.s3.publicBucket,
|
|
681
695
|
Key: key,
|
|
682
696
|
ContentType: contentType,
|
|
683
|
-
CacheControl: cache,
|
|
697
|
+
CacheControl: cache || getCacheControl(isPrivate),
|
|
684
698
|
ACL: isPrivate ? undefined : 'public-read',
|
|
685
699
|
})
|
|
686
700
|
|
|
@@ -734,7 +748,7 @@ export async function completeMultipartUploadToS3(
|
|
|
734
748
|
})
|
|
735
749
|
|
|
736
750
|
await client.send(command)
|
|
737
|
-
return isPrivate ?
|
|
751
|
+
return isPrivate ? getFileURL(key) : getS3URL(key)
|
|
738
752
|
}
|
|
739
753
|
|
|
740
754
|
export async function deleteFromS3(path: string, isPrivate = true) {
|
|
@@ -800,17 +814,15 @@ export async function listFromS3(path: string, isPrivate = true) {
|
|
|
800
814
|
)
|
|
801
815
|
ContinuationToken = response.NextContinuationToken
|
|
802
816
|
files.push(
|
|
803
|
-
...(
|
|
804
|
-
(
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
})),
|
|
813
|
-
)),
|
|
817
|
+
...(response.Contents || [])
|
|
818
|
+
.filter((item) => item.Key === path || item.Key?.startsWith(`${path}/`))
|
|
819
|
+
.map((item) => ({
|
|
820
|
+
path: removeRoot(item.Key || '', isPrivate),
|
|
821
|
+
name: item.Key?.split('/').pop() || '',
|
|
822
|
+
url: isPrivate ? getFileURL(item.Key || '') : getS3URL(item.Key || ''),
|
|
823
|
+
updatedAt: item.LastModified ?? new Date(0),
|
|
824
|
+
size: item.Size || 0,
|
|
825
|
+
})),
|
|
814
826
|
)
|
|
815
827
|
} while (ContinuationToken)
|
|
816
828
|
|
|
@@ -829,6 +841,7 @@ export async function copyFromS3(src: string, dest: string, isPrivate = true, de
|
|
|
829
841
|
Bucket: bucket,
|
|
830
842
|
Key: dest,
|
|
831
843
|
CopySource: copySource,
|
|
844
|
+
ACL: isPrivate ? undefined : 'public-read',
|
|
832
845
|
}),
|
|
833
846
|
)
|
|
834
847
|
if (deleteSrc) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { H3Event } from 'h3'
|
|
2
|
+
import { isIPv6 } from 'node:net'
|
|
2
3
|
|
|
3
4
|
export interface RateLimitRule {
|
|
4
5
|
limit: number // max requests per window
|
|
@@ -24,7 +25,31 @@ setInterval(() => {
|
|
|
24
25
|
async function getId(event: H3Event): Promise<string> {
|
|
25
26
|
const { user } = await getUserSession(event)
|
|
26
27
|
if (user) return user.id
|
|
27
|
-
return getClientIP(event)
|
|
28
|
+
return rateLimitIP(getClientIP(event))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// An IPv6 client usually holds a whole /64, so counting single addresses would let it rotate
|
|
32
|
+
// through 2^64 of them. Count the /64 instead, and IPv4-mapped addresses as their IPv4.
|
|
33
|
+
export function rateLimitIP(ip: string): string {
|
|
34
|
+
const address = ip.split('%')[0]!.toLowerCase() // drop the zone id
|
|
35
|
+
if (!isIPv6(address)) return ip
|
|
36
|
+
|
|
37
|
+
const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)
|
|
38
|
+
if (mapped) return mapped[1]!
|
|
39
|
+
|
|
40
|
+
// an embedded IPv4 tail fills the last two groups
|
|
41
|
+
const toGroups = (part: string) =>
|
|
42
|
+
part ? part.split(':').flatMap((group) => (group.includes('.') ? ['0', '0'] : [group])) : []
|
|
43
|
+
const [head = '', tail] = address.split('::')
|
|
44
|
+
let groups = toGroups(head)
|
|
45
|
+
if (tail !== undefined) {
|
|
46
|
+
const tailGroups = toGroups(tail)
|
|
47
|
+
groups = [...groups, ...Array(8 - groups.length - tailGroups.length).fill('0'), ...tailGroups]
|
|
48
|
+
}
|
|
49
|
+
return `${groups
|
|
50
|
+
.slice(0, 4)
|
|
51
|
+
.map((group) => parseInt(group, 16).toString(16))
|
|
52
|
+
.join(':')}::/64`
|
|
28
53
|
}
|
|
29
54
|
|
|
30
55
|
export async function enforceRateLimit(
|
package/nuxt.config.ts
CHANGED
|
@@ -13,10 +13,7 @@ export default defineNuxtConfig({
|
|
|
13
13
|
lang: 'en',
|
|
14
14
|
},
|
|
15
15
|
title: process.env.NUXT_PUBLIC_NAME,
|
|
16
|
-
link: [
|
|
17
|
-
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' },
|
|
18
|
-
{ rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' },
|
|
19
|
-
],
|
|
16
|
+
link: [{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' }],
|
|
20
17
|
meta: [{ name: 'theme-color', content: '#000000' }],
|
|
21
18
|
},
|
|
22
19
|
},
|
|
@@ -29,7 +26,7 @@ export default defineNuxtConfig({
|
|
|
29
26
|
rateLimit: {
|
|
30
27
|
routes: {
|
|
31
28
|
// limit: max requests per IP, window: in seconds
|
|
32
|
-
'/api/log': { limit:
|
|
29
|
+
'/api/log': { limit: 10, window: 3600 },
|
|
33
30
|
'/api/auth/options': { limit: 10, window: 60 },
|
|
34
31
|
'/api/auth/password': { limit: 5, window: 60 },
|
|
35
32
|
'/api/auth/otp/get': { limit: 5, window: 900 },
|
|
@@ -38,6 +35,7 @@ export default defineNuxtConfig({
|
|
|
38
35
|
'/api/auth/otp/verify': { limit: 10, window: 60 },
|
|
39
36
|
'/api/auth/totp/verify': { limit: 10, window: 60 },
|
|
40
37
|
'/api/auth/webauthn/authenticate': { limit: 10, window: 60 },
|
|
38
|
+
'/api/auth/webauthn/register': { limit: 10, window: 60 },
|
|
41
39
|
'/api/auth/anonymous': { limit: 5, window: 3600 },
|
|
42
40
|
},
|
|
43
41
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maxibase",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Nuxt layer with auth, files, logs and backups",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nuxt",
|
|
@@ -58,12 +58,12 @@
|
|
|
58
58
|
"prepublishOnly": "pnpm lint && pnpm fmt:check && pnpm typecheck && pnpm test"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@aws-sdk/client-s3": "^3.
|
|
62
|
-
"@aws-sdk/s3-request-presigner": "^3.
|
|
63
|
-
"@iconify-json/lucide": "^1.2.
|
|
64
|
-
"@nuxt/hints": "^1.1.
|
|
61
|
+
"@aws-sdk/client-s3": "^3.1154.0",
|
|
62
|
+
"@aws-sdk/s3-request-presigner": "^3.1154.0",
|
|
63
|
+
"@iconify-json/lucide": "^1.2.147",
|
|
64
|
+
"@nuxt/hints": "^1.1.5",
|
|
65
65
|
"@nuxt/icon": "^2.5.1",
|
|
66
|
-
"@pinia/nuxt": "^1.0.
|
|
66
|
+
"@pinia/nuxt": "^1.0.3",
|
|
67
67
|
"@simplewebauthn/browser": "^11.0.0",
|
|
68
68
|
"@simplewebauthn/server": "^11.0.0",
|
|
69
69
|
"@types/nodemailer": "^8.0.2",
|
|
@@ -71,24 +71,25 @@
|
|
|
71
71
|
"@types/qrcode": "^1.5.6",
|
|
72
72
|
"drizzle-orm": "1.0.0-rc.3",
|
|
73
73
|
"goku-css": "^9.0.8",
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"nuxt-
|
|
74
|
+
"mime": "^4.1.1",
|
|
75
|
+
"nodemailer": "^10.1.7",
|
|
76
|
+
"nuxt-auth-utils": "^0.5.32",
|
|
77
|
+
"nuxt-i18n-micro": "^3.30.1",
|
|
77
78
|
"otplib": "^13.5.0",
|
|
78
79
|
"pg": "^8.23.1",
|
|
79
|
-
"pinia": "^4.0
|
|
80
|
+
"pinia": "^4.1.0",
|
|
80
81
|
"qrcode": "^1.5.4",
|
|
81
|
-
"zod": "^4.6.
|
|
82
|
+
"zod": "^4.6.7"
|
|
82
83
|
},
|
|
83
84
|
"devDependencies": {
|
|
84
85
|
"drizzle-kit": "1.0.0-rc.3",
|
|
85
86
|
"drizzle-seed": "1.0.0-rc.3",
|
|
86
87
|
"maxibase": "link:.",
|
|
87
|
-
"nuxt": "^4.6.
|
|
88
|
+
"nuxt": "^4.6.2",
|
|
88
89
|
"oxfmt": "^0.72.0",
|
|
89
|
-
"oxlint": "^1.
|
|
90
|
+
"oxlint": "^1.89.0",
|
|
90
91
|
"typescript": "^5.9.3",
|
|
91
|
-
"vitest": "^5.0.
|
|
92
|
-
"vue-tsc": "^3.3.
|
|
92
|
+
"vitest": "^5.0.5",
|
|
93
|
+
"vue-tsc": "^3.3.13"
|
|
93
94
|
}
|
|
94
95
|
}
|