@mahidsec/nest 1.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/src/server.ts ADDED
@@ -0,0 +1,524 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import { createServer } from 'http';
4
+ import fs from 'fs';
5
+ import { readFile, writeFile, readdir, stat, rename } from 'fs/promises';
6
+ import path from 'path';
7
+ import crypto from 'crypto';
8
+ import { spawn, execSync } from 'child_process';
9
+ import { homedir, platform, arch } from 'os';
10
+ import { COURSES_PATH, COURSE_PROGRESS_PATH, DATA_DIR } from './config.js';
11
+ import type { Course, CourseWithVideos, FileType, FileItem, DirectoryScanResult } from './types.js';
12
+
13
+ const app = express();
14
+ const httpServer = createServer(app);
15
+
16
+ const PORT = Number(process.env.PORT) || 6969;
17
+ const IS_TUNNEL = process.env.NEST_TUNNEL === 'true';
18
+
19
+ // ─── CORS: localhost only + tunnel support ───
20
+ app.use(cors());
21
+
22
+ app.use(express.json());
23
+
24
+ // ─── Security headers ───
25
+ app.use((_req, res, next) => {
26
+ res.setHeader('Content-Security-Policy',
27
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' blob: data:; media-src 'self' blob:;");
28
+ res.setHeader('X-Content-Type-Options', 'nosniff');
29
+ res.setHeader('X-Frame-Options', 'DENY');
30
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
31
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
32
+ next();
33
+ });
34
+
35
+ // ─── Serve static frontend ───
36
+ const publicDir = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'frontend', 'dist');
37
+ if (fs.existsSync(publicDir)) {
38
+ app.use(express.static(publicDir, { maxAge: '1h' }));
39
+ }
40
+
41
+ // ─── Helpers ───
42
+
43
+ const VALID_ICONS = [
44
+ 'Zap', 'Music', 'Languages', 'BookOpen', 'DollarSign', 'Code', 'Paintbrush', 'Microscope',
45
+ 'BarChart3', 'Dumbbell', 'Camera', 'Gamepad2', 'Brain', 'Scale', 'HeartPulse', 'Wrench',
46
+ 'GraduationCap', 'Briefcase',
47
+ ];
48
+
49
+ const getCourses = async (): Promise<Course[]> => {
50
+ try {
51
+ const data = await readFile(COURSES_PATH, 'utf-8');
52
+ return JSON.parse(data);
53
+ } catch { return []; }
54
+ };
55
+
56
+ const saveCourses = async (courses: Course[]): Promise<void> => {
57
+ const tmp = COURSES_PATH + '.tmp';
58
+ await writeFile(tmp, JSON.stringify(courses, null, 2));
59
+ await rename(tmp, COURSES_PATH);
60
+ };
61
+
62
+ const naturalCompare = (a: string, b: string): number =>
63
+ a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
64
+
65
+ const HIDDEN_EXTS = ['.srt', '.sub', '.ass', '.ssa', '.idx', '.vtt'];
66
+
67
+ const isHiddenMediaSub = (filename: string): boolean =>
68
+ HIDDEN_EXTS.includes(path.extname(filename).toLowerCase());
69
+
70
+ const getFileType = (filename: string): FileType => {
71
+ const ext = path.extname(filename).toLowerCase();
72
+ const videoExts = ['.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v'];
73
+ const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
74
+ const codeExts = ['.js', '.ts', '.py', '.java', '.c', '.cpp', '.h', '.cs', '.go', '.rs', '.rb', '.php', '.swift', '.kt', '.html', '.css', '.scss', '.json', '.xml', '.yaml', '.yml', '.sh', '.bash', '.sql', '.r', '.jsx', '.tsx', '.vue', '.svelte'];
75
+ const docExts = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.odt'];
76
+ const textExts = ['.txt', '.md', '.rtf', '.log', '.csv'];
77
+ const linkExts = ['.url', '.webloc', '.desktop', '.lnk'];
78
+ if (videoExts.includes(ext)) return 'video';
79
+ if (imageExts.includes(ext)) return 'image';
80
+ if (codeExts.includes(ext)) return 'code';
81
+ if (docExts.includes(ext)) return 'document';
82
+ if (textExts.includes(ext)) return 'text';
83
+ if (linkExts.includes(ext)) return 'link';
84
+ return 'other';
85
+ };
86
+
87
+ const scanDirectory = async (dirPath: string, relativeTo: string): Promise<DirectoryScanResult> => {
88
+ const entries = await readdir(dirPath, { withFileTypes: true });
89
+ const folders: FileItem[] = [];
90
+ const files: FileItem[] = [];
91
+
92
+ for (const entry of entries) {
93
+ if (entry.name.startsWith('.')) continue;
94
+ const fullPath = path.join(dirPath, entry.name);
95
+ const relPath = path.relative(relativeTo, fullPath);
96
+
97
+ if (entry.isDirectory()) {
98
+ const children = await scanDirectory(fullPath, relativeTo);
99
+ folders.push({
100
+ name: entry.name,
101
+ type: 'folder',
102
+ path: relPath,
103
+ children: children.items,
104
+ totalVideos: children.totalVideos,
105
+ });
106
+ } else {
107
+ if (isHiddenMediaSub(entry.name)) continue;
108
+ const fileType = getFileType(entry.name);
109
+ const s = await stat(fullPath);
110
+ files.push({
111
+ name: entry.name,
112
+ type: fileType,
113
+ path: relPath,
114
+ size: s.size,
115
+ });
116
+ }
117
+ }
118
+
119
+ folders.sort((a, b) => naturalCompare(a.name, b.name));
120
+ files.sort((a, b) => naturalCompare(a.name, b.name));
121
+
122
+ const items = [...folders, ...files];
123
+ const totalVideos = files.filter(f => f.type === 'video').length
124
+ + folders.reduce((sum, f) => sum + (f.totalVideos || 0), 0);
125
+
126
+ return { items, totalVideos };
127
+ };
128
+
129
+ const countVideoFiles = async (dirPath: string): Promise<number> => {
130
+ try { await stat(dirPath); } catch { return 0; }
131
+ let count = 0;
132
+ const videoExts = ['.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v'];
133
+ try {
134
+ const entries = await readdir(dirPath, { withFileTypes: true });
135
+ for (const entry of entries) {
136
+ if (entry.name.startsWith('.')) continue;
137
+ if (entry.isDirectory()) count += await countVideoFiles(path.join(dirPath, entry.name));
138
+ else if (videoExts.includes(path.extname(entry.name).toLowerCase())) count++;
139
+ }
140
+ } catch {}
141
+ return count;
142
+ };
143
+
144
+ // ─── Video count cache (30s TTL, max 200 entries) ───
145
+ const videoCountCache = new Map<string, { count: number; ts: number }>();
146
+ const CACHE_TTL = 30_000;
147
+ const CACHE_MAX = 200;
148
+
149
+ const getCachedVideoCount = async (dirPath: string): Promise<number> => {
150
+ const cached = videoCountCache.get(dirPath);
151
+ if (cached && Date.now() - cached.ts < CACHE_TTL) return cached.count;
152
+ const count = await countVideoFiles(dirPath);
153
+ if (videoCountCache.size >= CACHE_MAX) {
154
+ const oldest = videoCountCache.keys().next().value;
155
+ if (oldest) videoCountCache.delete(oldest);
156
+ }
157
+ videoCountCache.set(dirPath, { count, ts: Date.now() });
158
+ return count;
159
+ };
160
+
161
+ const invalidateVideoCount = (dirPath: string) => {
162
+ videoCountCache.delete(dirPath);
163
+ };
164
+
165
+ const getCourseProgressData = async (): Promise<Record<string, Record<string, boolean>>> => {
166
+ try {
167
+ const data = await readFile(COURSE_PROGRESS_PATH, 'utf-8');
168
+ return JSON.parse(data);
169
+ } catch { return {}; }
170
+ };
171
+
172
+ const saveCourseProgressData = async (data: Record<string, Record<string, boolean>>): Promise<void> => {
173
+ const tmp = COURSE_PROGRESS_PATH + '.tmp';
174
+ await writeFile(tmp, JSON.stringify(data));
175
+ await rename(tmp, COURSE_PROGRESS_PATH);
176
+ };
177
+
178
+ // ─── Cloudflare Tunnel (server-side for web UI control) ───
179
+
180
+ let tunnelChild: ReturnType<typeof spawn> | null = null;
181
+ let tunnelPublicUrl: string | null = null;
182
+
183
+ const NEST_BIN_DIR = path.join(homedir(), '.nest', 'bin');
184
+ const CLOUDFLARED_PATH = path.join(NEST_BIN_DIR, 'cloudflared');
185
+
186
+ function getCloudflaredDownloadUrl(): string {
187
+ const p = platform();
188
+ const a = arch();
189
+ const osMap: Record<string, string> = { linux: 'linux', darwin: 'darwin' };
190
+ const archMap: Record<string, string> = { x64: 'amd64', arm64: 'arm64' };
191
+ return `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${osMap[p] || p}-${archMap[a] || a}`;
192
+ }
193
+
194
+ function findCloudflared(): string | null {
195
+ // 1. Check system PATH
196
+ try {
197
+ const result = execSync('which cloudflared 2>/dev/null || command -v cloudflared 2>/dev/null').toString().trim();
198
+ if (result && fs.existsSync(result)) return result;
199
+ } catch {}
200
+ // 2. Check ~/.nest/bin/cloudflared
201
+ if (fs.existsSync(CLOUDFLARED_PATH)) return CLOUDFLARED_PATH;
202
+ // 3. Auto-download
203
+ console.log('[Tunnel] cloudflared not found — downloading...');
204
+ try {
205
+ if (!fs.existsSync(NEST_BIN_DIR)) fs.mkdirSync(NEST_BIN_DIR, { recursive: true });
206
+ execSync(`curl -fSL -o "${CLOUDFLARED_PATH}" "${getCloudflaredDownloadUrl()}"`, { stdio: 'inherit' });
207
+ fs.chmodSync(CLOUDFLARED_PATH, 0o755);
208
+ console.log('[Tunnel] cloudflared installed');
209
+ return CLOUDFLARED_PATH;
210
+ } catch {
211
+ console.error('[Tunnel] Failed to download cloudflared');
212
+ return null;
213
+ }
214
+ }
215
+
216
+ app.get('/api/tunnel', (_req, res) => {
217
+ res.json({ active: !!tunnelChild && !!tunnelPublicUrl, url: tunnelPublicUrl });
218
+ });
219
+
220
+ app.post('/api/tunnel/start', async (_req, res) => {
221
+ if (tunnelChild) {
222
+ return res.json({ success: true, url: tunnelPublicUrl });
223
+ }
224
+
225
+ const bin = findCloudflared();
226
+ if (!bin) {
227
+ return res.status(400).json({ error: 'cloudflared not found. Run `cloudflared tunnel --url http://localhost:${PORT}` manually or install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/' });
228
+ }
229
+
230
+ tunnelChild = spawn(bin, ['tunnel', '--url', `http://localhost:${PORT}`], {
231
+ stdio: ['ignore', 'pipe', 'pipe'],
232
+ });
233
+
234
+ let resolved = false;
235
+
236
+ const extractUrl = (text: string) => {
237
+ if (resolved) return;
238
+ const match = text.match(/https?:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
239
+ if (match) {
240
+ resolved = true;
241
+ tunnelPublicUrl = match[0];
242
+ }
243
+ };
244
+
245
+ tunnelChild.stdout?.on('data', (data) => {
246
+ extractUrl(data.toString());
247
+ });
248
+
249
+ tunnelChild.stderr?.on('data', (data) => {
250
+ extractUrl(data.toString());
251
+ });
252
+
253
+ tunnelChild.on('close', () => {
254
+ tunnelChild = null;
255
+ tunnelPublicUrl = null;
256
+ });
257
+
258
+ tunnelChild.on('error', () => {
259
+ tunnelChild = null;
260
+ tunnelPublicUrl = null;
261
+ });
262
+
263
+ // Wait up to 15s for URL
264
+ const tunnelUrl = await new Promise<string | null>((resolve) => {
265
+ if (tunnelPublicUrl) return resolve(tunnelPublicUrl);
266
+ const timer = setTimeout(() => { clearInterval(interval); resolve(null); }, 15000);
267
+ const interval = setInterval(() => {
268
+ if (tunnelPublicUrl) { clearTimeout(timer); clearInterval(interval); resolve(tunnelPublicUrl); }
269
+ }, 200);
270
+ });
271
+
272
+ if (tunnelUrl) {
273
+ res.json({ success: true, url: tunnelUrl });
274
+ } else {
275
+ res.status(500).json({ error: 'Tunnel failed to start (timeout)' });
276
+ }
277
+ });
278
+
279
+ app.post('/api/tunnel/stop', (_req, res) => {
280
+ if (tunnelChild) {
281
+ try { tunnelChild.kill('SIGTERM'); } catch {}
282
+ tunnelChild = null;
283
+ tunnelPublicUrl = null;
284
+ }
285
+ res.json({ success: true });
286
+ });
287
+
288
+ // ─── Course Routes (no auth — local only) ───
289
+
290
+ app.get('/api/courses', async (_req, res) => {
291
+ try {
292
+ const courses = await getCourses();
293
+ const enriched: CourseWithVideos[] = await Promise.all(courses.map(async (c) => ({
294
+ ...c,
295
+ totalVideos: await getCachedVideoCount(c.localPath),
296
+ })));
297
+ res.json(enriched);
298
+ } catch (err) {
299
+ console.error('[Courses] List error:', err);
300
+ res.status(500).json({ error: 'Failed to load courses' });
301
+ }
302
+ });
303
+
304
+ app.post('/api/courses', async (req, res) => {
305
+ const { name, localPath, icon, subtitle } = req.body;
306
+ if (!name || !localPath) return res.status(400).json({ error: 'Name and localPath are required' });
307
+
308
+ // Validate icon
309
+ if (icon && !VALID_ICONS.includes(icon)) {
310
+ return res.status(400).json({ error: `Invalid icon. Valid icons: ${VALID_ICONS.join(', ')}` });
311
+ }
312
+
313
+ // Validate subtitle length
314
+ if (subtitle && subtitle.length > 200) {
315
+ return res.status(400).json({ error: 'Subtitle must be 200 characters or less' });
316
+ }
317
+
318
+ const resolved = path.resolve(localPath);
319
+ const s = await stat(resolved).catch(() => null);
320
+ if (!s || !s.isDirectory()) {
321
+ return res.status(400).json({ error: 'Path does not exist or is not a directory' });
322
+ }
323
+
324
+ const courses = await getCourses();
325
+ const course = {
326
+ id: crypto.randomUUID(),
327
+ name,
328
+ subtitle: subtitle || '',
329
+ localPath: resolved,
330
+ icon: icon || 'BookOpen',
331
+ createdAt: new Date().toISOString(),
332
+ };
333
+ courses.push(course);
334
+ await saveCourses(courses);
335
+ invalidateVideoCount(resolved);
336
+ console.log(`[Courses] Added "${name}" → ${resolved}`);
337
+ res.json({ success: true, course });
338
+ });
339
+
340
+ app.delete('/api/courses/:id', async (req, res) => {
341
+ const courses = await getCourses();
342
+ const target = courses.find((c) => c.id === req.params.id);
343
+ if (!target) return res.status(404).json({ error: 'Course not found' });
344
+ invalidateVideoCount(target.localPath);
345
+ await saveCourses(courses.filter((c) => c.id !== req.params.id));
346
+ console.log(`[Courses] Removed course ${req.params.id}`);
347
+ res.json({ success: true });
348
+ });
349
+
350
+ app.get('/api/courses/:id/browse', async (req, res) => {
351
+ const courses = await getCourses();
352
+ const course = courses.find((c) => c.id === req.params.id);
353
+ if (!course) return res.status(404).json({ error: 'Course not found' });
354
+
355
+ try {
356
+ await stat(course.localPath);
357
+ } catch {
358
+ return res.status(404).json({ error: 'Course directory not found on disk' });
359
+ }
360
+
361
+ try {
362
+ const result = await scanDirectory(course.localPath, course.localPath);
363
+ invalidateVideoCount(course.localPath);
364
+ res.json({ ...course, ...result });
365
+ } catch (err: unknown) {
366
+ console.error('[Courses] Browse error:', err);
367
+ res.status(500).json({ error: 'Failed to scan directory' });
368
+ }
369
+ });
370
+
371
+ app.get('/api/courses/:id/file', async (req, res) => {
372
+ try {
373
+ const courses = await getCourses();
374
+ const course = courses.find((c) => c.id === req.params.id);
375
+ if (!course) return res.status(404).json({ error: 'Course not found' });
376
+
377
+ const filePath = req.query.path as string;
378
+ if (!filePath) return res.status(400).json({ error: 'File path required' });
379
+
380
+ const resolved = path.resolve(course.localPath, filePath);
381
+ // Canonicalize both paths to prevent symlink escapes
382
+ const courseRoot = await fs.promises.realpath(path.resolve(course.localPath));
383
+ let realResolved: string;
384
+ try {
385
+ realResolved = await fs.promises.realpath(resolved);
386
+ } catch {
387
+ // File doesn't exist yet or broken symlink — fall back to resolved path
388
+ // but still validate the resolved path is under courseRoot
389
+ realResolved = resolved;
390
+ }
391
+ if (!realResolved.startsWith(courseRoot + path.sep) && realResolved !== courseRoot) {
392
+ return res.status(403).json({ error: 'Access denied' });
393
+ }
394
+
395
+ const fileStat = await stat(realResolved).catch(() => null);
396
+ if (!fileStat) return res.status(404).json({ error: 'File not found' });
397
+
398
+ const ext = path.extname(realResolved).toLowerCase();
399
+ const fileType = getFileType(path.basename(realResolved));
400
+
401
+ if (fileType === 'text' || fileType === 'code') {
402
+ const content = await readFile(realResolved, 'utf-8');
403
+ return res.json({ type: fileType, content, name: path.basename(realResolved) });
404
+ }
405
+
406
+ if (fileType === 'link') {
407
+ try {
408
+ const content = await readFile(realResolved, 'utf-8');
409
+ const urlMatch = content.match(/URL=(.+)/i) || content.match(/https?:\/\/[^\s]+/);
410
+ return res.json({ type: 'link', url: urlMatch ? urlMatch[1] || urlMatch[0] : content.trim(), name: path.basename(realResolved) });
411
+ } catch {
412
+ return res.status(500).json({ error: 'Failed to read link file' });
413
+ }
414
+ }
415
+
416
+ const mimeMap: Record<string, string> = {
417
+ '.mp4': 'video/mp4', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo',
418
+ '.mov': 'video/quicktime', '.webm': 'video/webm', '.m4v': 'video/mp4',
419
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
420
+ '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp', '.svg': 'image/svg+xml',
421
+ '.pdf': 'application/pdf',
422
+ '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
423
+ };
424
+ const contentType = mimeMap[ext] || 'application/octet-stream';
425
+
426
+ const safePipe = (stream: fs.ReadStream, response: typeof res) => {
427
+ stream.on('error', () => { stream.destroy(); });
428
+ req.on('close', () => { stream.destroy(); });
429
+ stream.pipe(response);
430
+ };
431
+
432
+ if (fileType === 'video') {
433
+ const range = req.headers.range;
434
+ if (range) {
435
+ const parts = range.replace(/bytes=/, '').split('-');
436
+ let start = parseInt(parts[0] || '0', 10);
437
+ let end = parts[1] ? parseInt(parts[1], 10) : fileStat.size - 1;
438
+ // Validate and clamp range bounds
439
+ if (isNaN(start) || start < 0) start = 0;
440
+ if (isNaN(end) || end >= fileStat.size) end = fileStat.size - 1;
441
+ if (start > end) start = end;
442
+ res.writeHead(206, {
443
+ 'Content-Range': `bytes ${start}-${end}/${fileStat.size}`,
444
+ 'Accept-Ranges': 'bytes',
445
+ 'Content-Length': end - start + 1,
446
+ 'Content-Type': contentType,
447
+ });
448
+ safePipe(fs.createReadStream(realResolved, { start, end }), res);
449
+ } else {
450
+ res.writeHead(200, {
451
+ 'Content-Length': fileStat.size,
452
+ 'Content-Type': contentType,
453
+ 'Accept-Ranges': 'bytes',
454
+ });
455
+ safePipe(fs.createReadStream(realResolved), res);
456
+ }
457
+ return;
458
+ }
459
+
460
+ res.writeHead(200, {
461
+ 'Content-Length': fileStat.size,
462
+ 'Content-Type': contentType,
463
+ 'Cache-Control': 'public, max-age=3600',
464
+ });
465
+ safePipe(fs.createReadStream(realResolved), res);
466
+ } catch (err) {
467
+ console.error('[Courses] File error:', err);
468
+ res.status(500).json({ error: 'Failed to serve file' });
469
+ }
470
+ });
471
+
472
+ // ─── Course Progress (local, no auth) ───
473
+
474
+ app.get('/api/courses/:id/progress', async (_req, res) => {
475
+ const courseId = _req.params.id;
476
+ const all = await getCourseProgressData();
477
+ res.json(all[courseId] || {});
478
+ });
479
+
480
+ app.put('/api/courses/:id/progress', async (req, res) => {
481
+ const courseId = req.params.id;
482
+ const { filePath, watched } = req.body;
483
+ if (!filePath) return res.status(400).json({ error: 'filePath required' });
484
+ const all = await getCourseProgressData();
485
+ if (!all[courseId]) all[courseId] = {};
486
+ if (watched) all[courseId][filePath] = true;
487
+ else delete all[courseId][filePath];
488
+ await saveCourseProgressData(all);
489
+ res.json(all[courseId]);
490
+ });
491
+
492
+ // ─── SPA fallback ───
493
+ app.get('*', (_req, res) => {
494
+ const indexPath = path.join(publicDir, 'index.html');
495
+ if (fs.existsSync(indexPath)) {
496
+ res.sendFile(indexPath);
497
+ } else {
498
+ res.status(404).send('Frontend not built. Run: npm run build');
499
+ }
500
+ });
501
+
502
+ // ─── Start ───
503
+ httpServer.listen(PORT, '0.0.0.0', () => {
504
+ console.log(`[Nest] Server running on http://localhost:${PORT}`);
505
+ console.log(`[Nest] Data dir: ${DATA_DIR}`);
506
+ if (IS_TUNNEL) console.log(`[Nest] Tunnel mode: enabled`);
507
+ });
508
+
509
+ // ─── Graceful Shutdown ───
510
+ let shuttingDown = false;
511
+ const shutdown = (signal: string) => {
512
+ if (shuttingDown) return;
513
+ shuttingDown = true;
514
+ if (tunnelChild) {
515
+ try { tunnelChild.kill('SIGTERM'); } catch {}
516
+ tunnelChild = null;
517
+ tunnelPublicUrl = null;
518
+ }
519
+ (httpServer as any).closeAllConnections?.();
520
+ httpServer.close(() => process.exit(0));
521
+ setTimeout(() => process.exit(1), 3000);
522
+ };
523
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
524
+ process.on('SIGINT', () => shutdown('SIGINT'));
package/src/types.ts ADDED
@@ -0,0 +1,36 @@
1
+ // ─── Nest Type Definitions ───
2
+
3
+ export interface Course {
4
+ id: string;
5
+ name: string;
6
+ subtitle: string;
7
+ localPath: string;
8
+ icon: string;
9
+ createdAt: string;
10
+ }
11
+
12
+ export type FileType = 'video' | 'text' | 'code' | 'document' | 'link' | 'image' | 'other';
13
+
14
+ export interface FileItem {
15
+ name: string;
16
+ type: FileType | 'folder';
17
+ path: string;
18
+ size?: number;
19
+ children?: FileItem[];
20
+ totalVideos?: number;
21
+ }
22
+
23
+ export interface DirectoryScanResult {
24
+ items: FileItem[];
25
+ totalVideos: number;
26
+ }
27
+
28
+ export interface CourseWithVideos extends Course {
29
+ totalVideos: number;
30
+ }
31
+
32
+ export interface CourseProgress {
33
+ [courseId: string]: {
34
+ [filePath: string]: boolean;
35
+ };
36
+ }