recker 1.0.19-next.f5bb375 → 1.0.20-next.595cc59
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/dist/cli/index.d.ts +0 -1
- package/dist/cli/index.js +495 -2
- package/dist/cli/tui/shell.js +1 -1
- package/dist/core/client.d.ts +2 -0
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +4 -0
- package/dist/plugins/cache.js +1 -1
- package/dist/plugins/hls.d.ts +90 -17
- package/dist/plugins/hls.d.ts.map +1 -1
- package/dist/plugins/hls.js +343 -173
- package/dist/plugins/retry.js +2 -2
- package/dist/testing/index.d.ts +16 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +8 -0
- package/dist/testing/mock-dns-server.d.ts +70 -0
- package/dist/testing/mock-dns-server.d.ts.map +1 -0
- package/dist/testing/mock-dns-server.js +269 -0
- package/dist/testing/mock-ftp-server.d.ts +90 -0
- package/dist/testing/mock-ftp-server.d.ts.map +1 -0
- package/dist/testing/mock-ftp-server.js +562 -0
- package/dist/testing/mock-hls-server.d.ts +81 -0
- package/dist/testing/mock-hls-server.d.ts.map +1 -0
- package/dist/testing/mock-hls-server.js +381 -0
- package/dist/testing/mock-http-server.d.ts +100 -0
- package/dist/testing/mock-http-server.d.ts.map +1 -0
- package/dist/testing/mock-http-server.js +298 -0
- package/dist/testing/mock-sse-server.d.ts +77 -0
- package/dist/testing/mock-sse-server.d.ts.map +1 -0
- package/dist/testing/mock-sse-server.js +291 -0
- package/dist/testing/mock-telnet-server.d.ts +60 -0
- package/dist/testing/mock-telnet-server.d.ts.map +1 -0
- package/dist/testing/mock-telnet-server.js +273 -0
- package/dist/testing/mock-websocket-server.d.ts +78 -0
- package/dist/testing/mock-websocket-server.d.ts.map +1 -0
- package/dist/testing/mock-websocket-server.js +316 -0
- package/dist/testing/mock-whois-server.d.ts +57 -0
- package/dist/testing/mock-whois-server.d.ts.map +1 -0
- package/dist/testing/mock-whois-server.js +234 -0
- package/dist/transport/undici.js +1 -1
- package/dist/utils/dns-toolkit.js +1 -1
- package/dist/utils/dns.js +2 -2
- package/dist/utils/optional-require.js +1 -1
- package/dist/webrtc/index.js +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import * as net from 'node:net';
|
|
3
|
+
export class MockFtpServer extends EventEmitter {
|
|
4
|
+
options;
|
|
5
|
+
server = null;
|
|
6
|
+
sessions = new Map();
|
|
7
|
+
files = new Map();
|
|
8
|
+
started = false;
|
|
9
|
+
sessionCounter = 0;
|
|
10
|
+
dataPortCounter = 30000;
|
|
11
|
+
stats = {
|
|
12
|
+
connectionsTotal: 0,
|
|
13
|
+
commandsReceived: 0,
|
|
14
|
+
filesDownloaded: 0,
|
|
15
|
+
filesUploaded: 0,
|
|
16
|
+
bytesTransferred: 0,
|
|
17
|
+
commandLog: [],
|
|
18
|
+
};
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
super();
|
|
21
|
+
this.options = {
|
|
22
|
+
port: 2121,
|
|
23
|
+
host: '127.0.0.1',
|
|
24
|
+
anonymous: true,
|
|
25
|
+
username: 'user',
|
|
26
|
+
password: 'pass',
|
|
27
|
+
welcomeMessage: 'Welcome to Recker Mock FTP Server',
|
|
28
|
+
delay: 0,
|
|
29
|
+
...options,
|
|
30
|
+
};
|
|
31
|
+
this.addDefaultFiles();
|
|
32
|
+
}
|
|
33
|
+
get isRunning() {
|
|
34
|
+
return this.started;
|
|
35
|
+
}
|
|
36
|
+
get port() {
|
|
37
|
+
return this.options.port;
|
|
38
|
+
}
|
|
39
|
+
get host() {
|
|
40
|
+
return this.options.host;
|
|
41
|
+
}
|
|
42
|
+
get url() {
|
|
43
|
+
return `ftp://${this.options.host}:${this.options.port}`;
|
|
44
|
+
}
|
|
45
|
+
get statistics() {
|
|
46
|
+
return { ...this.stats };
|
|
47
|
+
}
|
|
48
|
+
addFile(path, content) {
|
|
49
|
+
const normalizedPath = this.normalizePath(path);
|
|
50
|
+
const data = typeof content === 'string' ? content : content;
|
|
51
|
+
this.files.set(normalizedPath, {
|
|
52
|
+
content: data,
|
|
53
|
+
size: typeof content === 'string' ? Buffer.byteLength(content) : content.length,
|
|
54
|
+
modified: new Date(),
|
|
55
|
+
isDirectory: false,
|
|
56
|
+
});
|
|
57
|
+
const parts = normalizedPath.split('/').filter(Boolean);
|
|
58
|
+
let currentPath = '';
|
|
59
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
60
|
+
currentPath += '/' + parts[i];
|
|
61
|
+
if (!this.files.has(currentPath)) {
|
|
62
|
+
this.files.set(currentPath, {
|
|
63
|
+
content: '',
|
|
64
|
+
size: 0,
|
|
65
|
+
modified: new Date(),
|
|
66
|
+
isDirectory: true,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
addDirectory(path) {
|
|
72
|
+
const normalizedPath = this.normalizePath(path);
|
|
73
|
+
this.files.set(normalizedPath, {
|
|
74
|
+
content: '',
|
|
75
|
+
size: 0,
|
|
76
|
+
modified: new Date(),
|
|
77
|
+
isDirectory: true,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
removeFile(path) {
|
|
81
|
+
return this.files.delete(this.normalizePath(path));
|
|
82
|
+
}
|
|
83
|
+
getFile(path) {
|
|
84
|
+
return this.files.get(this.normalizePath(path));
|
|
85
|
+
}
|
|
86
|
+
listDirectory(path) {
|
|
87
|
+
const normalizedPath = this.normalizePath(path);
|
|
88
|
+
const prefix = normalizedPath === '/' ? '/' : normalizedPath + '/';
|
|
89
|
+
const entries = [];
|
|
90
|
+
for (const [filePath] of this.files) {
|
|
91
|
+
if (filePath.startsWith(prefix) && filePath !== normalizedPath) {
|
|
92
|
+
const relative = filePath.substring(prefix.length);
|
|
93
|
+
const firstPart = relative.split('/')[0];
|
|
94
|
+
if (firstPart && !entries.includes(firstPart)) {
|
|
95
|
+
entries.push(firstPart);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return entries.sort();
|
|
100
|
+
}
|
|
101
|
+
clearFiles() {
|
|
102
|
+
this.files.clear();
|
|
103
|
+
this.addDefaultFiles();
|
|
104
|
+
}
|
|
105
|
+
normalizePath(path) {
|
|
106
|
+
let normalized = path.replace(/\/+/g, '/');
|
|
107
|
+
if (!normalized.startsWith('/')) {
|
|
108
|
+
normalized = '/' + normalized;
|
|
109
|
+
}
|
|
110
|
+
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
111
|
+
normalized = normalized.slice(0, -1);
|
|
112
|
+
}
|
|
113
|
+
return normalized;
|
|
114
|
+
}
|
|
115
|
+
addDefaultFiles() {
|
|
116
|
+
this.addDirectory('/');
|
|
117
|
+
this.addFile('/welcome.txt', 'Welcome to the FTP server!\nThis is a test file.');
|
|
118
|
+
this.addFile('/readme.md', '# Mock FTP Server\n\nThis is a mock FTP server for testing.');
|
|
119
|
+
this.addDirectory('/data');
|
|
120
|
+
this.addFile('/data/sample.json', JSON.stringify({ message: 'Hello', count: 42 }, null, 2));
|
|
121
|
+
this.addFile('/data/config.txt', 'host=localhost\nport=8080\ndebug=true');
|
|
122
|
+
this.addDirectory('/public');
|
|
123
|
+
this.addFile('/public/index.html', '<html><body><h1>Public Files</h1></body></html>');
|
|
124
|
+
}
|
|
125
|
+
async start() {
|
|
126
|
+
if (this.started) {
|
|
127
|
+
throw new Error('Server already started');
|
|
128
|
+
}
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
this.server = net.createServer((socket) => {
|
|
131
|
+
this.handleConnection(socket);
|
|
132
|
+
});
|
|
133
|
+
this.server.on('error', (err) => {
|
|
134
|
+
this.emit('error', err);
|
|
135
|
+
if (!this.started) {
|
|
136
|
+
reject(err);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
this.server.listen(this.options.port, this.options.host, () => {
|
|
140
|
+
this.started = true;
|
|
141
|
+
this.emit('start');
|
|
142
|
+
resolve();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async stop() {
|
|
147
|
+
if (!this.started || !this.server)
|
|
148
|
+
return;
|
|
149
|
+
for (const session of this.sessions.values()) {
|
|
150
|
+
session.passiveSocket?.close();
|
|
151
|
+
session.socket.end();
|
|
152
|
+
}
|
|
153
|
+
this.sessions.clear();
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
this.server.close(() => {
|
|
156
|
+
this.server = null;
|
|
157
|
+
this.started = false;
|
|
158
|
+
this.emit('stop');
|
|
159
|
+
resolve();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
reset() {
|
|
164
|
+
this.stats = {
|
|
165
|
+
connectionsTotal: 0,
|
|
166
|
+
commandsReceived: 0,
|
|
167
|
+
filesDownloaded: 0,
|
|
168
|
+
filesUploaded: 0,
|
|
169
|
+
bytesTransferred: 0,
|
|
170
|
+
commandLog: [],
|
|
171
|
+
};
|
|
172
|
+
this.clearFiles();
|
|
173
|
+
this.emit('reset');
|
|
174
|
+
}
|
|
175
|
+
handleConnection(socket) {
|
|
176
|
+
const sessionId = `ftp-${++this.sessionCounter}`;
|
|
177
|
+
const session = {
|
|
178
|
+
id: sessionId,
|
|
179
|
+
socket,
|
|
180
|
+
authenticated: false,
|
|
181
|
+
username: null,
|
|
182
|
+
currentDir: '/',
|
|
183
|
+
transferMode: 'BINARY',
|
|
184
|
+
passiveSocket: null,
|
|
185
|
+
connectedAt: new Date(),
|
|
186
|
+
};
|
|
187
|
+
this.sessions.set(sessionId, session);
|
|
188
|
+
this.stats.connectionsTotal++;
|
|
189
|
+
this.emit('connect', session);
|
|
190
|
+
this.sendResponse(socket, 220, this.options.welcomeMessage);
|
|
191
|
+
let inputBuffer = '';
|
|
192
|
+
socket.on('data', async (data) => {
|
|
193
|
+
inputBuffer += data.toString('utf8');
|
|
194
|
+
while (inputBuffer.includes('\r\n')) {
|
|
195
|
+
const lineEnd = inputBuffer.indexOf('\r\n');
|
|
196
|
+
const line = inputBuffer.substring(0, lineEnd);
|
|
197
|
+
inputBuffer = inputBuffer.substring(lineEnd + 2);
|
|
198
|
+
if (line) {
|
|
199
|
+
await this.handleCommand(line, session);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
socket.on('close', () => {
|
|
204
|
+
session.passiveSocket?.close();
|
|
205
|
+
this.sessions.delete(sessionId);
|
|
206
|
+
this.emit('disconnect', session);
|
|
207
|
+
});
|
|
208
|
+
socket.on('error', (err) => {
|
|
209
|
+
this.emit('error', err, session);
|
|
210
|
+
this.sessions.delete(sessionId);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async handleCommand(line, session) {
|
|
214
|
+
this.stats.commandsReceived++;
|
|
215
|
+
const spaceIndex = line.indexOf(' ');
|
|
216
|
+
const command = (spaceIndex > 0 ? line.substring(0, spaceIndex) : line).toUpperCase();
|
|
217
|
+
const args = spaceIndex > 0 ? line.substring(spaceIndex + 1) : '';
|
|
218
|
+
this.stats.commandLog.push({
|
|
219
|
+
command: line,
|
|
220
|
+
sessionId: session.id,
|
|
221
|
+
timestamp: Date.now(),
|
|
222
|
+
});
|
|
223
|
+
this.emit('command', command, args, session);
|
|
224
|
+
if (this.options.delay > 0) {
|
|
225
|
+
await new Promise((resolve) => setTimeout(resolve, this.options.delay));
|
|
226
|
+
}
|
|
227
|
+
switch (command) {
|
|
228
|
+
case 'USER':
|
|
229
|
+
this.handleUser(args, session);
|
|
230
|
+
break;
|
|
231
|
+
case 'PASS':
|
|
232
|
+
this.handlePass(args, session);
|
|
233
|
+
break;
|
|
234
|
+
case 'SYST':
|
|
235
|
+
this.sendResponse(session.socket, 215, 'UNIX Type: L8');
|
|
236
|
+
break;
|
|
237
|
+
case 'FEAT':
|
|
238
|
+
this.sendMultilineResponse(session.socket, 211, ['Features:', ' PASV', ' SIZE', ' UTF8', 'End']);
|
|
239
|
+
break;
|
|
240
|
+
case 'PWD':
|
|
241
|
+
case 'XPWD':
|
|
242
|
+
this.sendResponse(session.socket, 257, `"${session.currentDir}" is current directory`);
|
|
243
|
+
break;
|
|
244
|
+
case 'CWD':
|
|
245
|
+
case 'XCWD':
|
|
246
|
+
this.handleCwd(args, session);
|
|
247
|
+
break;
|
|
248
|
+
case 'CDUP':
|
|
249
|
+
case 'XCUP':
|
|
250
|
+
this.handleCdup(session);
|
|
251
|
+
break;
|
|
252
|
+
case 'TYPE':
|
|
253
|
+
this.handleType(args, session);
|
|
254
|
+
break;
|
|
255
|
+
case 'PASV':
|
|
256
|
+
await this.handlePasv(session);
|
|
257
|
+
break;
|
|
258
|
+
case 'LIST':
|
|
259
|
+
await this.handleList(args, session);
|
|
260
|
+
break;
|
|
261
|
+
case 'NLST':
|
|
262
|
+
await this.handleNlst(args, session);
|
|
263
|
+
break;
|
|
264
|
+
case 'RETR':
|
|
265
|
+
await this.handleRetr(args, session);
|
|
266
|
+
break;
|
|
267
|
+
case 'STOR':
|
|
268
|
+
await this.handleStor(args, session);
|
|
269
|
+
break;
|
|
270
|
+
case 'SIZE':
|
|
271
|
+
this.handleSize(args, session);
|
|
272
|
+
break;
|
|
273
|
+
case 'MDTM':
|
|
274
|
+
this.handleMdtm(args, session);
|
|
275
|
+
break;
|
|
276
|
+
case 'MKD':
|
|
277
|
+
case 'XMKD':
|
|
278
|
+
this.handleMkd(args, session);
|
|
279
|
+
break;
|
|
280
|
+
case 'RMD':
|
|
281
|
+
case 'XRMD':
|
|
282
|
+
this.handleRmd(args, session);
|
|
283
|
+
break;
|
|
284
|
+
case 'DELE':
|
|
285
|
+
this.handleDele(args, session);
|
|
286
|
+
break;
|
|
287
|
+
case 'NOOP':
|
|
288
|
+
this.sendResponse(session.socket, 200, 'NOOP ok');
|
|
289
|
+
break;
|
|
290
|
+
case 'QUIT':
|
|
291
|
+
this.sendResponse(session.socket, 221, 'Goodbye');
|
|
292
|
+
session.socket.end();
|
|
293
|
+
break;
|
|
294
|
+
default:
|
|
295
|
+
this.sendResponse(session.socket, 502, `Command not implemented: ${command}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
handleUser(username, session) {
|
|
299
|
+
session.username = username;
|
|
300
|
+
if (this.options.anonymous && (username.toLowerCase() === 'anonymous' || username.toLowerCase() === 'ftp')) {
|
|
301
|
+
session.authenticated = true;
|
|
302
|
+
this.sendResponse(session.socket, 230, 'Anonymous access granted');
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
this.sendResponse(session.socket, 331, 'Password required');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
handlePass(password, session) {
|
|
309
|
+
if (session.authenticated) {
|
|
310
|
+
this.sendResponse(session.socket, 230, 'Already logged in');
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (session.username === this.options.username && password === this.options.password) {
|
|
314
|
+
session.authenticated = true;
|
|
315
|
+
this.sendResponse(session.socket, 230, 'Login successful');
|
|
316
|
+
}
|
|
317
|
+
else if (this.options.anonymous && session.username?.toLowerCase() === 'anonymous') {
|
|
318
|
+
session.authenticated = true;
|
|
319
|
+
this.sendResponse(session.socket, 230, 'Anonymous access granted');
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
this.sendResponse(session.socket, 530, 'Login incorrect');
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
handleCwd(path, session) {
|
|
326
|
+
if (!this.requireAuth(session))
|
|
327
|
+
return;
|
|
328
|
+
const newPath = this.resolvePath(path, session.currentDir);
|
|
329
|
+
const dir = this.files.get(newPath);
|
|
330
|
+
if (dir && dir.isDirectory) {
|
|
331
|
+
session.currentDir = newPath;
|
|
332
|
+
this.sendResponse(session.socket, 250, 'Directory changed');
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
this.sendResponse(session.socket, 550, 'Directory not found');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
handleCdup(session) {
|
|
339
|
+
if (!this.requireAuth(session))
|
|
340
|
+
return;
|
|
341
|
+
if (session.currentDir !== '/') {
|
|
342
|
+
const parts = session.currentDir.split('/').filter(Boolean);
|
|
343
|
+
parts.pop();
|
|
344
|
+
session.currentDir = '/' + parts.join('/') || '/';
|
|
345
|
+
}
|
|
346
|
+
this.sendResponse(session.socket, 250, 'Directory changed');
|
|
347
|
+
}
|
|
348
|
+
handleType(type, session) {
|
|
349
|
+
const mode = type.toUpperCase();
|
|
350
|
+
if (mode === 'A' || mode === 'A N') {
|
|
351
|
+
session.transferMode = 'ASCII';
|
|
352
|
+
this.sendResponse(session.socket, 200, 'Type set to ASCII');
|
|
353
|
+
}
|
|
354
|
+
else if (mode === 'I' || mode === 'L 8') {
|
|
355
|
+
session.transferMode = 'BINARY';
|
|
356
|
+
this.sendResponse(session.socket, 200, 'Type set to Binary');
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
this.sendResponse(session.socket, 504, 'Type not supported');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async handlePasv(session) {
|
|
363
|
+
if (!this.requireAuth(session))
|
|
364
|
+
return;
|
|
365
|
+
session.passiveSocket?.close();
|
|
366
|
+
const dataPort = this.dataPortCounter++;
|
|
367
|
+
if (this.dataPortCounter > 32000)
|
|
368
|
+
this.dataPortCounter = 30000;
|
|
369
|
+
return new Promise((resolve) => {
|
|
370
|
+
session.passiveSocket = net.createServer();
|
|
371
|
+
session.passiveSocket.listen(dataPort, this.options.host, () => {
|
|
372
|
+
const p1 = Math.floor(dataPort / 256);
|
|
373
|
+
const p2 = dataPort % 256;
|
|
374
|
+
const hostParts = this.options.host.split('.');
|
|
375
|
+
this.sendResponse(session.socket, 227, `Entering Passive Mode (${hostParts.join(',')},${p1},${p2})`);
|
|
376
|
+
resolve();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
async handleList(path, session) {
|
|
381
|
+
if (!this.requireAuth(session))
|
|
382
|
+
return;
|
|
383
|
+
const targetPath = path ? this.resolvePath(path, session.currentDir) : session.currentDir;
|
|
384
|
+
const entries = this.listDirectory(targetPath);
|
|
385
|
+
this.sendResponse(session.socket, 150, 'Opening data connection');
|
|
386
|
+
const dataSocket = await this.waitForDataConnection(session);
|
|
387
|
+
if (!dataSocket) {
|
|
388
|
+
this.sendResponse(session.socket, 425, 'No data connection');
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const lines = [];
|
|
392
|
+
for (const entry of entries) {
|
|
393
|
+
const entryPath = targetPath === '/' ? `/${entry}` : `${targetPath}/${entry}`;
|
|
394
|
+
const file = this.files.get(entryPath);
|
|
395
|
+
if (file) {
|
|
396
|
+
const type = file.isDirectory ? 'd' : '-';
|
|
397
|
+
const size = file.size.toString().padStart(8);
|
|
398
|
+
const date = file.modified.toISOString().substring(0, 10);
|
|
399
|
+
lines.push(`${type}rw-r--r-- 1 user group ${size} ${date} ${entry}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
dataSocket.write(lines.join('\r\n') + '\r\n');
|
|
403
|
+
dataSocket.end();
|
|
404
|
+
this.sendResponse(session.socket, 226, 'Transfer complete');
|
|
405
|
+
}
|
|
406
|
+
async handleNlst(path, session) {
|
|
407
|
+
if (!this.requireAuth(session))
|
|
408
|
+
return;
|
|
409
|
+
const targetPath = path ? this.resolvePath(path, session.currentDir) : session.currentDir;
|
|
410
|
+
const entries = this.listDirectory(targetPath);
|
|
411
|
+
this.sendResponse(session.socket, 150, 'Opening data connection');
|
|
412
|
+
const dataSocket = await this.waitForDataConnection(session);
|
|
413
|
+
if (!dataSocket) {
|
|
414
|
+
this.sendResponse(session.socket, 425, 'No data connection');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
dataSocket.write(entries.join('\r\n') + '\r\n');
|
|
418
|
+
dataSocket.end();
|
|
419
|
+
this.sendResponse(session.socket, 226, 'Transfer complete');
|
|
420
|
+
}
|
|
421
|
+
async handleRetr(filename, session) {
|
|
422
|
+
if (!this.requireAuth(session))
|
|
423
|
+
return;
|
|
424
|
+
const filePath = this.resolvePath(filename, session.currentDir);
|
|
425
|
+
const file = this.files.get(filePath);
|
|
426
|
+
if (!file || file.isDirectory) {
|
|
427
|
+
this.sendResponse(session.socket, 550, 'File not found');
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
this.sendResponse(session.socket, 150, 'Opening data connection');
|
|
431
|
+
const dataSocket = await this.waitForDataConnection(session);
|
|
432
|
+
if (!dataSocket) {
|
|
433
|
+
this.sendResponse(session.socket, 425, 'No data connection');
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const content = typeof file.content === 'string' ? Buffer.from(file.content) : file.content;
|
|
437
|
+
dataSocket.write(content);
|
|
438
|
+
dataSocket.end();
|
|
439
|
+
this.stats.filesDownloaded++;
|
|
440
|
+
this.stats.bytesTransferred += content.length;
|
|
441
|
+
this.sendResponse(session.socket, 226, 'Transfer complete');
|
|
442
|
+
}
|
|
443
|
+
async handleStor(filename, session) {
|
|
444
|
+
if (!this.requireAuth(session))
|
|
445
|
+
return;
|
|
446
|
+
const filePath = this.resolvePath(filename, session.currentDir);
|
|
447
|
+
this.sendResponse(session.socket, 150, 'Opening data connection');
|
|
448
|
+
const dataSocket = await this.waitForDataConnection(session);
|
|
449
|
+
if (!dataSocket) {
|
|
450
|
+
this.sendResponse(session.socket, 425, 'No data connection');
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const chunks = [];
|
|
454
|
+
dataSocket.on('data', (chunk) => {
|
|
455
|
+
chunks.push(chunk);
|
|
456
|
+
});
|
|
457
|
+
dataSocket.on('end', () => {
|
|
458
|
+
const content = Buffer.concat(chunks);
|
|
459
|
+
this.addFile(filePath, content);
|
|
460
|
+
this.stats.filesUploaded++;
|
|
461
|
+
this.stats.bytesTransferred += content.length;
|
|
462
|
+
this.sendResponse(session.socket, 226, 'Transfer complete');
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
handleSize(filename, session) {
|
|
466
|
+
if (!this.requireAuth(session))
|
|
467
|
+
return;
|
|
468
|
+
const filePath = this.resolvePath(filename, session.currentDir);
|
|
469
|
+
const file = this.files.get(filePath);
|
|
470
|
+
if (!file || file.isDirectory) {
|
|
471
|
+
this.sendResponse(session.socket, 550, 'File not found');
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
this.sendResponse(session.socket, 213, file.size.toString());
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
handleMdtm(filename, session) {
|
|
478
|
+
if (!this.requireAuth(session))
|
|
479
|
+
return;
|
|
480
|
+
const filePath = this.resolvePath(filename, session.currentDir);
|
|
481
|
+
const file = this.files.get(filePath);
|
|
482
|
+
if (!file) {
|
|
483
|
+
this.sendResponse(session.socket, 550, 'File not found');
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
const date = file.modified.toISOString().replace(/[-:T]/g, '').substring(0, 14);
|
|
487
|
+
this.sendResponse(session.socket, 213, date);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
handleMkd(dirname, session) {
|
|
491
|
+
if (!this.requireAuth(session))
|
|
492
|
+
return;
|
|
493
|
+
const dirPath = this.resolvePath(dirname, session.currentDir);
|
|
494
|
+
this.addDirectory(dirPath);
|
|
495
|
+
this.sendResponse(session.socket, 257, `"${dirPath}" created`);
|
|
496
|
+
}
|
|
497
|
+
handleRmd(dirname, session) {
|
|
498
|
+
if (!this.requireAuth(session))
|
|
499
|
+
return;
|
|
500
|
+
const dirPath = this.resolvePath(dirname, session.currentDir);
|
|
501
|
+
if (this.removeFile(dirPath)) {
|
|
502
|
+
this.sendResponse(session.socket, 250, 'Directory removed');
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
this.sendResponse(session.socket, 550, 'Directory not found');
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
handleDele(filename, session) {
|
|
509
|
+
if (!this.requireAuth(session))
|
|
510
|
+
return;
|
|
511
|
+
const filePath = this.resolvePath(filename, session.currentDir);
|
|
512
|
+
if (this.removeFile(filePath)) {
|
|
513
|
+
this.sendResponse(session.socket, 250, 'File deleted');
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
this.sendResponse(session.socket, 550, 'File not found');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
requireAuth(session) {
|
|
520
|
+
if (!session.authenticated) {
|
|
521
|
+
this.sendResponse(session.socket, 530, 'Please login first');
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
resolvePath(path, currentDir) {
|
|
527
|
+
if (path.startsWith('/')) {
|
|
528
|
+
return this.normalizePath(path);
|
|
529
|
+
}
|
|
530
|
+
return this.normalizePath(currentDir + '/' + path);
|
|
531
|
+
}
|
|
532
|
+
sendResponse(socket, code, message) {
|
|
533
|
+
socket.write(`${code} ${message}\r\n`);
|
|
534
|
+
}
|
|
535
|
+
sendMultilineResponse(socket, code, lines) {
|
|
536
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
537
|
+
socket.write(`${code}-${lines[i]}\r\n`);
|
|
538
|
+
}
|
|
539
|
+
socket.write(`${code} ${lines[lines.length - 1]}\r\n`);
|
|
540
|
+
}
|
|
541
|
+
async waitForDataConnection(session) {
|
|
542
|
+
if (!session.passiveSocket)
|
|
543
|
+
return null;
|
|
544
|
+
return new Promise((resolve) => {
|
|
545
|
+
const timeout = setTimeout(() => {
|
|
546
|
+
session.passiveSocket?.close();
|
|
547
|
+
resolve(null);
|
|
548
|
+
}, 5000);
|
|
549
|
+
session.passiveSocket.once('connection', (socket) => {
|
|
550
|
+
clearTimeout(timeout);
|
|
551
|
+
session.passiveSocket.close();
|
|
552
|
+
session.passiveSocket = null;
|
|
553
|
+
resolve(socket);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
static async create(options = {}) {
|
|
558
|
+
const server = new MockFtpServer(options);
|
|
559
|
+
await server.start();
|
|
560
|
+
return server;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { Transport } from '../types/index.js';
|
|
3
|
+
export interface MockHlsServerOptions {
|
|
4
|
+
mode?: 'vod' | 'live' | 'event';
|
|
5
|
+
segmentDuration?: number;
|
|
6
|
+
segmentCount?: number;
|
|
7
|
+
windowSize?: number;
|
|
8
|
+
startSequence?: number;
|
|
9
|
+
realtime?: boolean;
|
|
10
|
+
segmentInterval?: number;
|
|
11
|
+
multiQuality?: boolean;
|
|
12
|
+
variants?: MockHlsVariant[];
|
|
13
|
+
encrypted?: boolean;
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
delay?: number;
|
|
16
|
+
segmentDataGenerator?: (sequence: number, variant?: string) => Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
export interface MockHlsVariant {
|
|
19
|
+
name: string;
|
|
20
|
+
bandwidth: number;
|
|
21
|
+
resolution?: string;
|
|
22
|
+
codecs?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface MockHlsSegment {
|
|
25
|
+
sequence: number;
|
|
26
|
+
duration: number;
|
|
27
|
+
data: Uint8Array;
|
|
28
|
+
addedAt: number;
|
|
29
|
+
programDateTime?: Date;
|
|
30
|
+
discontinuity?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface MockHlsStats {
|
|
33
|
+
playlistRequests: number;
|
|
34
|
+
segmentRequests: number;
|
|
35
|
+
segmentsServed: number;
|
|
36
|
+
bytesServed: number;
|
|
37
|
+
requestLog: Array<{
|
|
38
|
+
url: string;
|
|
39
|
+
timestamp: number;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
export declare class MockHlsServer extends EventEmitter {
|
|
43
|
+
private options;
|
|
44
|
+
private segments;
|
|
45
|
+
private currentSequence;
|
|
46
|
+
private ended;
|
|
47
|
+
private started;
|
|
48
|
+
private realtimeInterval;
|
|
49
|
+
private startTime;
|
|
50
|
+
private stats;
|
|
51
|
+
constructor(options?: MockHlsServerOptions);
|
|
52
|
+
get isRunning(): boolean;
|
|
53
|
+
get isEnded(): boolean;
|
|
54
|
+
get manifestUrl(): string;
|
|
55
|
+
get segmentCount(): number;
|
|
56
|
+
get statistics(): MockHlsStats;
|
|
57
|
+
get transport(): Transport;
|
|
58
|
+
start(): Promise<void>;
|
|
59
|
+
stop(): Promise<void>;
|
|
60
|
+
endStream(): void;
|
|
61
|
+
reset(): void;
|
|
62
|
+
addSegment(variant?: string, options?: Partial<MockHlsSegment>): MockHlsSegment;
|
|
63
|
+
addDiscontinuity(variant?: string): void;
|
|
64
|
+
getSegments(variant?: string): MockHlsSegment[];
|
|
65
|
+
private handleRequest;
|
|
66
|
+
private handleMasterPlaylist;
|
|
67
|
+
private handleMediaPlaylist;
|
|
68
|
+
private handleSegment;
|
|
69
|
+
private handleKey;
|
|
70
|
+
private createResponse;
|
|
71
|
+
private initializeVodSegments;
|
|
72
|
+
private initializeLiveSegments;
|
|
73
|
+
private addSegmentToAllVariants;
|
|
74
|
+
private startRealtimeSegmentGeneration;
|
|
75
|
+
private defaultSegmentGenerator;
|
|
76
|
+
static create(options?: MockHlsServerOptions): Promise<MockHlsServer>;
|
|
77
|
+
}
|
|
78
|
+
export declare function createMockHlsVod(segmentCount?: number, options?: Omit<MockHlsServerOptions, 'mode' | 'segmentCount'>): Promise<MockHlsServer>;
|
|
79
|
+
export declare function createMockHlsLive(options?: Omit<MockHlsServerOptions, 'mode'>): Promise<MockHlsServer>;
|
|
80
|
+
export declare function createMockHlsMultiQuality(options?: Omit<MockHlsServerOptions, 'multiQuality'>): Promise<MockHlsServer>;
|
|
81
|
+
//# sourceMappingURL=mock-hls-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mock-hls-server.d.ts","sourceRoot":"","sources":["../../src/testing/mock-hls-server.ts"],"names":[],"mappings":"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"}
|