maxibase 0.0.0-stage → 1.0.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.
Files changed (115) hide show
  1. package/README.md +269 -2
  2. package/docker/Dockerfile +22 -0
  3. package/docker/Dockerfile.dockerignore +25 -0
  4. package/docker/compose.yaml +19 -0
  5. package/layers/base/app/app.vue +22 -0
  6. package/layers/base/app/components/AuthCheck.vue +316 -0
  7. package/layers/base/app/components/DataExplorer.vue +38 -0
  8. package/layers/base/app/components/LocaleSwitcher.vue +28 -0
  9. package/layers/base/app/components/TheConfirmation.vue +48 -0
  10. package/layers/base/app/components/TheLoading.vue +36 -0
  11. package/layers/base/app/components/TheNotifications.vue +71 -0
  12. package/layers/base/app/components/files/FilesDelete.vue +33 -0
  13. package/layers/base/app/components/files/FilesUpload.vue +85 -0
  14. package/layers/base/app/error.vue +28 -0
  15. package/layers/base/app/layouts/default.vue +15 -0
  16. package/layers/base/app/layouts/simple.vue +3 -0
  17. package/layers/base/app/middleware/admin.ts +15 -0
  18. package/layers/base/app/middleware/authenticated.ts +10 -0
  19. package/layers/base/app/middleware/mfa.global.ts +8 -0
  20. package/layers/base/app/pages/admin/logs.vue +364 -0
  21. package/layers/base/app/pages/signin.vue +121 -0
  22. package/layers/base/app/pages/user/auth.vue +509 -0
  23. package/layers/base/app/plugins/error.ts +21 -0
  24. package/layers/base/app/plugins/session.ts +22 -0
  25. package/layers/base/app/stores/app.ts +100 -0
  26. package/layers/base/app/utils/clipboard.ts +6 -0
  27. package/layers/base/app/utils/files.ts +95 -0
  28. package/layers/base/app/utils/image.ts +33 -0
  29. package/layers/base/nuxt.config.ts +146 -0
  30. package/layers/base/public/images/auth/microsoft.svg +1 -0
  31. package/layers/base/public/robots.txt +2 -0
  32. package/layers/base/server/api/admin/logs.get.ts +49 -0
  33. package/layers/base/server/api/auth/anonymous.post.ts +31 -0
  34. package/layers/base/server/api/auth/email/get.post.ts +46 -0
  35. package/layers/base/server/api/auth/email/verify.post.ts +46 -0
  36. package/layers/base/server/api/auth/index.get.ts +10 -0
  37. package/layers/base/server/api/auth/index.post.ts +19 -0
  38. package/layers/base/server/api/auth/options.post.ts +26 -0
  39. package/layers/base/server/api/auth/otp/get.post.ts +74 -0
  40. package/layers/base/server/api/auth/otp/verify.post.ts +70 -0
  41. package/layers/base/server/api/auth/password.post.ts +42 -0
  42. package/layers/base/server/api/auth/refresh.delete.ts +4 -0
  43. package/layers/base/server/api/auth/refresh.post.ts +5 -0
  44. package/layers/base/server/api/auth/totp/confirm.post.ts +26 -0
  45. package/layers/base/server/api/auth/totp/disable.post.ts +33 -0
  46. package/layers/base/server/api/auth/totp/generate.get.ts +6 -0
  47. package/layers/base/server/api/auth/totp/verify.post.ts +36 -0
  48. package/layers/base/server/api/auth/webauthn/authenticate.post.ts +39 -0
  49. package/layers/base/server/api/auth/webauthn/index.delete.ts +43 -0
  50. package/layers/base/server/api/auth/webauthn/index.post.ts +17 -0
  51. package/layers/base/server/api/auth/webauthn/register.post.ts +49 -0
  52. package/layers/base/server/api/files/[...path].get.ts +37 -0
  53. package/layers/base/server/api/files/chunk.post.ts +36 -0
  54. package/layers/base/server/api/files/copy.post.ts +22 -0
  55. package/layers/base/server/api/files/index.delete.ts +12 -0
  56. package/layers/base/server/api/files/index.get.ts +19 -0
  57. package/layers/base/server/api/files/index.post.ts +15 -0
  58. package/layers/base/server/api/log.post.ts +20 -0
  59. package/layers/base/server/database/defaults/access.ts +14 -0
  60. package/layers/base/server/database/defaults/db.ts +5 -0
  61. package/layers/base/server/database/defaults/relations.ts +5 -0
  62. package/layers/base/server/database/relations.ts +35 -0
  63. package/layers/base/server/database/schema.ts +66 -0
  64. package/layers/base/server/middleware/0.rateLimit.ts +21 -0
  65. package/layers/base/server/middleware/1.session.ts +14 -0
  66. package/layers/base/server/middleware/2.mfa.ts +34 -0
  67. package/layers/base/server/middleware/3.admin.ts +4 -0
  68. package/layers/base/server/plugins/session.ts +9 -0
  69. package/layers/base/server/routes/auth/microsoft.get.ts +21 -0
  70. package/layers/base/server/routes/dev/email/[template].ts +15 -0
  71. package/layers/base/server/tasks/backup.ts +42 -0
  72. package/layers/base/server/tasks/clean.ts +39 -0
  73. package/layers/base/server/utils/access.ts +8 -0
  74. package/layers/base/server/utils/auth.ts +207 -0
  75. package/layers/base/server/utils/backup.ts +98 -0
  76. package/layers/base/server/utils/db.ts +3 -0
  77. package/layers/base/server/utils/email.ts +138 -0
  78. package/layers/base/server/utils/files.ts +751 -0
  79. package/layers/base/server/utils/ip.ts +15 -0
  80. package/layers/base/server/utils/log.ts +31 -0
  81. package/layers/base/server/utils/rateLimit.ts +69 -0
  82. package/layers/base/server/utils/schemas.ts +8 -0
  83. package/layers/base/server/utils/text.ts +4 -0
  84. package/layers/base/server/utils/time.ts +3 -0
  85. package/layers/base/shared/types/auth.d.ts +22 -0
  86. package/layers/base/shared/utils/logs.ts +9 -0
  87. package/layers/base/shared/utils/url.ts +5 -0
  88. package/locales/en.json +33 -0
  89. package/locales/fr.json +33 -0
  90. package/locales/pages/admin-logs/en.json +1 -0
  91. package/locales/pages/admin-logs/fr.json +1 -0
  92. package/locales/pages/index/en.json +1 -0
  93. package/locales/pages/index/fr.json +1 -0
  94. package/locales/pages/signin/en.json +5 -0
  95. package/locales/pages/signin/fr.json +5 -0
  96. package/locales/pages/user-auth/en.json +22 -0
  97. package/locales/pages/user-auth/fr.json +22 -0
  98. package/nuxt.config.ts +54 -0
  99. package/package.json +92 -4
  100. package/scripts/db/migrate.ts +9 -0
  101. package/scripts/db/push-server.sh +45 -0
  102. package/scripts/db/restore.sh +26 -0
  103. package/scripts/deploy.sh +34 -0
  104. package/scripts/env.sh +20 -0
  105. package/scripts/remote/switch.sh +48 -0
  106. package/scripts/rollback.sh +12 -0
  107. package/server/assets/emails/base.html +43 -0
  108. package/server/assets/emails/en/base.html +1 -0
  109. package/server/assets/emails/en/emailChange.html +4 -0
  110. package/server/assets/emails/en/emailChanged.html +2 -0
  111. package/server/assets/emails/en/otp.html +10 -0
  112. package/server/assets/emails/fr/base.html +1 -0
  113. package/server/assets/emails/fr/emailChange.html +4 -0
  114. package/server/assets/emails/fr/emailChanged.html +2 -0
  115. package/server/assets/emails/fr/otp.html +10 -0
