@valentinkolb/filegate 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/LICENSE +21 -0
- package/README.md +429 -0
- package/package.json +56 -0
- package/src/client.ts +354 -0
- package/src/config.ts +37 -0
- package/src/handlers/files.ts +423 -0
- package/src/handlers/search.ts +119 -0
- package/src/handlers/upload.ts +332 -0
- package/src/index.ts +83 -0
- package/src/lib/openapi.ts +47 -0
- package/src/lib/ownership.ts +64 -0
- package/src/lib/path.ts +103 -0
- package/src/lib/response.ts +10 -0
- package/src/lib/validator.ts +21 -0
- package/src/schemas.ts +170 -0
- package/src/utils.ts +282 -0
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Common
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export const ErrorSchema = z.object({
|
|
8
|
+
error: z.string(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const FileTypeSchema = z.enum(["file", "directory"]);
|
|
12
|
+
|
|
13
|
+
export const FileInfoSchema = z.object({
|
|
14
|
+
name: z.string(),
|
|
15
|
+
path: z.string(),
|
|
16
|
+
type: FileTypeSchema,
|
|
17
|
+
size: z.number(),
|
|
18
|
+
mtime: z.iso.datetime(),
|
|
19
|
+
isHidden: z.boolean(),
|
|
20
|
+
mimeType: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const DirInfoSchema = FileInfoSchema.extend({
|
|
24
|
+
items: z.array(FileInfoSchema),
|
|
25
|
+
total: z.number(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Query Params
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export const PathQuerySchema = z.object({
|
|
33
|
+
path: z.string().min(1),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const InfoQuerySchema = z.object({
|
|
37
|
+
path: z.string().min(1),
|
|
38
|
+
showHidden: z
|
|
39
|
+
.string()
|
|
40
|
+
.optional()
|
|
41
|
+
.transform((v) => v === "true"),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const SearchQuerySchema = z.object({
|
|
45
|
+
paths: z.string().min(1),
|
|
46
|
+
pattern: z.string().min(1).max(500),
|
|
47
|
+
showHidden: z
|
|
48
|
+
.string()
|
|
49
|
+
.optional()
|
|
50
|
+
.transform((v) => v === "true"),
|
|
51
|
+
limit: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.transform((v) => (v ? parseInt(v, 10) : undefined)),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** Count recursive wildcards (**) in a glob pattern */
|
|
58
|
+
export const countRecursiveWildcards = (pattern: string): number => {
|
|
59
|
+
return (pattern.match(/\*\*/g) || []).length;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// Request Bodies
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
export const MkdirBodySchema = z.object({
|
|
67
|
+
path: z.string().min(1),
|
|
68
|
+
ownerUid: z.number().int().optional(),
|
|
69
|
+
ownerGid: z.number().int().optional(),
|
|
70
|
+
mode: z
|
|
71
|
+
.string()
|
|
72
|
+
.regex(/^[0-7]{3,4}$/)
|
|
73
|
+
.optional(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const MoveBodySchema = z.object({
|
|
77
|
+
from: z.string().min(1),
|
|
78
|
+
to: z.string().min(1),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const CopyBodySchema = z.object({
|
|
82
|
+
from: z.string().min(1),
|
|
83
|
+
to: z.string().min(1),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const UploadStartBodySchema = z.object({
|
|
87
|
+
path: z.string().min(1),
|
|
88
|
+
filename: z.string().min(1),
|
|
89
|
+
size: z.number().int().positive(),
|
|
90
|
+
checksum: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
91
|
+
chunkSize: z.number().int().positive(),
|
|
92
|
+
ownerUid: z.number().int().optional(),
|
|
93
|
+
ownerGid: z.number().int().optional(),
|
|
94
|
+
mode: z
|
|
95
|
+
.string()
|
|
96
|
+
.regex(/^[0-7]{3,4}$/)
|
|
97
|
+
.optional(),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// Response Schemas
|
|
102
|
+
// ============================================================================
|
|
103
|
+
|
|
104
|
+
export const SearchResultSchema = z.object({
|
|
105
|
+
basePath: z.string(),
|
|
106
|
+
files: z.array(FileInfoSchema),
|
|
107
|
+
total: z.number(),
|
|
108
|
+
hasMore: z.boolean(),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export const SearchResponseSchema = z.object({
|
|
112
|
+
results: z.array(SearchResultSchema),
|
|
113
|
+
totalFiles: z.number(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const UploadStartResponseSchema = z.object({
|
|
117
|
+
uploadId: z.string().regex(/^[a-f0-9]{16}$/),
|
|
118
|
+
totalChunks: z.number(),
|
|
119
|
+
chunkSize: z.number(),
|
|
120
|
+
uploadedChunks: z.array(z.number()),
|
|
121
|
+
completed: z.literal(false),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export const UploadChunkProgressSchema = z.object({
|
|
125
|
+
chunkIndex: z.number(),
|
|
126
|
+
uploadedChunks: z.array(z.number()),
|
|
127
|
+
completed: z.literal(false),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const UploadChunkCompleteSchema = z.object({
|
|
131
|
+
completed: z.literal(true),
|
|
132
|
+
file: FileInfoSchema.extend({ checksum: z.string() }),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
export const UploadChunkResponseSchema = z.union([UploadChunkProgressSchema, UploadChunkCompleteSchema]);
|
|
136
|
+
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// Header Schemas
|
|
139
|
+
// ============================================================================
|
|
140
|
+
|
|
141
|
+
export const UploadFileHeadersSchema = z.object({
|
|
142
|
+
"x-file-path": z.string().min(1),
|
|
143
|
+
"x-file-name": z.string().min(1),
|
|
144
|
+
"x-owner-uid": z.string().regex(/^\d+$/).transform(Number).optional(),
|
|
145
|
+
"x-owner-gid": z.string().regex(/^\d+$/).transform(Number).optional(),
|
|
146
|
+
"x-file-mode": z
|
|
147
|
+
.string()
|
|
148
|
+
.regex(/^[0-7]{3,4}$/)
|
|
149
|
+
.optional(),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export const UploadChunkHeadersSchema = z.object({
|
|
153
|
+
"x-upload-id": z.string().regex(/^[a-f0-9]{16}$/),
|
|
154
|
+
"x-chunk-index": z.string().regex(/^\d+$/).transform(Number),
|
|
155
|
+
"x-chunk-checksum": z
|
|
156
|
+
.string()
|
|
157
|
+
.regex(/^sha256:[a-f0-9]{64}$/)
|
|
158
|
+
.optional(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ============================================================================
|
|
162
|
+
// Types
|
|
163
|
+
// ============================================================================
|
|
164
|
+
|
|
165
|
+
export type FileInfo = z.infer<typeof FileInfoSchema>;
|
|
166
|
+
export type DirInfo = z.infer<typeof DirInfoSchema>;
|
|
167
|
+
export type SearchResult = z.infer<typeof SearchResultSchema>;
|
|
168
|
+
export type UploadStartBody = z.infer<typeof UploadStartBodySchema>;
|
|
169
|
+
export type UploadFileHeaders = z.infer<typeof UploadFileHeadersSchema>;
|
|
170
|
+
export type UploadChunkHeaders = z.infer<typeof UploadChunkHeadersSchema>;
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Filegate Utils - Browser-compatible utilities for chunked uploads
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Type Declarations
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
// Blob.slice() is standard but missing from TypeScript's lib.dom.d.ts
|
|
10
|
+
declare global {
|
|
11
|
+
interface Blob {
|
|
12
|
+
slice(start?: number, end?: number, contentType?: string): Blob;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Types
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
export interface ChunkInfo {
|
|
21
|
+
index: number;
|
|
22
|
+
data: Blob;
|
|
23
|
+
total: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UploadState {
|
|
27
|
+
uploaded: number;
|
|
28
|
+
total: number;
|
|
29
|
+
percent: number;
|
|
30
|
+
status: "pending" | "uploading" | "completed" | "error";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type StateSubscriber = (state: UploadState) => void;
|
|
34
|
+
|
|
35
|
+
export interface PrepareOptions {
|
|
36
|
+
file: Blob | File;
|
|
37
|
+
chunkSize?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SendOptions {
|
|
41
|
+
index: number;
|
|
42
|
+
retries?: number;
|
|
43
|
+
fn: (chunk: { index: number; data: Blob }) => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SendAllOptions {
|
|
47
|
+
skip?: number[];
|
|
48
|
+
retries?: number;
|
|
49
|
+
concurrency?: number;
|
|
50
|
+
fn: (chunk: { index: number; data: Blob }) => Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// ChunkedUpload Class
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
export class ChunkedUpload {
|
|
58
|
+
readonly file: Blob | File;
|
|
59
|
+
readonly fileSize: number;
|
|
60
|
+
readonly chunkSize: number;
|
|
61
|
+
readonly totalChunks: number;
|
|
62
|
+
readonly checksum: string;
|
|
63
|
+
|
|
64
|
+
private _state: UploadState;
|
|
65
|
+
private _subscribers: Set<StateSubscriber> = new Set();
|
|
66
|
+
private _completedChunks: Set<number> = new Set();
|
|
67
|
+
|
|
68
|
+
constructor(opts: { file: Blob | File; fileSize: number; chunkSize: number; totalChunks: number; checksum: string }) {
|
|
69
|
+
this.file = opts.file;
|
|
70
|
+
this.fileSize = opts.fileSize;
|
|
71
|
+
this.chunkSize = opts.chunkSize;
|
|
72
|
+
this.totalChunks = opts.totalChunks;
|
|
73
|
+
this.checksum = opts.checksum;
|
|
74
|
+
this._state = {
|
|
75
|
+
uploaded: 0,
|
|
76
|
+
total: opts.totalChunks,
|
|
77
|
+
percent: 0,
|
|
78
|
+
status: "pending",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ==========================================================================
|
|
83
|
+
// State Management
|
|
84
|
+
// ==========================================================================
|
|
85
|
+
|
|
86
|
+
get state(): UploadState {
|
|
87
|
+
return { ...this._state };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
subscribe(fn: StateSubscriber): () => void {
|
|
91
|
+
this._subscribers.add(fn);
|
|
92
|
+
// Emit current state immediately
|
|
93
|
+
fn(this.state);
|
|
94
|
+
return () => {
|
|
95
|
+
this._subscribers.delete(fn);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private _updateState(partial: Partial<UploadState>): void {
|
|
100
|
+
this._state = { ...this._state, ...partial };
|
|
101
|
+
for (const fn of this._subscribers) {
|
|
102
|
+
fn(this.state);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
complete(opts: { index: number }): void {
|
|
107
|
+
if (this._completedChunks.has(opts.index)) return;
|
|
108
|
+
|
|
109
|
+
this._completedChunks.add(opts.index);
|
|
110
|
+
const uploaded = this._completedChunks.size;
|
|
111
|
+
const percent = Math.round((uploaded / this.totalChunks) * 100);
|
|
112
|
+
const status = uploaded === this.totalChunks ? "completed" : "uploading";
|
|
113
|
+
|
|
114
|
+
this._updateState({ uploaded, percent, status });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
reset(): void {
|
|
118
|
+
this._completedChunks.clear();
|
|
119
|
+
this._updateState({
|
|
120
|
+
uploaded: 0,
|
|
121
|
+
percent: 0,
|
|
122
|
+
status: "pending",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ==========================================================================
|
|
127
|
+
// Chunk Access
|
|
128
|
+
// ==========================================================================
|
|
129
|
+
|
|
130
|
+
get(opts: { index: number }): Blob {
|
|
131
|
+
const start = opts.index * this.chunkSize;
|
|
132
|
+
const end = Math.min(start + this.chunkSize, this.fileSize);
|
|
133
|
+
// Blob.prototype.slice is standard but TypeScript's lib.dom.d.ts doesn't include it
|
|
134
|
+
// We use a type-safe wrapper that preserves the Blob type
|
|
135
|
+
return (this.file as Blob).slice(start, end);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async hash(opts: { data: Blob | ArrayBuffer }): Promise<string> {
|
|
139
|
+
const buffer = opts.data instanceof Blob ? await opts.data.arrayBuffer() : opts.data;
|
|
140
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
|
|
141
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
142
|
+
return `sha256:${Array.from(hashArray)
|
|
143
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
144
|
+
.join("")}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ==========================================================================
|
|
148
|
+
// Upload Helpers
|
|
149
|
+
// ==========================================================================
|
|
150
|
+
|
|
151
|
+
async send(opts: SendOptions): Promise<void> {
|
|
152
|
+
const { index, retries = 0, fn } = opts;
|
|
153
|
+
const data = this.get({ index });
|
|
154
|
+
|
|
155
|
+
let lastError: Error | null = null;
|
|
156
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
157
|
+
try {
|
|
158
|
+
await fn({ index, data });
|
|
159
|
+
this.complete({ index });
|
|
160
|
+
return;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
163
|
+
if (attempt < retries) {
|
|
164
|
+
// Exponential backoff: 100ms, 200ms, 400ms, ...
|
|
165
|
+
await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this._updateState({ status: "error" });
|
|
171
|
+
throw lastError;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async sendAll(opts: SendAllOptions): Promise<void> {
|
|
175
|
+
const { skip = [], retries = 0, concurrency = 1, fn } = opts;
|
|
176
|
+
const skipSet = new Set(skip);
|
|
177
|
+
|
|
178
|
+
// Mark skipped chunks as completed
|
|
179
|
+
for (const index of skip) {
|
|
180
|
+
this.complete({ index });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this._updateState({ status: "uploading" });
|
|
184
|
+
|
|
185
|
+
// Get indices that need to be uploaded
|
|
186
|
+
const toUpload: number[] = [];
|
|
187
|
+
for (let i = 0; i < this.totalChunks; i++) {
|
|
188
|
+
if (!skipSet.has(i) && !this._completedChunks.has(i)) {
|
|
189
|
+
toUpload.push(i);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (concurrency === 1) {
|
|
194
|
+
// Sequential upload
|
|
195
|
+
for (const index of toUpload) {
|
|
196
|
+
await this.send({ index, retries, fn });
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
// Concurrent upload with limited parallelism
|
|
200
|
+
const queue = [...toUpload];
|
|
201
|
+
const inFlight: Promise<void>[] = [];
|
|
202
|
+
|
|
203
|
+
while (queue.length > 0 || inFlight.length > 0) {
|
|
204
|
+
// Start new uploads up to concurrency limit
|
|
205
|
+
while (queue.length > 0 && inFlight.length < concurrency) {
|
|
206
|
+
const index = queue.shift()!;
|
|
207
|
+
const promise = this.send({ index, retries, fn }).then(() => {
|
|
208
|
+
inFlight.splice(inFlight.indexOf(promise), 1);
|
|
209
|
+
});
|
|
210
|
+
inFlight.push(promise);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Wait for at least one to complete
|
|
214
|
+
if (inFlight.length > 0) {
|
|
215
|
+
await Promise.race(inFlight);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this._updateState({ status: "completed" });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ==========================================================================
|
|
224
|
+
// Async Iterator
|
|
225
|
+
// ==========================================================================
|
|
226
|
+
|
|
227
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<ChunkInfo> {
|
|
228
|
+
for (let index = 0; index < this.totalChunks; index++) {
|
|
229
|
+
yield {
|
|
230
|
+
index,
|
|
231
|
+
data: this.get({ index }),
|
|
232
|
+
total: this.totalChunks,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ============================================================================
|
|
239
|
+
// chunks namespace
|
|
240
|
+
// ============================================================================
|
|
241
|
+
|
|
242
|
+
async function prepare(opts: PrepareOptions): Promise<ChunkedUpload> {
|
|
243
|
+
const { file, chunkSize = 5 * 1024 * 1024 } = opts;
|
|
244
|
+
const fileSize = file.size;
|
|
245
|
+
const totalChunks = Math.ceil(fileSize / chunkSize);
|
|
246
|
+
|
|
247
|
+
// Calculate checksum using WebCrypto
|
|
248
|
+
const buffer = await file.arrayBuffer();
|
|
249
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
|
|
250
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
251
|
+
const checksum = `sha256:${Array.from(hashArray)
|
|
252
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
253
|
+
.join("")}`;
|
|
254
|
+
|
|
255
|
+
return new ChunkedUpload({
|
|
256
|
+
file,
|
|
257
|
+
fileSize,
|
|
258
|
+
chunkSize,
|
|
259
|
+
totalChunks,
|
|
260
|
+
checksum,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export const chunks = {
|
|
265
|
+
prepare,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// ============================================================================
|
|
269
|
+
// formatBytes
|
|
270
|
+
// ============================================================================
|
|
271
|
+
|
|
272
|
+
export function formatBytes(opts: { bytes: number; decimals?: number }): string {
|
|
273
|
+
const { bytes, decimals = 2 } = opts;
|
|
274
|
+
|
|
275
|
+
if (bytes === 0) return "0 Bytes";
|
|
276
|
+
|
|
277
|
+
const k = 1024;
|
|
278
|
+
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
|
|
279
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
280
|
+
|
|
281
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
|
|
282
|
+
}
|