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