@mantiq/filesystem 0.0.1
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 +19 -0
- package/package.json +83 -0
- package/src/FilesystemManager.ts +177 -0
- package/src/FilesystemServiceProvider.ts +30 -0
- package/src/contracts/FilesystemConfig.ts +60 -0
- package/src/contracts/FilesystemDriver.ts +45 -0
- package/src/drivers/AzureBlobDriver.ts +377 -0
- package/src/drivers/FTPDriver.ts +299 -0
- package/src/drivers/GCSDriver.ts +311 -0
- package/src/drivers/LocalDriver.ts +281 -0
- package/src/drivers/NullDriver.ts +35 -0
- package/src/drivers/S3Driver.ts +449 -0
- package/src/drivers/SFTPDriver.ts +306 -0
- package/src/errors/FileExistsError.ts +7 -0
- package/src/errors/FileNotFoundError.ts +7 -0
- package/src/errors/FilesystemError.ts +7 -0
- package/src/helpers/mime.ts +61 -0
- package/src/helpers/storage.ts +13 -0
- package/src/index.ts +38 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import type { FilesystemDriver, PutOptions } from '../contracts/FilesystemDriver.ts'
|
|
2
|
+
import { FilesystemError } from '../errors/FilesystemError.ts'
|
|
3
|
+
import { FileNotFoundError } from '../errors/FileNotFoundError.ts'
|
|
4
|
+
import { guessMimeType } from '../helpers/mime.ts'
|
|
5
|
+
|
|
6
|
+
export interface AzureConfig {
|
|
7
|
+
container: string
|
|
8
|
+
connectionString?: string
|
|
9
|
+
accountName?: string
|
|
10
|
+
accountKey?: string
|
|
11
|
+
sasToken?: string
|
|
12
|
+
root?: string
|
|
13
|
+
url?: string
|
|
14
|
+
visibility?: 'public' | 'private'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Azure Blob Storage filesystem driver.
|
|
19
|
+
*
|
|
20
|
+
* Requires: bun add @azure/storage-blob
|
|
21
|
+
*/
|
|
22
|
+
export class AzureBlobDriver implements FilesystemDriver {
|
|
23
|
+
private _containerClient: any = null
|
|
24
|
+
private readonly containerName: string
|
|
25
|
+
private readonly prefix: string
|
|
26
|
+
private readonly urlBase: string | undefined
|
|
27
|
+
private readonly defaultVisibility: 'public' | 'private'
|
|
28
|
+
private readonly _config: AzureConfig
|
|
29
|
+
|
|
30
|
+
constructor(config: AzureConfig) {
|
|
31
|
+
this._config = config
|
|
32
|
+
this.containerName = config.container
|
|
33
|
+
this.prefix = config.root ? config.root.replace(/^\/+|\/+$/g, '') + '/' : ''
|
|
34
|
+
this.urlBase = config.url
|
|
35
|
+
this.defaultVisibility = config.visibility ?? 'private'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private async container(): Promise<any> {
|
|
39
|
+
if (!this._containerClient) {
|
|
40
|
+
let BlobServiceClient: any, StorageSharedKeyCredential: any
|
|
41
|
+
try {
|
|
42
|
+
;({ BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob'))
|
|
43
|
+
} catch {
|
|
44
|
+
throw new FilesystemError(
|
|
45
|
+
'The @azure/storage-blob package is required for the Azure driver. Install it with: bun add @azure/storage-blob',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let serviceClient: any
|
|
50
|
+
if (this._config.connectionString) {
|
|
51
|
+
serviceClient = BlobServiceClient.fromConnectionString(this._config.connectionString)
|
|
52
|
+
} else if (this._config.accountName && this._config.accountKey) {
|
|
53
|
+
const cred = new StorageSharedKeyCredential(this._config.accountName, this._config.accountKey)
|
|
54
|
+
serviceClient = new BlobServiceClient(`https://${this._config.accountName}.blob.core.windows.net`, cred)
|
|
55
|
+
} else if (this._config.accountName && this._config.sasToken) {
|
|
56
|
+
serviceClient = new BlobServiceClient(
|
|
57
|
+
`https://${this._config.accountName}.blob.core.windows.net?${this._config.sasToken}`,
|
|
58
|
+
)
|
|
59
|
+
} else {
|
|
60
|
+
throw new FilesystemError(
|
|
61
|
+
'Azure driver requires either connectionString, accountName+accountKey, or accountName+sasToken.',
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this._containerClient = serviceClient.getContainerClient(this.containerName)
|
|
66
|
+
}
|
|
67
|
+
return this._containerClient
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async blob(path: string): Promise<any> {
|
|
71
|
+
const c = await this.container()
|
|
72
|
+
return c.getBlockBlobClient(this.key(path))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private key(path: string): string {
|
|
76
|
+
return this.prefix + path.replace(/^\/+/, '')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private stripPrefix(key: string): string {
|
|
80
|
+
return this.prefix && key.startsWith(this.prefix)
|
|
81
|
+
? key.slice(this.prefix.length)
|
|
82
|
+
: key
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Reads ─────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async exists(path: string): Promise<boolean> {
|
|
88
|
+
const b = await this.blob(path)
|
|
89
|
+
return b.exists()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async get(path: string): Promise<string | null> {
|
|
93
|
+
try {
|
|
94
|
+
const b = await this.blob(path)
|
|
95
|
+
const res = await b.download()
|
|
96
|
+
return this.streamToString(res.readableStreamBody)
|
|
97
|
+
} catch (e: any) {
|
|
98
|
+
if (e.statusCode === 404) return null
|
|
99
|
+
throw new FilesystemError(`Azure get failed: ${e.message}`, { path })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async getBytes(path: string): Promise<Uint8Array | null> {
|
|
104
|
+
try {
|
|
105
|
+
const b = await this.blob(path)
|
|
106
|
+
const res = await b.download()
|
|
107
|
+
return this.streamToBytes(res.readableStreamBody)
|
|
108
|
+
} catch (e: any) {
|
|
109
|
+
if (e.statusCode === 404) return null
|
|
110
|
+
throw new FilesystemError(`Azure getBytes failed: ${e.message}`, { path })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async stream(path: string): Promise<ReadableStream | null> {
|
|
115
|
+
try {
|
|
116
|
+
const b = await this.blob(path)
|
|
117
|
+
if (!(await b.exists())) return null
|
|
118
|
+
const res = await b.download()
|
|
119
|
+
const body = res.readableStreamBody
|
|
120
|
+
if (!body) return null
|
|
121
|
+
if (body instanceof ReadableStream) return body
|
|
122
|
+
return new ReadableStream({
|
|
123
|
+
start(controller) {
|
|
124
|
+
body.on('data', (chunk: any) => controller.enqueue(new Uint8Array(chunk)))
|
|
125
|
+
body.on('end', () => controller.close())
|
|
126
|
+
body.on('error', (err: any) => controller.error(err))
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
} catch (e: any) {
|
|
130
|
+
if (e.statusCode === 404) return null
|
|
131
|
+
throw new FilesystemError(`Azure stream failed: ${e.message}`, { path })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Writes ────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
async put(path: string, contents: string | Uint8Array, options?: PutOptions): Promise<void> {
|
|
138
|
+
const b = await this.blob(path)
|
|
139
|
+
const body = typeof contents === 'string' ? Buffer.from(contents, 'utf-8') : Buffer.from(contents)
|
|
140
|
+
const contentType = options?.mimeType ?? guessMimeType(path) ?? 'application/octet-stream'
|
|
141
|
+
await b.upload(body, body.length, {
|
|
142
|
+
blobHTTPHeaders: { blobContentType: contentType },
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async putStream(path: string, stream: ReadableStream, options?: PutOptions): Promise<void> {
|
|
147
|
+
const body = new Uint8Array(await new Response(stream).arrayBuffer())
|
|
148
|
+
await this.put(path, body, options)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async append(path: string, contents: string): Promise<void> {
|
|
152
|
+
const existing = (await this.get(path)) ?? ''
|
|
153
|
+
await this.put(path, existing + contents)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async prepend(path: string, contents: string): Promise<void> {
|
|
157
|
+
const existing = (await this.get(path)) ?? ''
|
|
158
|
+
await this.put(path, contents + existing)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Operations ────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
async delete(path: string | string[]): Promise<boolean> {
|
|
164
|
+
const paths = Array.isArray(path) ? path : [path]
|
|
165
|
+
try {
|
|
166
|
+
for (const p of paths) {
|
|
167
|
+
const b = await this.blob(p)
|
|
168
|
+
await b.deleteIfExists()
|
|
169
|
+
}
|
|
170
|
+
return true
|
|
171
|
+
} catch {
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async copy(from: string, to: string): Promise<void> {
|
|
177
|
+
const src = await this.blob(from)
|
|
178
|
+
const dest = await this.blob(to)
|
|
179
|
+
const poller = await dest.beginCopyFromURL(src.url)
|
|
180
|
+
await poller.pollUntilDone()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async move(from: string, to: string): Promise<void> {
|
|
184
|
+
await this.copy(from, to)
|
|
185
|
+
await this.delete(from)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Metadata ──────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
async size(path: string): Promise<number> {
|
|
191
|
+
const props = await this.properties(path)
|
|
192
|
+
return props.contentLength ?? 0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async lastModified(path: string): Promise<number> {
|
|
196
|
+
const props = await this.properties(path)
|
|
197
|
+
return props.lastModified ? props.lastModified.getTime() : 0
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async mimeType(path: string): Promise<string | null> {
|
|
201
|
+
const props = await this.properties(path)
|
|
202
|
+
return props.contentType ?? null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
path(filePath: string): string {
|
|
206
|
+
return this.key(filePath)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── URLs ──────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
url(path: string): string {
|
|
212
|
+
if (this.urlBase) {
|
|
213
|
+
return `${this.urlBase.replace(/\/+$/, '')}/${this.key(path)}`
|
|
214
|
+
}
|
|
215
|
+
if (this._config.accountName) {
|
|
216
|
+
return `https://${this._config.accountName}.blob.core.windows.net/${this.containerName}/${this.key(path)}`
|
|
217
|
+
}
|
|
218
|
+
throw new FilesystemError('Cannot generate URL — no accountName or url configured.', { path })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async temporaryUrl(path: string, expiration: number, _options?: Record<string, any>): Promise<string> {
|
|
222
|
+
let generateBlobSASQueryParameters: any, BlobSASPermissions: any, SASProtocol: any, StorageSharedKeyCredential: any
|
|
223
|
+
try {
|
|
224
|
+
;({
|
|
225
|
+
generateBlobSASQueryParameters,
|
|
226
|
+
BlobSASPermissions,
|
|
227
|
+
SASProtocol,
|
|
228
|
+
StorageSharedKeyCredential,
|
|
229
|
+
} = await import('@azure/storage-blob'))
|
|
230
|
+
} catch {
|
|
231
|
+
throw new FilesystemError(
|
|
232
|
+
'The @azure/storage-blob package is required for temporaryUrl().',
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!this._config.accountName || !this._config.accountKey) {
|
|
237
|
+
throw new FilesystemError(
|
|
238
|
+
'temporaryUrl() requires accountName and accountKey for SAS token generation.',
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const cred = new StorageSharedKeyCredential(this._config.accountName, this._config.accountKey)
|
|
243
|
+
const sas = generateBlobSASQueryParameters({
|
|
244
|
+
containerName: this.containerName,
|
|
245
|
+
blobName: this.key(path),
|
|
246
|
+
permissions: BlobSASPermissions.parse('r'),
|
|
247
|
+
expiresOn: new Date(Date.now() + expiration * 1000),
|
|
248
|
+
protocol: SASProtocol.Https,
|
|
249
|
+
}, cred).toString()
|
|
250
|
+
|
|
251
|
+
return `${this.url(path)}?${sas}`
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Directories ───────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
async files(directory: string = ''): Promise<string[]> {
|
|
257
|
+
const prefix = this.directoryPrefix(directory)
|
|
258
|
+
const c = await this.container()
|
|
259
|
+
const results: string[] = []
|
|
260
|
+
|
|
261
|
+
for await (const item of c.listBlobsByHierarchy('/', { prefix })) {
|
|
262
|
+
if (item.kind !== 'prefix') {
|
|
263
|
+
results.push(this.stripPrefix(item.name))
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return results
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async allFiles(directory: string = ''): Promise<string[]> {
|
|
270
|
+
const prefix = this.directoryPrefix(directory)
|
|
271
|
+
const c = await this.container()
|
|
272
|
+
const results: string[] = []
|
|
273
|
+
|
|
274
|
+
for await (const blob of c.listBlobsFlat({ prefix })) {
|
|
275
|
+
if (!blob.name.endsWith('/')) {
|
|
276
|
+
results.push(this.stripPrefix(blob.name))
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return results.sort()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async directories(directory: string = ''): Promise<string[]> {
|
|
283
|
+
const prefix = this.directoryPrefix(directory)
|
|
284
|
+
const c = await this.container()
|
|
285
|
+
const results: string[] = []
|
|
286
|
+
|
|
287
|
+
for await (const item of c.listBlobsByHierarchy('/', { prefix })) {
|
|
288
|
+
if (item.kind === 'prefix') {
|
|
289
|
+
results.push(this.stripPrefix(item.name).replace(/\/$/, ''))
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return results
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async allDirectories(directory: string = ''): Promise<string[]> {
|
|
296
|
+
const allFiles = await this.allFiles(directory)
|
|
297
|
+
const dirs = new Set<string>()
|
|
298
|
+
for (const file of allFiles) {
|
|
299
|
+
const parts = file.split('/')
|
|
300
|
+
for (let i = 1; i < parts.length; i++) {
|
|
301
|
+
dirs.add(parts.slice(0, i).join('/'))
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return [...dirs].sort()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async makeDirectory(path: string): Promise<void> {
|
|
308
|
+
const b = await this.blob(path + '/')
|
|
309
|
+
await b.upload('', 0)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async deleteDirectory(directory: string): Promise<boolean> {
|
|
313
|
+
const prefix = this.directoryPrefix(directory)
|
|
314
|
+
const c = await this.container()
|
|
315
|
+
try {
|
|
316
|
+
for await (const blob of c.listBlobsFlat({ prefix })) {
|
|
317
|
+
await c.deleteBlob(blob.name)
|
|
318
|
+
}
|
|
319
|
+
return true
|
|
320
|
+
} catch {
|
|
321
|
+
return false
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Visibility ────────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
async setVisibility(path: string, visibility: 'public' | 'private'): Promise<void> {
|
|
328
|
+
// Azure manages access at the container level, not per-blob.
|
|
329
|
+
// Store visibility as blob metadata for API compatibility.
|
|
330
|
+
const b = await this.blob(path)
|
|
331
|
+
await b.setMetadata({ 'x-mantiq-visibility': visibility })
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async getVisibility(path: string): Promise<string> {
|
|
335
|
+
const b = await this.blob(path)
|
|
336
|
+
try {
|
|
337
|
+
const props = await b.getProperties()
|
|
338
|
+
return props.metadata?.['x-mantiq-visibility'] ?? this.defaultVisibility
|
|
339
|
+
} catch {
|
|
340
|
+
return this.defaultVisibility
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
private async properties(path: string): Promise<any> {
|
|
347
|
+
const b = await this.blob(path)
|
|
348
|
+
try {
|
|
349
|
+
return await b.getProperties()
|
|
350
|
+
} catch (e: any) {
|
|
351
|
+
if (e.statusCode === 404) throw new FileNotFoundError(path)
|
|
352
|
+
throw new FilesystemError(`Azure properties failed: ${e.message}`, { path })
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private directoryPrefix(directory: string): string {
|
|
357
|
+
return directory ? this.key(directory).replace(/\/$/, '') + '/' : this.prefix
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async streamToString(stream: any): Promise<string> {
|
|
361
|
+
if (!stream) return ''
|
|
362
|
+
const chunks: Buffer[] = []
|
|
363
|
+
for await (const chunk of stream) {
|
|
364
|
+
chunks.push(Buffer.from(chunk))
|
|
365
|
+
}
|
|
366
|
+
return Buffer.concat(chunks).toString('utf-8')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private async streamToBytes(stream: any): Promise<Uint8Array> {
|
|
370
|
+
if (!stream) return new Uint8Array(0)
|
|
371
|
+
const chunks: Buffer[] = []
|
|
372
|
+
for await (const chunk of stream) {
|
|
373
|
+
chunks.push(Buffer.from(chunk))
|
|
374
|
+
}
|
|
375
|
+
return new Uint8Array(Buffer.concat(chunks))
|
|
376
|
+
}
|
|
377
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { Writable, Readable } from 'node:stream'
|
|
2
|
+
import type { FilesystemDriver, PutOptions } from '../contracts/FilesystemDriver.ts'
|
|
3
|
+
import { FilesystemError } from '../errors/FilesystemError.ts'
|
|
4
|
+
import { FileNotFoundError } from '../errors/FileNotFoundError.ts'
|
|
5
|
+
import { guessMimeType } from '../helpers/mime.ts'
|
|
6
|
+
|
|
7
|
+
export interface FTPConfig {
|
|
8
|
+
host: string
|
|
9
|
+
port?: number
|
|
10
|
+
username?: string
|
|
11
|
+
password?: string
|
|
12
|
+
secure?: boolean | 'implicit'
|
|
13
|
+
root?: string
|
|
14
|
+
url?: string
|
|
15
|
+
visibility?: 'public' | 'private'
|
|
16
|
+
timeout?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* FTP filesystem driver using basic-ftp.
|
|
21
|
+
*
|
|
22
|
+
* Requires: bun add basic-ftp
|
|
23
|
+
*/
|
|
24
|
+
export class FTPDriver implements FilesystemDriver {
|
|
25
|
+
private _client: any = null
|
|
26
|
+
private _connected = false
|
|
27
|
+
private readonly root: string
|
|
28
|
+
private readonly urlBase: string | undefined
|
|
29
|
+
private readonly _config: FTPConfig
|
|
30
|
+
|
|
31
|
+
constructor(config: FTPConfig) {
|
|
32
|
+
this._config = config
|
|
33
|
+
this.root = (config.root ?? '/').replace(/\/+$/, '')
|
|
34
|
+
this.urlBase = config.url
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private async client(): Promise<any> {
|
|
38
|
+
if (!this._client || this._client.closed) {
|
|
39
|
+
let Client: any
|
|
40
|
+
try {
|
|
41
|
+
;({ Client } = await import('basic-ftp'))
|
|
42
|
+
} catch {
|
|
43
|
+
throw new FilesystemError(
|
|
44
|
+
'The basic-ftp package is required for the FTP driver. Install it with: bun add basic-ftp',
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
this._client = new Client(this._config.timeout ?? 30000)
|
|
48
|
+
this._connected = false
|
|
49
|
+
}
|
|
50
|
+
if (!this._connected) {
|
|
51
|
+
await this._client.access({
|
|
52
|
+
host: this._config.host,
|
|
53
|
+
port: this._config.port ?? 21,
|
|
54
|
+
user: this._config.username ?? 'anonymous',
|
|
55
|
+
password: this._config.password ?? '',
|
|
56
|
+
secure: this._config.secure ?? false,
|
|
57
|
+
})
|
|
58
|
+
this._connected = true
|
|
59
|
+
}
|
|
60
|
+
return this._client
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private fullPath(path: string): string {
|
|
64
|
+
return `${this.root}/${path.replace(/^\/+/, '')}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Disconnect from the FTP server. */
|
|
68
|
+
async disconnect(): Promise<void> {
|
|
69
|
+
if (this._client) {
|
|
70
|
+
this._client.close()
|
|
71
|
+
this._connected = false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Reads ─────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
async exists(path: string): Promise<boolean> {
|
|
78
|
+
const ftp = await this.client()
|
|
79
|
+
try {
|
|
80
|
+
await ftp.size(this.fullPath(path))
|
|
81
|
+
return true
|
|
82
|
+
} catch {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async get(path: string): Promise<string | null> {
|
|
88
|
+
const bytes = await this.getBytes(path)
|
|
89
|
+
return bytes ? new TextDecoder().decode(bytes) : null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async getBytes(path: string): Promise<Uint8Array | null> {
|
|
93
|
+
const ftp = await this.client()
|
|
94
|
+
try {
|
|
95
|
+
const chunks: Buffer[] = []
|
|
96
|
+
const writable = new Writable({
|
|
97
|
+
write(chunk, _encoding, callback) {
|
|
98
|
+
chunks.push(Buffer.from(chunk))
|
|
99
|
+
callback()
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
await ftp.downloadTo(writable, this.fullPath(path))
|
|
103
|
+
return new Uint8Array(Buffer.concat(chunks))
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async stream(path: string): Promise<ReadableStream | null> {
|
|
110
|
+
if (!(await this.exists(path))) return null
|
|
111
|
+
const bytes = await this.getBytes(path)
|
|
112
|
+
if (!bytes) return null
|
|
113
|
+
return new ReadableStream({
|
|
114
|
+
start(controller) {
|
|
115
|
+
controller.enqueue(bytes)
|
|
116
|
+
controller.close()
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Writes ────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
async put(path: string, contents: string | Uint8Array, _options?: PutOptions): Promise<void> {
|
|
124
|
+
const ftp = await this.client()
|
|
125
|
+
const buffer = typeof contents === 'string' ? Buffer.from(contents, 'utf-8') : Buffer.from(contents)
|
|
126
|
+
const readable = Readable.from(buffer)
|
|
127
|
+
await ftp.ensureDir(this.fullPath(path).split('/').slice(0, -1).join('/'))
|
|
128
|
+
await ftp.uploadFrom(readable, this.fullPath(path))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async putStream(path: string, stream: ReadableStream, options?: PutOptions): Promise<void> {
|
|
132
|
+
const body = new Uint8Array(await new Response(stream).arrayBuffer())
|
|
133
|
+
await this.put(path, body, options)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async append(path: string, contents: string): Promise<void> {
|
|
137
|
+
const ftp = await this.client()
|
|
138
|
+
const buffer = Buffer.from(contents, 'utf-8')
|
|
139
|
+
const readable = Readable.from(buffer)
|
|
140
|
+
await ftp.appendFrom(readable, this.fullPath(path))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async prepend(path: string, contents: string): Promise<void> {
|
|
144
|
+
const existing = (await this.get(path)) ?? ''
|
|
145
|
+
await this.put(path, contents + existing)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Operations ────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
async delete(path: string | string[]): Promise<boolean> {
|
|
151
|
+
const paths = Array.isArray(path) ? path : [path]
|
|
152
|
+
const ftp = await this.client()
|
|
153
|
+
try {
|
|
154
|
+
for (const p of paths) {
|
|
155
|
+
await ftp.remove(this.fullPath(p))
|
|
156
|
+
}
|
|
157
|
+
return true
|
|
158
|
+
} catch {
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async copy(from: string, to: string): Promise<void> {
|
|
164
|
+
// FTP doesn't support server-side copy — download and re-upload
|
|
165
|
+
const bytes = await this.getBytes(from)
|
|
166
|
+
if (!bytes) throw new FileNotFoundError(from)
|
|
167
|
+
await this.put(to, bytes)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async move(from: string, to: string): Promise<void> {
|
|
171
|
+
const ftp = await this.client()
|
|
172
|
+
await ftp.rename(this.fullPath(from), this.fullPath(to))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Metadata ──────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
async size(path: string): Promise<number> {
|
|
178
|
+
const ftp = await this.client()
|
|
179
|
+
try {
|
|
180
|
+
return await ftp.size(this.fullPath(path))
|
|
181
|
+
} catch {
|
|
182
|
+
throw new FileNotFoundError(path)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async lastModified(path: string): Promise<number> {
|
|
187
|
+
const ftp = await this.client()
|
|
188
|
+
try {
|
|
189
|
+
const date = await ftp.lastMod(this.fullPath(path))
|
|
190
|
+
return date.getTime()
|
|
191
|
+
} catch {
|
|
192
|
+
throw new FileNotFoundError(path)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async mimeType(path: string): Promise<string | null> {
|
|
197
|
+
return guessMimeType(path) ?? null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
path(filePath: string): string {
|
|
201
|
+
return this.fullPath(filePath)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── URLs ──────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
url(path: string): string {
|
|
207
|
+
if (!this.urlBase) {
|
|
208
|
+
throw new FilesystemError('URL generation is not supported — no url configured for this FTP disk.', { path })
|
|
209
|
+
}
|
|
210
|
+
return `${this.urlBase.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async temporaryUrl(_path: string, _expiration: number, _options?: Record<string, any>): Promise<string> {
|
|
214
|
+
throw new FilesystemError('Temporary URLs are not supported by the FTP driver.')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Directories ───────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
async files(directory: string = ''): Promise<string[]> {
|
|
220
|
+
const ftp = await this.client()
|
|
221
|
+
try {
|
|
222
|
+
const entries = await ftp.list(this.fullPath(directory))
|
|
223
|
+
return entries
|
|
224
|
+
.filter((e: any) => e.type !== 2) // type 2 = directory
|
|
225
|
+
.map((e: any) => directory ? `${directory}/${e.name}` : e.name)
|
|
226
|
+
} catch {
|
|
227
|
+
return []
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async allFiles(directory: string = ''): Promise<string[]> {
|
|
232
|
+
const results: string[] = []
|
|
233
|
+
const entries = await this.files(directory)
|
|
234
|
+
results.push(...entries)
|
|
235
|
+
|
|
236
|
+
const dirs = await this.directories(directory)
|
|
237
|
+
for (const dir of dirs) {
|
|
238
|
+
results.push(...(await this.allFiles(dir)))
|
|
239
|
+
}
|
|
240
|
+
return results.sort()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async directories(directory: string = ''): Promise<string[]> {
|
|
244
|
+
const ftp = await this.client()
|
|
245
|
+
try {
|
|
246
|
+
const entries = await ftp.list(this.fullPath(directory))
|
|
247
|
+
return entries
|
|
248
|
+
.filter((e: any) => e.type === 2) // type 2 = directory
|
|
249
|
+
.map((e: any) => directory ? `${directory}/${e.name}` : e.name)
|
|
250
|
+
} catch {
|
|
251
|
+
return []
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async allDirectories(directory: string = ''): Promise<string[]> {
|
|
256
|
+
const results: string[] = []
|
|
257
|
+
const dirs = await this.directories(directory)
|
|
258
|
+
for (const dir of dirs) {
|
|
259
|
+
results.push(dir)
|
|
260
|
+
results.push(...(await this.allDirectories(dir)))
|
|
261
|
+
}
|
|
262
|
+
return results.sort()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async makeDirectory(path: string): Promise<void> {
|
|
266
|
+
const ftp = await this.client()
|
|
267
|
+
await ftp.ensureDir(this.fullPath(path))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async deleteDirectory(directory: string): Promise<boolean> {
|
|
271
|
+
const ftp = await this.client()
|
|
272
|
+
try {
|
|
273
|
+
await ftp.removeDir(this.fullPath(directory))
|
|
274
|
+
return true
|
|
275
|
+
} catch {
|
|
276
|
+
return false
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Visibility ────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
async setVisibility(path: string, visibility: 'public' | 'private'): Promise<void> {
|
|
283
|
+
const ftp = await this.client()
|
|
284
|
+
const mode = visibility === 'public' ? '644' : '600'
|
|
285
|
+
try {
|
|
286
|
+
await ftp.send(`SITE CHMOD ${mode} ${this.fullPath(path)}`)
|
|
287
|
+
} catch {
|
|
288
|
+
throw new FilesystemError(
|
|
289
|
+
'FTP server does not support SITE CHMOD. Visibility changes are not available.',
|
|
290
|
+
{ path },
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async getVisibility(_path: string): Promise<string> {
|
|
296
|
+
// FTP does not provide a reliable way to read file permissions
|
|
297
|
+
return this._config.visibility ?? 'private'
|
|
298
|
+
}
|
|
299
|
+
}
|