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.
- package/README.md +269 -2
- package/docker/Dockerfile +22 -0
- package/docker/Dockerfile.dockerignore +25 -0
- package/docker/compose.yaml +19 -0
- package/layers/base/app/app.vue +22 -0
- package/layers/base/app/components/AuthCheck.vue +316 -0
- package/layers/base/app/components/DataExplorer.vue +38 -0
- package/layers/base/app/components/LocaleSwitcher.vue +28 -0
- package/layers/base/app/components/TheConfirmation.vue +48 -0
- package/layers/base/app/components/TheLoading.vue +36 -0
- package/layers/base/app/components/TheNotifications.vue +71 -0
- package/layers/base/app/components/files/FilesDelete.vue +33 -0
- package/layers/base/app/components/files/FilesUpload.vue +85 -0
- package/layers/base/app/error.vue +28 -0
- package/layers/base/app/layouts/default.vue +15 -0
- package/layers/base/app/layouts/simple.vue +3 -0
- package/layers/base/app/middleware/admin.ts +15 -0
- package/layers/base/app/middleware/authenticated.ts +10 -0
- package/layers/base/app/middleware/mfa.global.ts +8 -0
- package/layers/base/app/pages/admin/logs.vue +364 -0
- package/layers/base/app/pages/signin.vue +121 -0
- package/layers/base/app/pages/user/auth.vue +509 -0
- package/layers/base/app/plugins/error.ts +21 -0
- package/layers/base/app/plugins/session.ts +22 -0
- package/layers/base/app/stores/app.ts +100 -0
- package/layers/base/app/utils/clipboard.ts +6 -0
- package/layers/base/app/utils/files.ts +95 -0
- package/layers/base/app/utils/image.ts +33 -0
- package/layers/base/nuxt.config.ts +146 -0
- package/layers/base/public/images/auth/microsoft.svg +1 -0
- package/layers/base/public/robots.txt +2 -0
- package/layers/base/server/api/admin/logs.get.ts +49 -0
- package/layers/base/server/api/auth/anonymous.post.ts +31 -0
- package/layers/base/server/api/auth/email/get.post.ts +46 -0
- package/layers/base/server/api/auth/email/verify.post.ts +46 -0
- package/layers/base/server/api/auth/index.get.ts +10 -0
- package/layers/base/server/api/auth/index.post.ts +19 -0
- package/layers/base/server/api/auth/options.post.ts +26 -0
- package/layers/base/server/api/auth/otp/get.post.ts +74 -0
- package/layers/base/server/api/auth/otp/verify.post.ts +70 -0
- package/layers/base/server/api/auth/password.post.ts +42 -0
- package/layers/base/server/api/auth/refresh.delete.ts +4 -0
- package/layers/base/server/api/auth/refresh.post.ts +5 -0
- package/layers/base/server/api/auth/totp/confirm.post.ts +26 -0
- package/layers/base/server/api/auth/totp/disable.post.ts +33 -0
- package/layers/base/server/api/auth/totp/generate.get.ts +6 -0
- package/layers/base/server/api/auth/totp/verify.post.ts +36 -0
- package/layers/base/server/api/auth/webauthn/authenticate.post.ts +39 -0
- package/layers/base/server/api/auth/webauthn/index.delete.ts +43 -0
- package/layers/base/server/api/auth/webauthn/index.post.ts +17 -0
- package/layers/base/server/api/auth/webauthn/register.post.ts +49 -0
- package/layers/base/server/api/files/[...path].get.ts +37 -0
- package/layers/base/server/api/files/chunk.post.ts +36 -0
- package/layers/base/server/api/files/copy.post.ts +22 -0
- package/layers/base/server/api/files/index.delete.ts +12 -0
- package/layers/base/server/api/files/index.get.ts +19 -0
- package/layers/base/server/api/files/index.post.ts +15 -0
- package/layers/base/server/api/log.post.ts +20 -0
- package/layers/base/server/database/defaults/access.ts +14 -0
- package/layers/base/server/database/defaults/db.ts +5 -0
- package/layers/base/server/database/defaults/relations.ts +5 -0
- package/layers/base/server/database/relations.ts +35 -0
- package/layers/base/server/database/schema.ts +66 -0
- package/layers/base/server/middleware/0.rateLimit.ts +21 -0
- package/layers/base/server/middleware/1.session.ts +14 -0
- package/layers/base/server/middleware/2.mfa.ts +34 -0
- package/layers/base/server/middleware/3.admin.ts +4 -0
- package/layers/base/server/plugins/session.ts +9 -0
- package/layers/base/server/routes/auth/microsoft.get.ts +21 -0
- package/layers/base/server/routes/dev/email/[template].ts +15 -0
- package/layers/base/server/tasks/backup.ts +42 -0
- package/layers/base/server/tasks/clean.ts +39 -0
- package/layers/base/server/utils/access.ts +8 -0
- package/layers/base/server/utils/auth.ts +207 -0
- package/layers/base/server/utils/backup.ts +98 -0
- package/layers/base/server/utils/db.ts +3 -0
- package/layers/base/server/utils/email.ts +138 -0
- package/layers/base/server/utils/files.ts +751 -0
- package/layers/base/server/utils/ip.ts +15 -0
- package/layers/base/server/utils/log.ts +31 -0
- package/layers/base/server/utils/rateLimit.ts +69 -0
- package/layers/base/server/utils/schemas.ts +8 -0
- package/layers/base/server/utils/text.ts +4 -0
- package/layers/base/server/utils/time.ts +3 -0
- package/layers/base/shared/types/auth.d.ts +22 -0
- package/layers/base/shared/utils/logs.ts +9 -0
- package/layers/base/shared/utils/url.ts +5 -0
- package/locales/en.json +33 -0
- package/locales/fr.json +33 -0
- package/locales/pages/admin-logs/en.json +1 -0
- package/locales/pages/admin-logs/fr.json +1 -0
- package/locales/pages/index/en.json +1 -0
- package/locales/pages/index/fr.json +1 -0
- package/locales/pages/signin/en.json +5 -0
- package/locales/pages/signin/fr.json +5 -0
- package/locales/pages/user-auth/en.json +22 -0
- package/locales/pages/user-auth/fr.json +22 -0
- package/nuxt.config.ts +54 -0
- package/package.json +92 -4
- package/scripts/db/migrate.ts +9 -0
- package/scripts/db/push-server.sh +45 -0
- package/scripts/db/restore.sh +26 -0
- package/scripts/deploy.sh +34 -0
- package/scripts/env.sh +20 -0
- package/scripts/remote/switch.sh +48 -0
- package/scripts/rollback.sh +12 -0
- package/server/assets/emails/base.html +43 -0
- package/server/assets/emails/en/base.html +1 -0
- package/server/assets/emails/en/emailChange.html +4 -0
- package/server/assets/emails/en/emailChanged.html +2 -0
- package/server/assets/emails/en/otp.html +10 -0
- package/server/assets/emails/fr/base.html +1 -0
- package/server/assets/emails/fr/emailChange.html +4 -0
- package/server/assets/emails/fr/emailChanged.html +2 -0
- 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
|
+
}
|