maxibase 1.3.0 → 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.
@@ -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(files, props.path, props.isPrivate, (p) => {
61
- progress.value = p
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
  }
@@ -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
- let cache = `public, max-age=${maxAge || 31536000}`
30
- if (maxAge === 0) {
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
- src,
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
  })
@@ -1,10 +1,7 @@
1
1
  import { z } from 'zod'
2
2
 
3
3
  const querySchema = z.object({
4
- path: z
5
- .string()
6
- .min(1)
7
- .refine((p) => !p.includes('.')),
4
+ path: z.string().min(1),
8
5
  isPrivate: z
9
6
  .enum(['true', 'false'])
10
7
  .transform((v) => v === 'true')
@@ -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.filter((p) => p.name === 'file').map((file) => uploadFile(event, file, path, isPrivate)),
19
+ body
20
+ .filter((p) => p.name === 'file')
21
+ .map((file) => uploadFile(event, file, path, isPrivate, maxAge)),
14
22
  )
15
23
  })
@@ -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 = getSecurePath(path, isPrivate)
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
- undefined,
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 = getSecurePath(path, isPrivate)
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
- undefined,
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 = getSecurePath(path, isPrivate)
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(getS3Key(path), type, isPrivate))!
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, { cache, headers: range ? { range } : undefined })
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 filesStats = await readdir(localPath).catch(() => [])
422
- return (
423
- await Promise.all(
424
- filesStats.map(async (name) => {
425
- const localeFilePath = join(localPath, name)
426
- const stats = await stat(localeFilePath)
427
- const rootedPath = join(path, name)
428
- const filePath = removeRoot(rootedPath, isPrivate)
429
- return stats.isDirectory()
430
- ? await listFolder(event, filePath, isPrivate)
431
- : {
432
- name,
433
- size: stats.size,
434
- updatedAt: stats.mtime,
435
- path: filePath,
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 appendFilename(path: string, filename?: string) {
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 = 'public, max-age=31536000',
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 || 'public, max-age=31536000',
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 = 'public, max-age=31536000',
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 ? getS3SignedUrl(key) : getS3URL(key)
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
- ...(await Promise.all(
804
- (response.Contents || [])
805
- .filter((item) => item.Key === path || item.Key?.startsWith(`${path}/`))
806
- .map(async (item) => ({
807
- path: removeRoot(item.Key || '', isPrivate),
808
- name: item.Key?.split('/').pop() || '',
809
- url: isPrivate ? await getS3SignedUrl(item.Key || '') : getS3URL(item.Key || ''),
810
- updatedAt: item.LastModified ?? new Date(0),
811
- size: item.Size || 0,
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxibase",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Nuxt layer with auth, files, logs and backups",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -71,6 +71,7 @@
71
71
  "@types/qrcode": "^1.5.6",
72
72
  "drizzle-orm": "1.0.0-rc.3",
73
73
  "goku-css": "^9.0.8",
74
+ "mime": "^4.1.1",
74
75
  "nodemailer": "^10.1.7",
75
76
  "nuxt-auth-utils": "^0.5.32",
76
77
  "nuxt-i18n-micro": "^3.30.1",