@superdangerous/app-framework 4.9.2 → 4.14.0

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.
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Standardized Logging API Router
3
- * Provides consistent logging endpoints for all SuperDangerous applications
3
+ * Provides consistent logging endpoints for all EpiSensor applications
4
+ *
5
+ * SECURITY WARNING: This router exposes sensitive log files without authentication.
6
+ * Before production use, implement proper authentication middleware.
4
7
  */
5
8
 
6
9
  import express, { Request, Response } from "express";
@@ -26,13 +29,99 @@ export interface LogFile {
26
29
  path?: string;
27
30
  }
28
31
 
32
+ /**
33
+ * Validate and sanitize file path to prevent directory traversal
34
+ */
35
+ function validateFilePath(filename: string, baseDir: string): string | null {
36
+ if (!filename || typeof filename !== "string") {
37
+ return null;
38
+ }
39
+
40
+ // Normalize the filename and resolve path
41
+ const safeName = path.normalize(filename).replace(/^(\.\.[/\\])+/, "");
42
+ const resolvedPath = path.resolve(baseDir, safeName);
43
+ const resolvedBase = path.resolve(baseDir);
44
+
45
+ // Ensure the resolved path is within the base directory
46
+ if (
47
+ !resolvedPath.startsWith(resolvedBase + path.sep) &&
48
+ resolvedPath !== resolvedBase
49
+ ) {
50
+ return null;
51
+ }
52
+
53
+ return resolvedPath;
54
+ }
55
+
56
+ /**
57
+ * Safely parse integer with fallback
58
+ */
59
+ function parseIntSafe(value: any, defaultValue: number): number {
60
+ if (typeof value === "number") return Math.floor(value);
61
+ if (typeof value === "string") {
62
+ const parsed = parseInt(value, 10);
63
+ return isNaN(parsed) ? defaultValue : parsed;
64
+ }
65
+ return defaultValue;
66
+ }
67
+
68
+ // Helper function to format bytes
69
+ function formatBytes(bytes: number, decimals = 2): string {
70
+ if (bytes === 0) return "0 Bytes";
71
+ const k = 1024;
72
+ const dm = decimals < 0 ? 0 : decimals;
73
+ const sizes = ["Bytes", "KB", "MB", "GB"];
74
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
75
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
76
+ }
77
+
78
+ /**
79
+ * Helper to get log files from a directory
80
+ */
81
+ async function getLogFilesFromDir(
82
+ logsDir: string,
83
+ includeFullPath = false,
84
+ ): Promise<LogFile[]> {
85
+ if (!existsSync(logsDir)) {
86
+ return [];
87
+ }
88
+
89
+ const files = await fs.readdir(logsDir);
90
+ const logFiles: LogFile[] = [];
91
+
92
+ for (const file of files) {
93
+ if (
94
+ file.endsWith(".log") ||
95
+ file.endsWith(".txt") ||
96
+ file.endsWith(".gz")
97
+ ) {
98
+ const filePath = path.join(logsDir, file);
99
+ const stats = await fs.stat(filePath);
100
+
101
+ logFiles.push({
102
+ name: file,
103
+ size: stats.size,
104
+ modified: stats.mtime.toISOString(),
105
+ ...(includeFullPath && { path: filePath }),
106
+ });
107
+ }
108
+ }
109
+
110
+ // Sort by modified date, newest first
111
+ logFiles.sort(
112
+ (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime(),
113
+ );
114
+
115
+ return logFiles;
116
+ }
117
+
29
118
  /**
30
119
  * Get recent log entries
31
120
  * GET /api/logs/entries?limit=100&level=info
32
121
  */
33
122
  router.get("/entries", async (req: Request, res: Response) => {
34
123
  try {
35
- const limit = parseInt(req.query.limit as string) || 100;
124
+ const limit = parseIntSafe(req.query.limit, 100);
36
125
  const level = (req.query.level as string) || "all";
37
126
 
38
127
  const logger = getLogger;
@@ -59,36 +148,7 @@ router.get("/entries", async (req: Request, res: Response) => {
59
148
  router.get("/files", async (_req: Request, res: Response) => {
60
149
  try {
61
150
  const logsDir = path.join(process.cwd(), "data", "logs");
62
-
63
- if (!existsSync(logsDir)) {
64
- res.json({
65
- success: true,
66
- files: [],
67
- });
68
- return;
69
- }
70
-
71
- const files = await fs.readdir(logsDir);
72
- const logFiles: LogFile[] = [];
73
-
74
- for (const file of files) {
75
- if (file.endsWith(".log") || file.endsWith(".txt")) {
76
- const filePath = path.join(logsDir, file);
77
- const stats = await fs.stat(filePath);
78
-
79
- logFiles.push({
80
- name: file,
81
- size: stats.size,
82
- modified: stats.mtime.toISOString(),
83
- path: filePath,
84
- });
85
- }
86
- }
87
-
88
- // Sort by modified date, newest first
89
- logFiles.sort(
90
- (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime(),
91
- );
151
+ const logFiles = await getLogFilesFromDir(logsDir, true);
92
152
 
93
153
  res.json({
94
154
  success: true,
@@ -112,10 +172,10 @@ router.get("/download/:filename", async (req: Request, res: Response) => {
112
172
  try {
113
173
  const { filename } = req.params;
114
174
  const logsDir = path.join(process.cwd(), "data", "logs");
115
- const filePath = path.join(logsDir, filename);
116
175
 
117
176
  // Security check - prevent directory traversal
118
- if (!filePath.startsWith(logsDir)) {
177
+ const filePath = validateFilePath(filename, logsDir);
178
+ if (!filePath) {
119
179
  res.status(403).json({
120
180
  success: false,
121
181
  error: "Access denied",
@@ -149,10 +209,10 @@ router.get("/stream/:filename", async (req: Request, res: Response) => {
149
209
  try {
150
210
  const { filename } = req.params;
151
211
  const logsDir = path.join(process.cwd(), "data", "logs");
152
- const filePath = path.join(logsDir, filename);
153
212
 
154
- // Security check
155
- if (!filePath.startsWith(logsDir)) {
213
+ // Security check - prevent directory traversal
214
+ const filePath = validateFilePath(filename, logsDir);
215
+ if (!filePath) {
156
216
  res.status(403).json({
157
217
  success: false,
158
218
  error: "Access denied",
@@ -203,47 +263,21 @@ router.post("/clear", async (_req: Request, res: Response) => {
203
263
  });
204
264
 
205
265
  /**
206
- * Get archived log files
266
+ * Get archived log files (alias for /files for backward compatibility)
207
267
  * GET /api/logs/archives
208
268
  */
209
269
  router.get("/archives", async (_req: Request, res: Response) => {
210
270
  try {
211
271
  const logsDir = path.join(process.cwd(), "data", "logs");
272
+ const archives = await getLogFilesFromDir(logsDir);
212
273
 
213
- if (!existsSync(logsDir)) {
214
- return res.json({
215
- success: true,
216
- archives: [],
217
- });
218
- }
219
-
220
- const files = await fs.readdir(logsDir);
221
- const archives: LogFile[] = [];
222
-
223
- for (const file of files) {
224
- if (file.endsWith(".log") || file.endsWith(".txt")) {
225
- const filePath = path.join(logsDir, file);
226
- const stats = await fs.stat(filePath);
227
- archives.push({
228
- name: file,
229
- size: stats.size,
230
- modified: stats.mtime.toISOString(),
231
- });
232
- }
233
- }
234
-
235
- // Sort by modified date, newest first
236
- archives.sort(
237
- (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime(),
238
- );
239
-
240
- return res.json({
274
+ res.json({
241
275
  success: true,
242
276
  archives,
243
277
  });
244
278
  } catch (_error) {
245
279
  console.error("Failed to fetch archives:", _error);
246
- return res.status(500).json({
280
+ res.status(500).json({
247
281
  success: false,
248
282
  error: "Failed to fetch archives",
249
283
  archives: [],
@@ -259,10 +293,10 @@ router.delete("/archive/:filename", async (req: Request, res: Response) => {
259
293
  try {
260
294
  const { filename } = req.params;
261
295
  const logsDir = path.join(process.cwd(), "data", "logs");
262
- const filePath = path.join(logsDir, filename);
263
296
 
264
- // Security check
265
- if (!filePath.startsWith(logsDir) || filename.includes("..")) {
297
+ // Security check - prevent directory traversal
298
+ const filePath = validateFilePath(filename, logsDir);
299
+ if (!filePath) {
266
300
  return res.status(403).json({
267
301
  success: false,
268
302
  error: "Access denied",
@@ -338,52 +372,14 @@ router.get("/export", async (req: Request, res: Response) => {
338
372
  */
339
373
  router.get("/stats", async (_req: Request, res: Response) => {
340
374
  try {
341
- const logsDir = path.join(process.cwd(), "data", "logs");
342
-
343
- if (!existsSync(logsDir)) {
344
- res.json({
345
- success: true,
346
- stats: {
347
- totalSize: 0,
348
- fileCount: 0,
349
- oldestLog: null,
350
- newestLog: null,
351
- },
352
- });
353
- return;
354
- }
355
-
356
- const files = await fs.readdir(logsDir);
357
- let totalSize = 0;
358
- let oldestTime: Date | null = null;
359
- let newestTime: Date | null = null;
360
- let fileCount = 0;
361
-
362
- for (const file of files) {
363
- if (file.endsWith(".log") || file.endsWith(".txt")) {
364
- const filePath = path.join(logsDir, file);
365
- const stats = await fs.stat(filePath);
366
-
367
- totalSize += stats.size;
368
- fileCount++;
369
-
370
- if (!oldestTime || stats.birthtime < oldestTime) {
371
- oldestTime = stats.birthtime;
372
- }
373
- if (!newestTime || stats.mtime > newestTime) {
374
- newestTime = stats.mtime;
375
- }
376
- }
377
- }
375
+ const logger = getLogger;
376
+ const stats = await logger.getLogStats();
378
377
 
379
378
  res.json({
380
379
  success: true,
381
380
  stats: {
382
- totalSize,
383
- totalSizeFormatted: formatBytes(totalSize),
384
- fileCount,
385
- oldestLog: oldestTime?.toISOString() || null,
386
- newestLog: newestTime?.toISOString() || null,
381
+ ...stats,
382
+ totalSizeFormatted: formatBytes(stats.totalSize || 0),
387
383
  },
388
384
  });
389
385
  } catch (_error) {
@@ -506,26 +502,21 @@ router.get(
506
502
  async (req: Request, res: Response) => {
507
503
  try {
508
504
  const { filename } = req.params;
509
-
510
- // Security: prevent directory traversal
511
- const safeName = path.basename(filename);
512
-
513
- // Check in logs directory first
514
505
  const logsDir = path.join(process.cwd(), "data", "logs");
515
- let filePath = path.join(logsDir, safeName);
516
506
 
517
- // If not found, check in archive directory
518
- if (!existsSync(filePath)) {
519
- filePath = path.join(logsDir, "archive", safeName);
520
- }
507
+ // Security: prevent directory traversal using proper validation
508
+ let filePath = validateFilePath(filename, logsDir);
521
509
 
522
- // If still not found and filename includes 'archive/', handle that case
523
- if (!existsSync(filePath) && filename.startsWith("archive/")) {
524
- const archiveName = filename.replace("archive/", "");
525
- filePath = path.join(logsDir, "archive", archiveName);
510
+ // If not found in main logs, try archive directory
511
+ if (!filePath || !existsSync(filePath)) {
512
+ const archiveDir = path.join(logsDir, "archive");
513
+ const archiveFilename = filename.startsWith("archive/")
514
+ ? filename.substring("archive/".length)
515
+ : filename;
516
+ filePath = validateFilePath(archiveFilename, archiveDir);
526
517
  }
527
518
 
528
- if (!existsSync(filePath)) {
519
+ if (!filePath || !existsSync(filePath)) {
529
520
  return res.status(404).json({
530
521
  success: false,
531
522
  error: "File not found",
@@ -540,7 +531,7 @@ router.get(
540
531
  res.setHeader("Content-Type", "text/plain");
541
532
  }
542
533
 
543
- return res.download(filePath, safeName);
534
+ return res.download(filePath, path.basename(filePath));
544
535
  } catch (_error) {
545
536
  return res.status(500).json({
546
537
  success: false,
@@ -587,14 +578,4 @@ router.post("/rotate", async (_req: Request, res: Response) => {
587
578
  }
588
579
  });
589
580
 
590
- // Helper function to format bytes
591
- function formatBytes(bytes: number, decimals = 2): string {
592
- if (bytes === 0) return "0 Bytes";
593
- const k = 1024;
594
- const dm = decimals < 0 ? 0 : decimals;
595
- const sizes = ["Bytes", "KB", "MB", "GB"];
596
- const i = Math.floor(Math.log(bytes) / Math.log(k));
597
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
598
- }
599
-
600
581
  export default router;