@@ -0,0 +1,751 @@
1
+ import { join, dirname, basename } from 'path'
2
+ import {
3
+ mkdir,
4
+ readdir,
5
+ writeFile,
6
+ stat,
7
+ rm,
8
+ appendFile,
9
+ rename,
10
+ readFile as fsReadFile,
11
+ } from 'fs/promises'
12
+ import {
13
+ S3Client,
14
+ PutObjectCommand,
15
+ DeleteObjectCommand,
16
+ DeleteObjectsCommand,
17
+ ListObjectsV2Command,
18
+ GetObjectCommand,
19
+ CopyObjectCommand,
20
+ CreateMultipartUploadCommand,
21
+ UploadPartCommand,
22
+ CompleteMultipartUploadCommand,
23
+ AbortMultipartUploadCommand,
24
+ } from '@aws-sdk/client-s3'
25
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
26
+ import type { CompletedPart } from '@aws-sdk/client-s3'
27
+ import { checkFileAccess } from '#database/access'
28
+ import { createReadStream, createWriteStream } from 'fs'
29
+ import { pipeline } from 'stream/promises'
30
+ import type { Readable } from 'stream'
31
+ import type { H3Event, MultiPartData } from 'h3'
32
+ import { createHash } from 'crypto'
33
+
34
+ export async function uploadFile(
35
+ event: H3Event,
36
+ file: MultiPartData,
37
+ path = 'files',
38
+ isPrivate = true,
39
+ ) {
40
+ await checkFileAccess(event, path)
41
+ path = getSecurePath(path, isPrivate)
42
+ if (file.filename && !/[^/]\.[^.]+$/.test(path) && basename(path) !== basename(file.filename)) {
43
+ path = join(path, basename(file.filename))
44
+ }
45
+
46
+ let url
47
+ if (useS3()) {
48
+ const s3Key = getS3Key(path)
49
+ url = await uploadToS3(
50
+ s3Key,
51
+ file.data,
52
+ file.type || 'application/octet-stream',
53
+ undefined,
54
+ isPrivate,
55
+ )
56
+ } else {
57
+ const filePath = join(process.cwd(), path)
58
+ const folderPath = filePath.replace(/\/[^/]*$/, '')
59
+ await mkdir(folderPath, { recursive: true })
60
+ await writeFile(filePath, file.data)
61
+
62
+ url = getFileURL(path, isPrivate)
63
+ }
64
+ return url
65
+ }
66
+
67
+ // Same as uploadFile but for a file the server produces itself
68
+ export async function uploadStream(
69
+ event: H3Event,
70
+ file: { stream: Readable; filename?: string; type?: string },
71
+ path = 'files',
72
+ isPrivate = true,
73
+ ) {
74
+ await checkFileAccess(event, path)
75
+ path = getSecurePath(path, isPrivate)
76
+ if (file.filename && !/[^/]\.[^.]+$/.test(path) && basename(path) !== basename(file.filename)) {
77
+ path = join(path, basename(file.filename))
78
+ }
79
+
80
+ if (useS3()) {
81
+ return uploadStreamToS3(
82
+ getS3Key(path),
83
+ file.stream,
84
+ file.type || 'application/octet-stream',
85
+ undefined,
86
+ isPrivate,
87
+ )
88
+ }
89
+
90
+ const filePath = join(process.cwd(), path)
91
+ await mkdir(dirname(filePath), { recursive: true })
92
+
93
+ const tmpPath = `${filePath}.${crypto.randomUUID()}.part`
94
+ try {
95
+ await pipeline(file.stream, createWriteStream(tmpPath))
96
+ await rename(tmpPath, filePath)
97
+ } catch (error) {
98
+ await rm(tmpPath, { force: true })
99
+ throw error
100
+ }
101
+
102
+ return getFileURL(path, isPrivate)
103
+ }
104
+
105
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
106
+
107
+ function isUUID(value: string) {
108
+ return UUID_REGEX.test(value)
109
+ }
110
+
111
+ export async function uploadChunk(
112
+ event: H3Event,
113
+ {
114
+ uploadId,
115
+ chunkIndex,
116
+ totalChunks,
117
+ filename,
118
+ type,
119
+ chunk,
120
+ path,
121
+ isPrivate,
122
+ parts,
123
+ }: {
124
+ uploadId?: string
125
+ chunkIndex: number
126
+ totalChunks: number
127
+ filename: string
128
+ type: string
129
+ chunk: Buffer
130
+ path: string
131
+ isPrivate: boolean
132
+ parts: CompletedPart[]
133
+ },
134
+ ) {
135
+ if (chunkIndex < 0 || chunkIndex >= totalChunks || totalChunks < 1) {
136
+ throw createError({ statusCode: 400, message: 'Invalid chunk index' })
137
+ }
138
+
139
+ await checkFileAccess(event, path)
140
+ path = getSecurePath(path, isPrivate)
141
+ if (filename && !/[^/]\.[^.]+$/.test(path) && basename(path) !== basename(filename)) {
142
+ path = join(path, basename(filename))
143
+ }
144
+
145
+ if (useS3()) {
146
+ if (!uploadId) {
147
+ uploadId = (await initMultipartUploadToS3(getS3Key(path), type, isPrivate))!
148
+ parts = []
149
+ }
150
+
151
+ if (parts.length < totalChunks) {
152
+ const part = await uploadPartToS3(getS3Key(path), chunk, uploadId!, chunkIndex + 1, isPrivate)
153
+ if (part) parts.push(part)
154
+ if (parts.length < totalChunks) {
155
+ return { uploadId, parts }
156
+ }
157
+ }
158
+
159
+ return completeMultipartUploadToS3(getS3Key(path), uploadId!, parts, isPrivate)
160
+ }
161
+
162
+ if (!uploadId) {
163
+ uploadId = crypto.randomUUID()
164
+ parts = []
165
+ } else if (!isUUID(uploadId)) {
166
+ throw createError({ statusCode: 400, message: 'Invalid upload id' })
167
+ }
168
+
169
+ const tmpDir = join(process.cwd(), 'files', 'temp', uploadId)
170
+ await mkdir(tmpDir, { recursive: true })
171
+ await writeFile(join(tmpDir, String(chunkIndex)), chunk)
172
+
173
+ const receivedChunks = await readdir(tmpDir)
174
+ if (receivedChunks.length < totalChunks) {
175
+ parts = receivedChunks.map((_file, index) => ({
176
+ PartNumber: index + 1,
177
+ }))
178
+ return { uploadId, parts }
179
+ }
180
+
181
+ const fullPath = join(process.cwd(), path)
182
+ await mkdir(dirname(fullPath), { recursive: true })
183
+ for (let i = 0; i < totalChunks; i++) {
184
+ const data = await fsReadFile(join(tmpDir, String(i)))
185
+ if (i === 0) await writeFile(fullPath, data)
186
+ else await appendFile(fullPath, data)
187
+ }
188
+ await rm(tmpDir, { recursive: true, force: true })
189
+
190
+ return getFileURL(path, isPrivate)
191
+ }
192
+
193
+ export function getFileURL(path: string, isPrivate = true) {
194
+ const config = useRuntimeConfig()
195
+ const url = removeRoot(path, isPrivate)
196
+
197
+ if (isPrivate) {
198
+ return config.public.url + join('/', 'api/files', url) + '?isPrivate=true'
199
+ }
200
+ return config.public.files.url + join('/', url)
201
+ }
202
+
203
+ export function parseRangeHeader(range: string | undefined, size: number) {
204
+ if (!range) return null
205
+
206
+ const match = /^bytes=(\d*)-(\d*)$/.exec(range.trim())
207
+ if (!match) return null
208
+
209
+ const [, startRaw, endRaw] = match
210
+ let start: number
211
+ let end: number
212
+
213
+ if (startRaw) {
214
+ start = Number(startRaw)
215
+ end = endRaw ? Math.min(Number(endRaw), size - 1) : size - 1
216
+ } else {
217
+ // `bytes=-N` asks for the last N bytes
218
+ if (!endRaw || Number(endRaw) === 0) return 'unsatisfiable' as const
219
+ start = Math.max(size - Number(endRaw), 0)
220
+ end = size - 1
221
+ }
222
+
223
+ if (start > end || start >= size) return 'unsatisfiable' as const
224
+
225
+ return { start, end }
226
+ }
227
+
228
+ export async function getFile(
229
+ event: H3Event,
230
+ path: string,
231
+ isPrivate = false,
232
+ cache: RequestCache = 'no-cache',
233
+ expireIn?: number,
234
+ redirect?: boolean,
235
+ ) {
236
+ await checkFileAccess(event, path)
237
+ path = getSecurePath(path, isPrivate)
238
+
239
+ const range = getRequestHeader(event, 'range')
240
+
241
+ if (useS3()) {
242
+ const url = await getS3SignedUrl(getS3Key(path), expireIn, isPrivate)
243
+
244
+ if (redirect ?? useRuntimeConfig().s3.redirect) {
245
+ setHeader(event, 'Cache-Control', 'no-store')
246
+ return sendRedirect(event, url, 302)
247
+ }
248
+
249
+ const response = await fetch(url, { cache, headers: range ? { range } : undefined })
250
+
251
+ if (response.status === 416) {
252
+ const contentRange = response.headers.get('content-range')
253
+ if (contentRange) setHeader(event, 'Content-Range', contentRange)
254
+ throw createError({ statusCode: 416, message: 'Range not satisfiable' })
255
+ }
256
+ if (!response.ok) throw createError({ statusCode: 502, message: 'Failed to fetch file' })
257
+
258
+ const passthrough = {
259
+ 'Content-Type': response.headers.get('content-type'),
260
+ 'Content-Length': response.headers.get('content-length'),
261
+ 'Content-Range': response.headers.get('content-range'),
262
+ 'Accept-Ranges': response.headers.get('accept-ranges') || 'bytes',
263
+ }
264
+ for (const [name, value] of Object.entries(passthrough)) {
265
+ if (value) setHeader(event, name, value)
266
+ }
267
+ if (response.status === 206) setResponseStatus(event, 206)
268
+
269
+ return response.body
270
+ }
271
+
272
+ const fullPath = join(process.cwd(), path)
273
+
274
+ const stats = await stat(fullPath).catch(() => null)
275
+ if (!stats?.isFile()) {
276
+ throw createError({ statusCode: 404, message: 'File not found' })
277
+ }
278
+
279
+ setHeader(event, 'Accept-Ranges', 'bytes')
280
+
281
+ const parsedRange = parseRangeHeader(range, stats.size)
282
+ if (parsedRange === 'unsatisfiable') {
283
+ setHeader(event, 'Content-Range', `bytes */${stats.size}`)
284
+ throw createError({ statusCode: 416, message: 'Range not satisfiable' })
285
+ }
286
+
287
+ if (parsedRange) {
288
+ const { start, end } = parsedRange
289
+ setResponseStatus(event, 206)
290
+ setHeader(event, 'Content-Range', `bytes ${start}-${end}/${stats.size}`)
291
+ setHeader(event, 'Content-Length', end - start + 1)
292
+ return createReadStream(fullPath, { start, end })
293
+ }
294
+
295
+ setHeader(event, 'Content-Length', stats.size)
296
+ return createReadStream(fullPath)
297
+ }
298
+
299
+ export async function deletePath(event: H3Event, path: string, isPrivate = true) {
300
+ await checkFileAccess(event, path)
301
+ path = getSecurePath(path, isPrivate)
302
+ if (useS3()) await deleteFromS3(path, isPrivate)
303
+ else {
304
+ const localPath = join(process.cwd(), path)
305
+ await rm(localPath, { recursive: true, force: true })
306
+ const config = useRuntimeConfig()
307
+ const root = isPrivate ? config.filesPrivateFolder : config.filesPublicFolder
308
+ await deleteEmptyFolder(dirname(localPath), root)
309
+ }
310
+ }
311
+
312
+ async function deleteEmptyFolder(directory: string, root: string) {
313
+ try {
314
+ if (directory === join(process.cwd(), root)) return
315
+ const files = await readdir(directory)
316
+ if (files.length > 0) return
317
+
318
+ await rm(directory, { recursive: true })
319
+ await deleteEmptyFolder(dirname(directory), root)
320
+ } catch {}
321
+ }
322
+
323
+ type ListedFile = {
324
+ name: string
325
+ size: number
326
+ updatedAt: Date
327
+ path: string
328
+ url: string
329
+ }
330
+
331
+ export async function listFolder(
332
+ event: H3Event,
333
+ path: string,
334
+ isPrivate = true,
335
+ ): Promise<ListedFile[]> {
336
+ await checkFileAccess(event, path)
337
+ path = getSecurePath(path, isPrivate)
338
+
339
+ if (useS3()) return await listFromS3(path, isPrivate)
340
+ const localPath = join(process.cwd(), path)
341
+ const filesStats = await readdir(localPath).catch(() => [])
342
+ return (
343
+ await Promise.all(
344
+ filesStats.map(async (name) => {
345
+ const localeFilePath = join(localPath, name)
346
+ const stats = await stat(localeFilePath)
347
+ const rootedPath = join(path, name)
348
+ const filePath = removeRoot(rootedPath, isPrivate)
349
+ return stats.isDirectory()
350
+ ? await listFolder(event, filePath, isPrivate)
351
+ : {
352
+ name,
353
+ size: stats.size,
354
+ updatedAt: stats.mtime,
355
+ path: filePath,
356
+ url: getFileURL(rootedPath, isPrivate),
357
+ }
358
+ }),
359
+ )
360
+ ).flat()
361
+ }
362
+
363
+ export async function copyFile(
364
+ event: H3Event,
365
+ src: string,
366
+ dest: string,
367
+ isPrivate = true,
368
+ deleteSrc = false,
369
+ ) {
370
+ await checkFileAccess(event, src)
371
+ await checkFileAccess(event, dest)
372
+ src = getSecurePath(src, isPrivate)
373
+ dest = getSecurePath(dest, isPrivate)
374
+
375
+ if (useS3()) return copyFromS3(src, dest, isPrivate, deleteSrc)
376
+
377
+ const srcPath = join(process.cwd(), src)
378
+ const destPath = join(process.cwd(), dest)
379
+ await mkdir(dirname(destPath), { recursive: true })
380
+ await writeFile(destPath, createReadStream(srcPath))
381
+
382
+ if (deleteSrc) {
383
+ await deletePath(event, removeRoot(src, isPrivate), isPrivate)
384
+ }
385
+ }
386
+
387
+ export async function copyFiles(
388
+ event: H3Event,
389
+ src: string,
390
+ dest: string,
391
+ isPrivate = true,
392
+ deleteSrc = false,
393
+ ) {
394
+ const files = await listFolder(event, src, isPrivate)
395
+ const normalizedSrc = removeRoot(getSecurePath(src, isPrivate), isPrivate)
396
+ const normalizedDest = removeRoot(getSecurePath(dest, isPrivate), isPrivate)
397
+ return Promise.all(
398
+ files.map((file) =>
399
+ copyFile(
400
+ event,
401
+ file.path,
402
+ join(normalizedDest, file.path.substring(normalizedSrc.length + 1)),
403
+ isPrivate,
404
+ deleteSrc,
405
+ ),
406
+ ),
407
+ )
408
+ }
409
+
410
+ export function getSecurePath(path: string, isPrivate = true) {
411
+ const config = useRuntimeConfig()
412
+ const root = isPrivate ? config.filesPrivateFolder : config.filesPublicFolder
413
+ let normalizedPath = path.replace(/^\/+/, '')
414
+
415
+ if (normalizedPath.split('/').some((segment) => segment === '..' || segment === '.')) {
416
+ throw createError({ statusCode: 400, message: 'Invalid path' })
417
+ }
418
+
419
+ normalizedPath = join('/', root, removeRoot(normalizedPath, isPrivate))
420
+
421
+ return normalizedPath.replace(/^\//, '').replace(/\/$/, '')
422
+ }
423
+
424
+ export function removeRoot(path: string, isPrivate = true) {
425
+ const config = useRuntimeConfig()
426
+ const root = isPrivate ? config.filesPrivateFolder : config.filesPublicFolder
427
+ return path.replace(new RegExp(`^${root}/`), '')
428
+ }
429
+
430
+ // S3
431
+
432
+ let s3Client: S3Client | null = null
433
+
434
+ export const useS3 = () => {
435
+ const config = useRuntimeConfig()
436
+
437
+ if (!s3Client && config.s3.endpoint && config.s3.accessKeyId) {
438
+ s3Client = new S3Client({
439
+ region: config.s3.region,
440
+ endpoint: config.s3.endpoint,
441
+ credentials: {
442
+ accessKeyId: config.s3.accessKeyId,
443
+ secretAccessKey: config.s3.secretAccessKey,
444
+ },
445
+ forcePathStyle: true,
446
+ })
447
+ }
448
+ return s3Client
449
+ }
450
+
451
+ export async function uploadToS3(
452
+ key: string,
453
+ body: Buffer,
454
+ contentType: string,
455
+ cache = 'public, max-age=31536000',
456
+ isPrivate = true,
457
+ ) {
458
+ const client = useS3()
459
+ if (!client) return null
460
+
461
+ const config = useRuntimeConfig()
462
+
463
+ if (isPrivate) {
464
+ await client.send(
465
+ new PutObjectCommand({
466
+ Bucket: config.s3.privateBucket,
467
+ Key: key,
468
+ Body: body,
469
+ ContentType: contentType,
470
+ CacheControl: cache || 'public, max-age=31536000',
471
+ }),
472
+ )
473
+
474
+ return getS3SignedUrl(key)
475
+ }
476
+ await client.send(
477
+ new PutObjectCommand({
478
+ Bucket: config.s3.publicBucket,
479
+ Key: key,
480
+ Body: body,
481
+ ContentType: contentType,
482
+ CacheControl: cache || 'public, max-age=31536000',
483
+ ACL: 'public-read',
484
+ }),
485
+ )
486
+
487
+ return getS3URL(key)
488
+ }
489
+
490
+ export async function uploadStreamToS3(
491
+ key: string,
492
+ body: Readable,
493
+ contentType: string,
494
+ cache = 'public, max-age=31536000',
495
+ isPrivate = true,
496
+ ) {
497
+ const client = useS3()
498
+ if (!client) return null
499
+
500
+ const config = useRuntimeConfig()
501
+ const partSize = 5 * 1024 * 1024 // S3 minimum, only the last part may be smaller
502
+
503
+ let uploadId: string | undefined
504
+ const parts: CompletedPart[] = []
505
+ let pending: Buffer[] = []
506
+ let pendingSize = 0
507
+
508
+ const sendPart = async (part: Buffer) => {
509
+ if (!uploadId) {
510
+ uploadId = (await initMultipartUploadToS3(key, contentType, isPrivate, cache))!
511
+ }
512
+ const uploaded = await uploadPartToS3(key, part, uploadId, parts.length + 1, isPrivate)
513
+ if (uploaded) parts.push(uploaded)
514
+ }
515
+
516
+ try {
517
+ for await (const chunk of body) {
518
+ pending.push(chunk)
519
+ pendingSize += chunk.length
520
+
521
+ while (pendingSize >= partSize) {
522
+ const merged = Buffer.concat(pending, pendingSize)
523
+ await sendPart(merged.subarray(0, partSize))
524
+ pending = [merged.subarray(partSize)]
525
+ pendingSize = merged.length - partSize
526
+ }
527
+ }
528
+
529
+ // Never reached a full part, so there is nothing to assemble.
530
+ if (!uploadId) {
531
+ return uploadToS3(key, Buffer.concat(pending, pendingSize), contentType, cache, isPrivate)
532
+ }
533
+
534
+ if (pendingSize) await sendPart(Buffer.concat(pending, pendingSize))
535
+
536
+ return completeMultipartUploadToS3(key, uploadId, parts, isPrivate)
537
+ } catch (error) {
538
+ if (uploadId) {
539
+ await client
540
+ .send(
541
+ new AbortMultipartUploadCommand({
542
+ Bucket: isPrivate ? config.s3.privateBucket : config.s3.publicBucket,
543
+ Key: key,
544
+ UploadId: uploadId,
545
+ }),
546
+ )
547
+ .catch(() => {})
548
+ }
549
+ throw error
550
+ }
551
+ }
552
+
553
+ export async function initMultipartUploadToS3(
554
+ key: string,
555
+ contentType: string,
556
+ isPrivate = true,
557
+ cache?: string,
558
+ ) {
559
+ const client = useS3()
560
+ if (!client) return null
561
+
562
+ const config = useRuntimeConfig()
563
+
564
+ const command = new CreateMultipartUploadCommand({
565
+ Bucket: isPrivate ? config.s3.privateBucket : config.s3.publicBucket,
566
+ Key: key,
567
+ ContentType: contentType,
568
+ CacheControl: cache,
569
+ ACL: isPrivate ? undefined : 'public-read',
570
+ })
571
+
572
+ const { UploadId } = await client.send(command)
573
+ return UploadId
574
+ }
575
+
576
+ export async function uploadPartToS3(
577
+ key: string,
578
+ body: Buffer,
579
+ uploadId: string,
580
+ partNumber: number,
581
+ isPrivate = true,
582
+ ) {
583
+ const client = useS3()
584
+ if (!client) return null
585
+
586
+ const config = useRuntimeConfig()
587
+
588
+ const response = await client.send(
589
+ new UploadPartCommand({
590
+ Bucket: isPrivate ? config.s3.privateBucket : config.s3.publicBucket,
591
+ Key: key,
592
+ Body: body,
593
+ UploadId: uploadId,
594
+ PartNumber: partNumber,
595
+ }),
596
+ )
597
+
598
+ return { ETag: response.ETag, PartNumber: partNumber }
599
+ }
600
+
601
+ export async function completeMultipartUploadToS3(
602
+ key: string,
603
+ uploadId: string,
604
+ parts: CompletedPart[],
605
+ isPrivate = true,
606
+ ) {
607
+ const client = useS3()
608
+ if (!client) return null
609
+
610
+ const config = useRuntimeConfig()
611
+
612
+ const command = new CompleteMultipartUploadCommand({
613
+ Bucket: isPrivate ? config.s3.privateBucket : config.s3.publicBucket,
614
+ Key: key,
615
+ UploadId: uploadId,
616
+ MultipartUpload: {
617
+ Parts: parts,
618
+ },
619
+ })
620
+
621
+ await client.send(command)
622
+ return isPrivate ? getS3SignedUrl(key) : getS3URL(key)
623
+ }
624
+
625
+ export async function deleteFromS3(path: string, isPrivate = true) {
626
+ const client = useS3()
627
+ if (!client) return
628
+
629
+ const config = useRuntimeConfig()
630
+ const bucket = isPrivate ? config.s3.privateBucket : config.s3.publicBucket
631
+
632
+ let ContinuationToken: string | undefined
633
+ do {
634
+ const response = await client.send(
635
+ new ListObjectsV2Command({ Bucket: bucket, Prefix: path, ContinuationToken }),
636
+ )
637
+ ContinuationToken = response.NextContinuationToken
638
+
639
+ const keys = (response.Contents || [])
640
+ .filter((item) => item.Key === path || item.Key?.startsWith(`${path}/`))
641
+ .map((item) => ({ Key: item.Key! }))
642
+
643
+ if (keys.length) {
644
+ const command = new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: keys } })
645
+ // Infomaniak (OpenStack Swift) requires Content-MD5 instead of the SDK's default CRC32
646
+ command.middlewareStack.addRelativeTo(
647
+ (next: any) => async (args: any) => {
648
+ const headers = args.request.headers
649
+ for (const name of Object.keys(headers)) {
650
+ if (name.startsWith('x-amz-checksum-') || name === 'x-amz-sdk-checksum-algorithm') {
651
+ delete headers[name]
652
+ }
653
+ }
654
+ headers['content-md5'] = createHash('md5').update(args.request.body).digest('base64')
655
+ return next(args)
656
+ },
657
+ { relation: 'after', toMiddleware: 'flexibleChecksumsMiddleware' },
658
+ )
659
+ const result = await client.send(command)
660
+ if (result.Errors?.length) {
661
+ throw createError({
662
+ statusCode: 502,
663
+ message: `Failed to delete ${result.Errors.length} file(s): ${result.Errors.map((e) => `${e.Key} (${e.Code})`).join(', ')}`,
664
+ })
665
+ }
666
+ }
667
+ } while (ContinuationToken)
668
+ }
669
+
670
+ export async function listFromS3(path: string, isPrivate = true) {
671
+ const client = useS3()
672
+ if (!client) return []
673
+
674
+ const config = useRuntimeConfig()
675
+
676
+ let ContinuationToken: string | undefined
677
+ const files = []
678
+ do {
679
+ const response = await client.send(
680
+ new ListObjectsV2Command({
681
+ Bucket: isPrivate ? config.s3.privateBucket : config.s3.publicBucket,
682
+ Prefix: path,
683
+ ContinuationToken,
684
+ }),
685
+ )
686
+ ContinuationToken = response.NextContinuationToken
687
+ files.push(
688
+ ...(await Promise.all(
689
+ (response.Contents || [])
690
+ .filter((item) => item.Key === path || item.Key?.startsWith(`${path}/`))
691
+ .map(async (item) => ({
692
+ path: removeRoot(item.Key || '', isPrivate),
693
+ name: item.Key?.split('/').pop() || '',
694
+ url: isPrivate ? await getS3SignedUrl(item.Key || '') : getS3URL(item.Key || ''),
695
+ updatedAt: item.LastModified ?? new Date(0),
696
+ size: item.Size || 0,
697
+ })),
698
+ )),
699
+ )
700
+ } while (ContinuationToken)
701
+
702
+ return files
703
+ }
704
+
705
+ export async function copyFromS3(src: string, dest: string, isPrivate = true, deleteSrc = false) {
706
+ const client = useS3()
707
+ if (!client) return
708
+
709
+ const config = useRuntimeConfig()
710
+ const bucket = isPrivate ? config.s3.privateBucket : config.s3.publicBucket
711
+ const copySource = `/${bucket}/${src}`.split('/').map(encodeURIComponent).join('/')
712
+ await client.send(
713
+ new CopyObjectCommand({
714
+ Bucket: bucket,
715
+ Key: dest,
716
+ CopySource: copySource,
717
+ }),
718
+ )
719
+ if (deleteSrc) {
720
+ await client.send(
721
+ new DeleteObjectCommand({
722
+ Bucket: bucket,
723
+ Key: src,
724
+ }),
725
+ )
726
+ }
727
+ }
728
+
729
+ export function getS3Key(path: string) {
730
+ const key = path.replace(/\\/g, '/')
731
+ return key.startsWith('/') ? key.substring(1) : key
732
+ }
733
+
734
+ function getS3URL(path: string) {
735
+ const config = useRuntimeConfig()
736
+ return `${config.public.files.url}/${getS3Key(path)}`
737
+ }
738
+
739
+ async function getS3SignedUrl(key: string, expiresIn = 3600, isPrivate = true): Promise<string> {
740
+ const client = useS3()
741
+ if (!client) return ''
742
+
743
+ const config = useRuntimeConfig()
744
+
745
+ const command = new GetObjectCommand({
746
+ Bucket: isPrivate ? config.s3.privateBucket : config.s3.publicBucket,
747
+ Key: getS3Key(key),
748
+ })
749
+
750
+ return await getSignedUrl(client, command, { expiresIn })
751
+ }