@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,311 @@
|
|
|
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 GCSConfig {
|
|
7
|
+
bucket: string
|
|
8
|
+
projectId?: string
|
|
9
|
+
keyFilename?: string
|
|
10
|
+
credentials?: Record<string, any>
|
|
11
|
+
root?: string
|
|
12
|
+
url?: string
|
|
13
|
+
visibility?: 'public' | 'private'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Google Cloud Storage filesystem driver.
|
|
18
|
+
*
|
|
19
|
+
* Requires: bun add @google-cloud/storage
|
|
20
|
+
*/
|
|
21
|
+
export class GCSDriver implements FilesystemDriver {
|
|
22
|
+
private _storage: any = null
|
|
23
|
+
private _bucket: any = null
|
|
24
|
+
private readonly bucketName: string
|
|
25
|
+
private readonly prefix: string
|
|
26
|
+
private readonly urlBase: string | undefined
|
|
27
|
+
private readonly defaultVisibility: 'public' | 'private'
|
|
28
|
+
private readonly _config: GCSConfig
|
|
29
|
+
|
|
30
|
+
constructor(config: GCSConfig) {
|
|
31
|
+
this._config = config
|
|
32
|
+
this.bucketName = config.bucket
|
|
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 bucket(): Promise<any> {
|
|
39
|
+
if (!this._bucket) {
|
|
40
|
+
let Storage: any
|
|
41
|
+
try {
|
|
42
|
+
;({ Storage } = await import('@google-cloud/storage'))
|
|
43
|
+
} catch {
|
|
44
|
+
throw new FilesystemError(
|
|
45
|
+
'The @google-cloud/storage package is required for the GCS driver. Install it with: bun add @google-cloud/storage',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
const opts: any = {}
|
|
49
|
+
if (this._config.projectId) opts.projectId = this._config.projectId
|
|
50
|
+
if (this._config.keyFilename) opts.keyFilename = this._config.keyFilename
|
|
51
|
+
if (this._config.credentials) opts.credentials = this._config.credentials
|
|
52
|
+
this._storage = new Storage(opts)
|
|
53
|
+
this._bucket = this._storage.bucket(this.bucketName)
|
|
54
|
+
}
|
|
55
|
+
return this._bucket
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async file(path: string): Promise<any> {
|
|
59
|
+
const b = await this.bucket()
|
|
60
|
+
return b.file(this.key(path))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private key(path: string): string {
|
|
64
|
+
return this.prefix + path.replace(/^\/+/, '')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private stripPrefix(key: string): string {
|
|
68
|
+
return this.prefix && key.startsWith(this.prefix)
|
|
69
|
+
? key.slice(this.prefix.length)
|
|
70
|
+
: key
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Reads ─────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
async exists(path: string): Promise<boolean> {
|
|
76
|
+
const f = await this.file(path)
|
|
77
|
+
const [exists] = await f.exists()
|
|
78
|
+
return exists
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async get(path: string): Promise<string | null> {
|
|
82
|
+
const f = await this.file(path)
|
|
83
|
+
try {
|
|
84
|
+
const [contents] = await f.download()
|
|
85
|
+
return contents.toString('utf-8')
|
|
86
|
+
} catch (e: any) {
|
|
87
|
+
if (e.code === 404) return null
|
|
88
|
+
throw new FilesystemError(`GCS get failed: ${e.message}`, { path })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async getBytes(path: string): Promise<Uint8Array | null> {
|
|
93
|
+
const f = await this.file(path)
|
|
94
|
+
try {
|
|
95
|
+
const [contents] = await f.download()
|
|
96
|
+
return new Uint8Array(contents)
|
|
97
|
+
} catch (e: any) {
|
|
98
|
+
if (e.code === 404) return null
|
|
99
|
+
throw new FilesystemError(`GCS getBytes failed: ${e.message}`, { path })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async stream(path: string): Promise<ReadableStream | null> {
|
|
104
|
+
if (!(await this.exists(path))) return null
|
|
105
|
+
const f = await this.file(path)
|
|
106
|
+
const nodeStream = f.createReadStream()
|
|
107
|
+
return new ReadableStream({
|
|
108
|
+
start(controller) {
|
|
109
|
+
nodeStream.on('data', (chunk: any) => controller.enqueue(new Uint8Array(chunk)))
|
|
110
|
+
nodeStream.on('end', () => controller.close())
|
|
111
|
+
nodeStream.on('error', (err: any) => controller.error(err))
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Writes ────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
async put(path: string, contents: string | Uint8Array, options?: PutOptions): Promise<void> {
|
|
119
|
+
const f = await this.file(path)
|
|
120
|
+
const buffer = typeof contents === 'string' ? Buffer.from(contents, 'utf-8') : Buffer.from(contents)
|
|
121
|
+
const contentType = options?.mimeType ?? guessMimeType(path)
|
|
122
|
+
await f.save(buffer, {
|
|
123
|
+
...(contentType ? { contentType } : {}),
|
|
124
|
+
resumable: false,
|
|
125
|
+
})
|
|
126
|
+
const visibility = options?.visibility ?? this.defaultVisibility
|
|
127
|
+
if (visibility === 'public') {
|
|
128
|
+
await f.makePublic()
|
|
129
|
+
} else {
|
|
130
|
+
await f.makePrivate()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async putStream(path: string, stream: ReadableStream, options?: PutOptions): Promise<void> {
|
|
135
|
+
const body = new Uint8Array(await new Response(stream).arrayBuffer())
|
|
136
|
+
await this.put(path, body, options)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async append(path: string, contents: string): Promise<void> {
|
|
140
|
+
const existing = (await this.get(path)) ?? ''
|
|
141
|
+
await this.put(path, existing + contents)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async prepend(path: string, contents: string): Promise<void> {
|
|
145
|
+
const existing = (await this.get(path)) ?? ''
|
|
146
|
+
await this.put(path, contents + existing)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Operations ────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
async delete(path: string | string[]): Promise<boolean> {
|
|
152
|
+
const paths = Array.isArray(path) ? path : [path]
|
|
153
|
+
try {
|
|
154
|
+
for (const p of paths) {
|
|
155
|
+
const f = await this.file(p)
|
|
156
|
+
await f.delete({ ignoreNotFound: true })
|
|
157
|
+
}
|
|
158
|
+
return true
|
|
159
|
+
} catch {
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async copy(from: string, to: string): Promise<void> {
|
|
165
|
+
const src = await this.file(from)
|
|
166
|
+
const dest = await this.file(to)
|
|
167
|
+
await src.copy(dest)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async move(from: string, to: string): Promise<void> {
|
|
171
|
+
const src = await this.file(from)
|
|
172
|
+
const dest = await this.file(to)
|
|
173
|
+
await src.move(dest)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Metadata ──────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
async size(path: string): Promise<number> {
|
|
179
|
+
const meta = await this.metadata(path)
|
|
180
|
+
return parseInt(meta.size ?? '0', 10)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async lastModified(path: string): Promise<number> {
|
|
184
|
+
const meta = await this.metadata(path)
|
|
185
|
+
return meta.updated ? new Date(meta.updated).getTime() : 0
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async mimeType(path: string): Promise<string | null> {
|
|
189
|
+
const meta = await this.metadata(path)
|
|
190
|
+
return meta.contentType ?? null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
path(filePath: string): string {
|
|
194
|
+
return this.key(filePath)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── URLs ──────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
url(path: string): string {
|
|
200
|
+
const k = this.key(path)
|
|
201
|
+
if (this.urlBase) {
|
|
202
|
+
return `${this.urlBase.replace(/\/+$/, '')}/${k}`
|
|
203
|
+
}
|
|
204
|
+
return `https://storage.googleapis.com/${this.bucketName}/${k}`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async temporaryUrl(path: string, expiration: number, _options?: Record<string, any>): Promise<string> {
|
|
208
|
+
const f = await this.file(path)
|
|
209
|
+
const [url] = await f.getSignedUrl({
|
|
210
|
+
action: 'read',
|
|
211
|
+
expires: Date.now() + expiration * 1000,
|
|
212
|
+
})
|
|
213
|
+
return url
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Directories ───────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
async files(directory: string = ''): Promise<string[]> {
|
|
219
|
+
const prefix = this.directoryPrefix(directory)
|
|
220
|
+
const b = await this.bucket()
|
|
221
|
+
const [files] = await b.getFiles({ prefix, delimiter: '/' })
|
|
222
|
+
return files
|
|
223
|
+
.map((f: any) => this.stripPrefix(f.name))
|
|
224
|
+
.filter((name: string) => !name.endsWith('/'))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async allFiles(directory: string = ''): Promise<string[]> {
|
|
228
|
+
const prefix = this.directoryPrefix(directory)
|
|
229
|
+
const b = await this.bucket()
|
|
230
|
+
const [files] = await b.getFiles({ prefix })
|
|
231
|
+
return files
|
|
232
|
+
.map((f: any) => this.stripPrefix(f.name))
|
|
233
|
+
.filter((name: string) => !name.endsWith('/'))
|
|
234
|
+
.sort()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async directories(directory: string = ''): Promise<string[]> {
|
|
238
|
+
const prefix = this.directoryPrefix(directory)
|
|
239
|
+
const b = await this.bucket()
|
|
240
|
+
const [, , apiResponse] = await b.getFiles({ prefix, delimiter: '/', autoPaginate: false })
|
|
241
|
+
return (apiResponse.prefixes ?? []).map((p: string) =>
|
|
242
|
+
this.stripPrefix(p).replace(/\/$/, ''),
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async allDirectories(directory: string = ''): Promise<string[]> {
|
|
247
|
+
const allFiles = await this.allFiles(directory)
|
|
248
|
+
const dirs = new Set<string>()
|
|
249
|
+
for (const file of allFiles) {
|
|
250
|
+
const parts = file.split('/')
|
|
251
|
+
for (let i = 1; i < parts.length; i++) {
|
|
252
|
+
dirs.add(parts.slice(0, i).join('/'))
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return [...dirs].sort()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async makeDirectory(path: string): Promise<void> {
|
|
259
|
+
const f = await this.file(path + '/')
|
|
260
|
+
await f.save('', { resumable: false })
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async deleteDirectory(directory: string): Promise<boolean> {
|
|
264
|
+
const prefix = this.directoryPrefix(directory)
|
|
265
|
+
const b = await this.bucket()
|
|
266
|
+
try {
|
|
267
|
+
await b.deleteFiles({ prefix, force: true })
|
|
268
|
+
return true
|
|
269
|
+
} catch {
|
|
270
|
+
return false
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Visibility ────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
async setVisibility(path: string, visibility: 'public' | 'private'): Promise<void> {
|
|
277
|
+
const f = await this.file(path)
|
|
278
|
+
if (visibility === 'public') {
|
|
279
|
+
await f.makePublic()
|
|
280
|
+
} else {
|
|
281
|
+
await f.makePrivate()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async getVisibility(path: string): Promise<string> {
|
|
286
|
+
const f = await this.file(path)
|
|
287
|
+
try {
|
|
288
|
+
const [isPublic] = await f.isPublic()
|
|
289
|
+
return isPublic ? 'public' : 'private'
|
|
290
|
+
} catch {
|
|
291
|
+
return 'private'
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
private async metadata(path: string): Promise<any> {
|
|
298
|
+
const f = await this.file(path)
|
|
299
|
+
try {
|
|
300
|
+
const [meta] = await f.getMetadata()
|
|
301
|
+
return meta
|
|
302
|
+
} catch (e: any) {
|
|
303
|
+
if (e.code === 404) throw new FileNotFoundError(path)
|
|
304
|
+
throw new FilesystemError(`GCS metadata failed: ${e.message}`, { path })
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private directoryPrefix(directory: string): string {
|
|
309
|
+
return directory ? this.key(directory).replace(/\/$/, '') + '/' : this.prefix
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { resolve, join, dirname, relative } from 'node:path'
|
|
2
|
+
import { mkdir, rm, readdir, stat, rename, copyFile, chmod, appendFile } from 'node:fs/promises'
|
|
3
|
+
import type { FilesystemDriver, PutOptions } from '../contracts/FilesystemDriver.ts'
|
|
4
|
+
import { FilesystemError } from '../errors/FilesystemError.ts'
|
|
5
|
+
import { FileNotFoundError } from '../errors/FileNotFoundError.ts'
|
|
6
|
+
|
|
7
|
+
export class LocalDriver implements FilesystemDriver {
|
|
8
|
+
private readonly root: string
|
|
9
|
+
private readonly urlBase: string | undefined
|
|
10
|
+
private readonly defaultVisibility: 'public' | 'private'
|
|
11
|
+
|
|
12
|
+
constructor(root: string, urlBase?: string, defaultVisibility: 'public' | 'private' = 'public') {
|
|
13
|
+
this.root = resolve(root)
|
|
14
|
+
this.urlBase = urlBase
|
|
15
|
+
this.defaultVisibility = defaultVisibility
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Reads ───────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
async exists(path: string): Promise<boolean> {
|
|
21
|
+
return Bun.file(this.fullPath(path)).exists()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async get(path: string): Promise<string | null> {
|
|
25
|
+
const fp = this.fullPath(path)
|
|
26
|
+
const file = Bun.file(fp)
|
|
27
|
+
if (!(await file.exists())) return null
|
|
28
|
+
return file.text()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async getBytes(path: string): Promise<Uint8Array | null> {
|
|
32
|
+
const fp = this.fullPath(path)
|
|
33
|
+
const file = Bun.file(fp)
|
|
34
|
+
if (!(await file.exists())) return null
|
|
35
|
+
return new Uint8Array(await file.arrayBuffer())
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async stream(path: string): Promise<ReadableStream | null> {
|
|
39
|
+
const fp = this.fullPath(path)
|
|
40
|
+
const file = Bun.file(fp)
|
|
41
|
+
if (!(await file.exists())) return null
|
|
42
|
+
return file.stream()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Writes ──────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
async put(path: string, contents: string | Uint8Array, options?: PutOptions): Promise<void> {
|
|
48
|
+
const fp = this.fullPath(path)
|
|
49
|
+
await mkdir(dirname(fp), { recursive: true })
|
|
50
|
+
await Bun.write(fp, contents)
|
|
51
|
+
|
|
52
|
+
const visibility = options?.visibility ?? this.defaultVisibility
|
|
53
|
+
await this.applyVisibility(fp, visibility)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async putStream(path: string, stream: ReadableStream, options?: PutOptions): Promise<void> {
|
|
57
|
+
const fp = this.fullPath(path)
|
|
58
|
+
await mkdir(dirname(fp), { recursive: true })
|
|
59
|
+
// Consume the stream into a Response to get the full body, then write
|
|
60
|
+
const body = await new Response(stream).arrayBuffer()
|
|
61
|
+
await Bun.write(fp, body)
|
|
62
|
+
|
|
63
|
+
const visibility = options?.visibility ?? this.defaultVisibility
|
|
64
|
+
await this.applyVisibility(fp, visibility)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async append(path: string, contents: string): Promise<void> {
|
|
68
|
+
const fp = this.fullPath(path)
|
|
69
|
+
await mkdir(dirname(fp), { recursive: true })
|
|
70
|
+
await appendFile(fp, contents)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async prepend(path: string, contents: string): Promise<void> {
|
|
74
|
+
const fp = this.fullPath(path)
|
|
75
|
+
await mkdir(dirname(fp), { recursive: true })
|
|
76
|
+
const file = Bun.file(fp)
|
|
77
|
+
const existing = (await file.exists()) ? await file.text() : ''
|
|
78
|
+
await Bun.write(fp, contents + existing)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Operations ──────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
async delete(path: string | string[]): Promise<boolean> {
|
|
84
|
+
const paths = Array.isArray(path) ? path : [path]
|
|
85
|
+
let allDeleted = true
|
|
86
|
+
|
|
87
|
+
for (const p of paths) {
|
|
88
|
+
const fp = this.fullPath(p)
|
|
89
|
+
try {
|
|
90
|
+
if (await Bun.file(fp).exists()) {
|
|
91
|
+
await rm(fp)
|
|
92
|
+
} else {
|
|
93
|
+
allDeleted = false
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
allDeleted = false
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return allDeleted
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async copy(from: string, to: string): Promise<void> {
|
|
104
|
+
const fromPath = this.fullPath(from)
|
|
105
|
+
const toPath = this.fullPath(to)
|
|
106
|
+
await mkdir(dirname(toPath), { recursive: true })
|
|
107
|
+
await copyFile(fromPath, toPath)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async move(from: string, to: string): Promise<void> {
|
|
111
|
+
const fromPath = this.fullPath(from)
|
|
112
|
+
const toPath = this.fullPath(to)
|
|
113
|
+
await mkdir(dirname(toPath), { recursive: true })
|
|
114
|
+
await rename(fromPath, toPath)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Metadata ────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
async size(path: string): Promise<number> {
|
|
120
|
+
const fp = this.fullPath(path)
|
|
121
|
+
try {
|
|
122
|
+
const s = await stat(fp)
|
|
123
|
+
return s.size
|
|
124
|
+
} catch {
|
|
125
|
+
throw new FileNotFoundError(path)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async lastModified(path: string): Promise<number> {
|
|
130
|
+
const fp = this.fullPath(path)
|
|
131
|
+
try {
|
|
132
|
+
const s = await stat(fp)
|
|
133
|
+
return Math.floor(s.mtimeMs)
|
|
134
|
+
} catch {
|
|
135
|
+
throw new FileNotFoundError(path)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async mimeType(path: string): Promise<string | null> {
|
|
140
|
+
const fp = this.fullPath(path)
|
|
141
|
+
const file = Bun.file(fp)
|
|
142
|
+
if (!(await file.exists())) return null
|
|
143
|
+
return file.type || null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
path(filePath: string): string {
|
|
147
|
+
return this.fullPath(filePath)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── URLs ────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
url(path: string): string {
|
|
153
|
+
if (!this.urlBase) {
|
|
154
|
+
throw new FilesystemError('URL generation is not supported — no url configured for this disk.', { path })
|
|
155
|
+
}
|
|
156
|
+
return `${this.urlBase.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async temporaryUrl(_path: string, _expiration: number, _options?: Record<string, any>): Promise<string> {
|
|
160
|
+
throw new FilesystemError('Temporary URLs are not supported by the local driver.')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Directories ─────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
async files(directory: string = ''): Promise<string[]> {
|
|
166
|
+
const dir = this.fullPath(directory)
|
|
167
|
+
try {
|
|
168
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
169
|
+
return entries
|
|
170
|
+
.filter((e) => e.isFile())
|
|
171
|
+
.map((e) => directory ? `${directory}/${e.name}` : e.name)
|
|
172
|
+
} catch {
|
|
173
|
+
return []
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async allFiles(directory: string = ''): Promise<string[]> {
|
|
178
|
+
const dir = this.fullPath(directory)
|
|
179
|
+
try {
|
|
180
|
+
const entries = await readdir(dir, { withFileTypes: true, recursive: true })
|
|
181
|
+
const results: string[] = []
|
|
182
|
+
for (const e of entries) {
|
|
183
|
+
if (e.isFile()) {
|
|
184
|
+
// Build relative path from the parentPath if available
|
|
185
|
+
const parentRel = this.entryRelativePath(e, dir)
|
|
186
|
+
const full = parentRel ? `${parentRel}/${e.name}` : e.name
|
|
187
|
+
results.push(directory ? `${directory}/${full}` : full)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return results.sort()
|
|
191
|
+
} catch {
|
|
192
|
+
return []
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async directories(directory: string = ''): Promise<string[]> {
|
|
197
|
+
const dir = this.fullPath(directory)
|
|
198
|
+
try {
|
|
199
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
200
|
+
return entries
|
|
201
|
+
.filter((e) => e.isDirectory())
|
|
202
|
+
.map((e) => directory ? `${directory}/${e.name}` : e.name)
|
|
203
|
+
} catch {
|
|
204
|
+
return []
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async allDirectories(directory: string = ''): Promise<string[]> {
|
|
209
|
+
const dir = this.fullPath(directory)
|
|
210
|
+
try {
|
|
211
|
+
const entries = await readdir(dir, { withFileTypes: true, recursive: true })
|
|
212
|
+
const results: string[] = []
|
|
213
|
+
for (const e of entries) {
|
|
214
|
+
if (e.isDirectory()) {
|
|
215
|
+
const parentRel = this.entryRelativePath(e, dir)
|
|
216
|
+
const full = parentRel ? `${parentRel}/${e.name}` : e.name
|
|
217
|
+
results.push(directory ? `${directory}/${full}` : full)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return results.sort()
|
|
221
|
+
} catch {
|
|
222
|
+
return []
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async makeDirectory(path: string): Promise<void> {
|
|
227
|
+
await mkdir(this.fullPath(path), { recursive: true })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async deleteDirectory(directory: string): Promise<boolean> {
|
|
231
|
+
const fp = this.fullPath(directory)
|
|
232
|
+
try {
|
|
233
|
+
await rm(fp, { recursive: true, force: true })
|
|
234
|
+
return true
|
|
235
|
+
} catch {
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Visibility ──────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
async setVisibility(path: string, visibility: 'public' | 'private'): Promise<void> {
|
|
243
|
+
await this.applyVisibility(this.fullPath(path), visibility)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async getVisibility(path: string): Promise<string> {
|
|
247
|
+
const fp = this.fullPath(path)
|
|
248
|
+
const s = await stat(fp)
|
|
249
|
+
// Check if "others" have read permission (o+r)
|
|
250
|
+
return (s.mode & 0o004) ? 'public' : 'private'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Internal ────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
private fullPath(path: string): string {
|
|
256
|
+
const resolved = resolve(this.root, path)
|
|
257
|
+
this.assertWithinRoot(resolved)
|
|
258
|
+
return resolved
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private assertWithinRoot(resolvedPath: string): void {
|
|
262
|
+
if (!resolvedPath.startsWith(this.root)) {
|
|
263
|
+
throw new FilesystemError(
|
|
264
|
+
'Path traversal detected — resolved path escapes the disk root.',
|
|
265
|
+
{ resolved: resolvedPath, root: this.root },
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private async applyVisibility(fullPath: string, visibility: 'public' | 'private'): Promise<void> {
|
|
271
|
+
const mode = visibility === 'public' ? 0o644 : 0o600
|
|
272
|
+
await chmod(fullPath, mode)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private entryRelativePath(entry: any, baseDir: string): string {
|
|
276
|
+
// Bun/Node 20+ Dirent has parentPath or path property
|
|
277
|
+
const parent: string | undefined = entry.parentPath ?? entry.path
|
|
278
|
+
if (!parent) return ''
|
|
279
|
+
return relative(baseDir, parent)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { FilesystemDriver, PutOptions } from '../contracts/FilesystemDriver.ts'
|
|
2
|
+
|
|
3
|
+
export class NullDriver implements FilesystemDriver {
|
|
4
|
+
async exists(_path: string): Promise<boolean> { return false }
|
|
5
|
+
async get(_path: string): Promise<string | null> { return null }
|
|
6
|
+
async getBytes(_path: string): Promise<Uint8Array | null> { return null }
|
|
7
|
+
async stream(_path: string): Promise<ReadableStream | null> { return null }
|
|
8
|
+
|
|
9
|
+
async put(_path: string, _contents: string | Uint8Array, _options?: PutOptions): Promise<void> {}
|
|
10
|
+
async putStream(_path: string, _stream: ReadableStream, _options?: PutOptions): Promise<void> {}
|
|
11
|
+
async append(_path: string, _contents: string): Promise<void> {}
|
|
12
|
+
async prepend(_path: string, _contents: string): Promise<void> {}
|
|
13
|
+
|
|
14
|
+
async delete(_path: string | string[]): Promise<boolean> { return true }
|
|
15
|
+
async copy(_from: string, _to: string): Promise<void> {}
|
|
16
|
+
async move(_from: string, _to: string): Promise<void> {}
|
|
17
|
+
|
|
18
|
+
async size(_path: string): Promise<number> { return 0 }
|
|
19
|
+
async lastModified(_path: string): Promise<number> { return 0 }
|
|
20
|
+
async mimeType(_path: string): Promise<string | null> { return null }
|
|
21
|
+
path(filePath: string): string { return filePath }
|
|
22
|
+
|
|
23
|
+
url(path: string): string { return path }
|
|
24
|
+
async temporaryUrl(path: string, _expiration: number): Promise<string> { return path }
|
|
25
|
+
|
|
26
|
+
async files(_directory?: string): Promise<string[]> { return [] }
|
|
27
|
+
async allFiles(_directory?: string): Promise<string[]> { return [] }
|
|
28
|
+
async directories(_directory?: string): Promise<string[]> { return [] }
|
|
29
|
+
async allDirectories(_directory?: string): Promise<string[]> { return [] }
|
|
30
|
+
async makeDirectory(_path: string): Promise<void> {}
|
|
31
|
+
async deleteDirectory(_directory: string): Promise<boolean> { return true }
|
|
32
|
+
|
|
33
|
+
async setVisibility(_path: string, _visibility: 'public' | 'private'): Promise<void> {}
|
|
34
|
+
async getVisibility(_path: string): Promise<string> { return 'public' }
|
|
35
|
+
}
|