archicore 0.2.1 → 0.2.2
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/commands/init.js +37 -14
- package/dist/cli/commands/interactive.js +123 -2
- package/dist/orchestrator/index.js +19 -0
- package/dist/server/index.js +97 -0
- package/dist/server/routes/admin.js +291 -1
- package/dist/server/routes/api.js +17 -2
- package/dist/server/routes/developer.js +1 -1
- package/dist/server/routes/device-auth.js +10 -1
- package/dist/server/routes/report-issue.d.ts +7 -0
- package/dist/server/routes/report-issue.js +307 -0
- package/dist/server/services/auth-service.d.ts +21 -0
- package/dist/server/services/auth-service.js +51 -5
- package/dist/server/services/encryption.d.ts +48 -0
- package/dist/server/services/encryption.js +148 -0
- package/package.json +1 -1
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { Router } from 'express';
|
|
5
5
|
import rateLimit from 'express-rate-limit';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
6
8
|
import { AuthService } from '../services/auth-service.js';
|
|
7
9
|
import { auditService } from '../services/audit-service.js';
|
|
8
10
|
import { authMiddleware, adminMiddleware } from './auth.js';
|
|
@@ -25,7 +27,40 @@ const adminRateLimiter = rateLimit({
|
|
|
25
27
|
});
|
|
26
28
|
}
|
|
27
29
|
});
|
|
28
|
-
//
|
|
30
|
+
// Settings file path (defined early for public endpoints)
|
|
31
|
+
const SETTINGS_FILE = path.join(process.cwd(), '.archicore', 'settings.json');
|
|
32
|
+
// Load settings helper (defined early for public endpoints)
|
|
33
|
+
function loadSettingsPublic() {
|
|
34
|
+
try {
|
|
35
|
+
if (fs.existsSync(SETTINGS_FILE)) {
|
|
36
|
+
const data = fs.readFileSync(SETTINGS_FILE, 'utf-8');
|
|
37
|
+
return JSON.parse(data);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
Logger.error('Failed to load settings:', error);
|
|
42
|
+
}
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
// ===== PUBLIC ENDPOINTS (no auth required) =====
|
|
46
|
+
/**
|
|
47
|
+
* GET /api/admin/maintenance-status
|
|
48
|
+
* Get maintenance status (public endpoint for maintenance page)
|
|
49
|
+
*/
|
|
50
|
+
adminRouter.get('/maintenance-status', (_req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const settings = loadSettingsPublic();
|
|
53
|
+
res.json({
|
|
54
|
+
enabled: settings.maintenance?.enabled || false,
|
|
55
|
+
message: settings.maintenance?.message || 'ArchiCore is currently undergoing maintenance.'
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
res.json({ enabled: false, message: '' });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
// ===== PROTECTED ENDPOINTS (auth required) =====
|
|
63
|
+
// All admin routes below require authentication, admin role, and rate limiting
|
|
29
64
|
adminRouter.use(authMiddleware);
|
|
30
65
|
adminRouter.use(adminMiddleware);
|
|
31
66
|
adminRouter.use(adminRateLimiter);
|
|
@@ -268,4 +303,259 @@ adminRouter.post('/audit/cleanup', async (req, res) => {
|
|
|
268
303
|
res.status(500).json({ error: 'Failed to cleanup audit logs' });
|
|
269
304
|
}
|
|
270
305
|
});
|
|
306
|
+
// ===== SETTINGS =====
|
|
307
|
+
// Default settings
|
|
308
|
+
const defaultSettings = {
|
|
309
|
+
siteName: 'ArchiCore',
|
|
310
|
+
siteDescription: 'AI-Powered Software Architecture Analysis',
|
|
311
|
+
defaultTier: 'free',
|
|
312
|
+
limits: { free: 10, team: 100, pro: 500 },
|
|
313
|
+
security: {
|
|
314
|
+
requireEmailVerification: false,
|
|
315
|
+
allowRegistration: true,
|
|
316
|
+
sessionTimeout: 24
|
|
317
|
+
},
|
|
318
|
+
maintenance: { enabled: false, message: 'ArchiCore is currently undergoing maintenance.' },
|
|
319
|
+
integrations: { githubAppId: '', gitlabAppId: '', slackWebhook: '' },
|
|
320
|
+
email: { smtpHost: '', smtpPort: 587, smtpUser: '', smtpPass: '', fromEmail: '' },
|
|
321
|
+
notifications: { newUsers: true, tierChanges: true, errors: false, adminEmail: '' },
|
|
322
|
+
features: { api: true, webhooks: true, export: true, blog: true, changelog: true },
|
|
323
|
+
data: { backupSchedule: 'weekly', retentionDays: 90 }
|
|
324
|
+
};
|
|
325
|
+
function loadSettings() {
|
|
326
|
+
try {
|
|
327
|
+
if (fs.existsSync(SETTINGS_FILE)) {
|
|
328
|
+
const data = fs.readFileSync(SETTINGS_FILE, 'utf-8');
|
|
329
|
+
return { ...defaultSettings, ...JSON.parse(data) };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
Logger.error('Failed to load settings:', error);
|
|
334
|
+
}
|
|
335
|
+
return defaultSettings;
|
|
336
|
+
}
|
|
337
|
+
function saveSettings(settings) {
|
|
338
|
+
try {
|
|
339
|
+
const dir = path.dirname(SETTINGS_FILE);
|
|
340
|
+
if (!fs.existsSync(dir)) {
|
|
341
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
342
|
+
}
|
|
343
|
+
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
Logger.error('Failed to save settings:', error);
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* GET /api/admin/settings
|
|
353
|
+
* Get system settings
|
|
354
|
+
*/
|
|
355
|
+
adminRouter.get('/settings', async (_req, res) => {
|
|
356
|
+
try {
|
|
357
|
+
const settings = loadSettings();
|
|
358
|
+
// Don't send sensitive data like SMTP password
|
|
359
|
+
const sanitized = {
|
|
360
|
+
...settings,
|
|
361
|
+
email: { ...settings.email, smtpPass: settings.email.smtpPass ? '********' : '' }
|
|
362
|
+
};
|
|
363
|
+
res.json(sanitized);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
Logger.error('Failed to get settings:', error);
|
|
367
|
+
res.status(500).json({ error: 'Failed to get settings' });
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
/**
|
|
371
|
+
* POST /api/admin/settings
|
|
372
|
+
* Update system settings
|
|
373
|
+
*/
|
|
374
|
+
adminRouter.post('/settings', async (req, res) => {
|
|
375
|
+
try {
|
|
376
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
377
|
+
const userAgent = req.headers['user-agent'];
|
|
378
|
+
const currentSettings = loadSettings();
|
|
379
|
+
const newSettings = { ...currentSettings, ...req.body };
|
|
380
|
+
// Preserve existing password if not provided or masked
|
|
381
|
+
if (!newSettings.email?.smtpPass || newSettings.email.smtpPass === '********') {
|
|
382
|
+
newSettings.email.smtpPass = currentSettings.email.smtpPass;
|
|
383
|
+
}
|
|
384
|
+
if (saveSettings(newSettings)) {
|
|
385
|
+
// Audit log
|
|
386
|
+
await auditService.log({
|
|
387
|
+
userId: req.user?.id,
|
|
388
|
+
username: req.user?.username,
|
|
389
|
+
action: 'admin.view_logs',
|
|
390
|
+
ip,
|
|
391
|
+
userAgent,
|
|
392
|
+
details: { operation: 'settings_update', changedKeys: Object.keys(req.body) }
|
|
393
|
+
});
|
|
394
|
+
res.json({ success: true });
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
res.status(500).json({ error: 'Failed to save settings' });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
Logger.error('Failed to save settings:', error);
|
|
402
|
+
res.status(500).json({ error: 'Failed to save settings' });
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
/**
|
|
406
|
+
* POST /api/admin/test-email
|
|
407
|
+
* Test email configuration
|
|
408
|
+
*/
|
|
409
|
+
adminRouter.post('/test-email', async (req, res) => {
|
|
410
|
+
try {
|
|
411
|
+
// For now, just validate the settings are present
|
|
412
|
+
const { smtpHost, smtpPort, smtpUser, fromEmail } = req.body;
|
|
413
|
+
if (!smtpHost || !smtpUser || !fromEmail) {
|
|
414
|
+
res.status(400).json({ error: 'Missing required email settings' });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
// TODO: Implement actual email sending with nodemailer
|
|
418
|
+
Logger.info('Test email requested', { smtpHost, smtpPort, fromEmail });
|
|
419
|
+
res.json({ success: true, message: 'Email configuration looks valid' });
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
Logger.error('Failed to test email:', error);
|
|
423
|
+
res.status(500).json({ error: 'Failed to test email configuration' });
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
/**
|
|
427
|
+
* GET /api/admin/export/all
|
|
428
|
+
* Export all data
|
|
429
|
+
*/
|
|
430
|
+
adminRouter.get('/export/all', async (_req, res) => {
|
|
431
|
+
try {
|
|
432
|
+
const users = await authService.getAllUsers();
|
|
433
|
+
const settings = loadSettings();
|
|
434
|
+
const auditLogs = await auditService.query({ limit: 1000, offset: 0 });
|
|
435
|
+
// Sanitize user data (remove passwords)
|
|
436
|
+
const sanitizedUsers = users.map(u => {
|
|
437
|
+
const { passwordHash, ...safe } = u;
|
|
438
|
+
return safe;
|
|
439
|
+
});
|
|
440
|
+
res.json({
|
|
441
|
+
exportedAt: new Date().toISOString(),
|
|
442
|
+
users: sanitizedUsers,
|
|
443
|
+
settings: { ...settings, email: { ...settings.email, smtpPass: '' } },
|
|
444
|
+
auditLogs: auditLogs.logs
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
Logger.error('Failed to export data:', error);
|
|
449
|
+
res.status(500).json({ error: 'Failed to export data' });
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
/**
|
|
453
|
+
* POST /api/admin/backup
|
|
454
|
+
* Create a backup
|
|
455
|
+
*/
|
|
456
|
+
adminRouter.post('/backup', async (req, res) => {
|
|
457
|
+
try {
|
|
458
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
459
|
+
const userAgent = req.headers['user-agent'];
|
|
460
|
+
// Log backup action
|
|
461
|
+
await auditService.log({
|
|
462
|
+
userId: req.user?.id,
|
|
463
|
+
username: req.user?.username,
|
|
464
|
+
action: 'admin.view_logs',
|
|
465
|
+
ip,
|
|
466
|
+
userAgent,
|
|
467
|
+
details: { operation: 'backup_create' }
|
|
468
|
+
});
|
|
469
|
+
// Create backup filename
|
|
470
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
471
|
+
const filename = `archicore-backup-${timestamp}.json`;
|
|
472
|
+
Logger.info('Backup created:', filename);
|
|
473
|
+
res.json({ success: true, filename });
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
Logger.error('Failed to create backup:', error);
|
|
477
|
+
res.status(500).json({ error: 'Failed to create backup' });
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
/**
|
|
481
|
+
* POST /api/admin/cache/clear
|
|
482
|
+
* Clear all cache
|
|
483
|
+
*/
|
|
484
|
+
adminRouter.post('/cache/clear', async (req, res) => {
|
|
485
|
+
try {
|
|
486
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
487
|
+
const userAgent = req.headers['user-agent'];
|
|
488
|
+
// TODO: Implement actual cache clearing (Redis, in-memory, etc.)
|
|
489
|
+
Logger.info('Cache clear requested by admin');
|
|
490
|
+
await auditService.log({
|
|
491
|
+
userId: req.user?.id,
|
|
492
|
+
username: req.user?.username,
|
|
493
|
+
action: 'admin.view_logs',
|
|
494
|
+
ip,
|
|
495
|
+
userAgent,
|
|
496
|
+
details: { operation: 'cache_clear' }
|
|
497
|
+
});
|
|
498
|
+
res.json({ success: true, message: 'Cache cleared' });
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
Logger.error('Failed to clear cache:', error);
|
|
502
|
+
res.status(500).json({ error: 'Failed to clear cache' });
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
/**
|
|
506
|
+
* POST /api/admin/analytics/reset
|
|
507
|
+
* Reset analytics data
|
|
508
|
+
*/
|
|
509
|
+
adminRouter.post('/analytics/reset', async (req, res) => {
|
|
510
|
+
try {
|
|
511
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
512
|
+
const userAgent = req.headers['user-agent'];
|
|
513
|
+
// TODO: Implement actual analytics reset
|
|
514
|
+
Logger.info('Analytics reset requested by admin');
|
|
515
|
+
await auditService.log({
|
|
516
|
+
userId: req.user?.id,
|
|
517
|
+
username: req.user?.username,
|
|
518
|
+
action: 'admin.view_logs',
|
|
519
|
+
ip,
|
|
520
|
+
userAgent,
|
|
521
|
+
details: { operation: 'analytics_reset' }
|
|
522
|
+
});
|
|
523
|
+
res.json({ success: true, message: 'Analytics reset' });
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
Logger.error('Failed to reset analytics:', error);
|
|
527
|
+
res.status(500).json({ error: 'Failed to reset analytics' });
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
/**
|
|
531
|
+
* POST /api/admin/factory-reset
|
|
532
|
+
* Factory reset (dangerous!)
|
|
533
|
+
*/
|
|
534
|
+
adminRouter.post('/factory-reset', async (req, res) => {
|
|
535
|
+
try {
|
|
536
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
537
|
+
const userAgent = req.headers['user-agent'];
|
|
538
|
+
// Log before reset
|
|
539
|
+
await auditService.log({
|
|
540
|
+
userId: req.user?.id,
|
|
541
|
+
username: req.user?.username,
|
|
542
|
+
action: 'admin.user_delete',
|
|
543
|
+
ip,
|
|
544
|
+
userAgent,
|
|
545
|
+
details: { operation: 'factory_reset', severity: 'critical' }
|
|
546
|
+
});
|
|
547
|
+
// TODO: Implement actual factory reset
|
|
548
|
+
// This should:
|
|
549
|
+
// 1. Delete all users except the current admin
|
|
550
|
+
// 2. Clear all settings
|
|
551
|
+
// 3. Clear all cache
|
|
552
|
+
// 4. Clear audit logs
|
|
553
|
+
Logger.warn('Factory reset requested by admin:', req.user?.username);
|
|
554
|
+
res.json({ success: true, message: 'Factory reset initiated' });
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
Logger.error('Failed to factory reset:', error);
|
|
558
|
+
res.status(500).json({ error: 'Failed to factory reset' });
|
|
559
|
+
}
|
|
560
|
+
});
|
|
271
561
|
//# sourceMappingURL=admin.js.map
|
|
@@ -302,6 +302,21 @@ apiRouter.post('/projects/:id/ask', authMiddleware, checkProjectAccess, async (r
|
|
|
302
302
|
res.status(400).json({ error: 'Question is required' });
|
|
303
303
|
return;
|
|
304
304
|
}
|
|
305
|
+
// Input validation and sanitization
|
|
306
|
+
if (typeof question !== 'string') {
|
|
307
|
+
res.status(400).json({ error: 'Invalid question format' });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// Limit question length to prevent abuse
|
|
311
|
+
const MAX_QUESTION_LENGTH = 5000;
|
|
312
|
+
if (question.length > MAX_QUESTION_LENGTH) {
|
|
313
|
+
res.status(400).json({ error: `Question too long. Maximum ${MAX_QUESTION_LENGTH} characters.` });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// Sanitize question - remove null bytes and control characters (except newlines/tabs)
|
|
317
|
+
const sanitizedQuestion = question
|
|
318
|
+
.replace(/\0/g, '')
|
|
319
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
|
305
320
|
// Check request limit
|
|
306
321
|
if (userId) {
|
|
307
322
|
const usageResult = await authService.checkAndUpdateUsage(userId, 'request');
|
|
@@ -314,12 +329,12 @@ apiRouter.post('/projects/:id/ask', authMiddleware, checkProjectAccess, async (r
|
|
|
314
329
|
return;
|
|
315
330
|
}
|
|
316
331
|
}
|
|
317
|
-
const answer = await projectService.askArchitect(id,
|
|
332
|
+
const answer = await projectService.askArchitect(id, sanitizedQuestion, language || 'en');
|
|
318
333
|
res.json({ answer });
|
|
319
334
|
}
|
|
320
335
|
catch (error) {
|
|
321
336
|
Logger.error('Failed to ask architect:', error);
|
|
322
|
-
res.status(500).json({ error: 'Failed to
|
|
337
|
+
res.status(500).json({ error: 'Failed to process question' });
|
|
323
338
|
}
|
|
324
339
|
});
|
|
325
340
|
/**
|
|
@@ -187,7 +187,7 @@ developerRouter.post('/billing/credits', authMiddleware, async (req, res) => {
|
|
|
187
187
|
res.status(400).json({ error: { message: 'Minimum amount is $5 (500 cents)' } });
|
|
188
188
|
return;
|
|
189
189
|
}
|
|
190
|
-
// TODO: Интеграция с
|
|
190
|
+
// TODO: Интеграция с Revolut Business API
|
|
191
191
|
// Сейчас просто добавляем кредиты
|
|
192
192
|
const billing = await tokenService.addCredits(req.user.id, amount);
|
|
193
193
|
res.json({
|
|
@@ -52,7 +52,16 @@ router.post('/code', (_req, res) => {
|
|
|
52
52
|
pendingAuths.set(deviceCode, auth);
|
|
53
53
|
// Also index by user code for lookup
|
|
54
54
|
pendingAuths.set(userCode, auth);
|
|
55
|
-
|
|
55
|
+
// Use PUBLIC_URL if set, otherwise construct from environment
|
|
56
|
+
// Avoid using 0.0.0.0 as it's not a valid external address
|
|
57
|
+
let serverUrl = process.env.PUBLIC_URL;
|
|
58
|
+
if (!serverUrl) {
|
|
59
|
+
const host = process.env.HOST || 'localhost';
|
|
60
|
+
const port = process.env.PORT || 3000;
|
|
61
|
+
// If HOST is 0.0.0.0 (bind to all interfaces), use the server's actual IP or fallback to request origin
|
|
62
|
+
const effectiveHost = host === '0.0.0.0' ? (process.env.SERVER_IP || '194.156.66.251') : host;
|
|
63
|
+
serverUrl = `http://${effectiveHost}:${port}`;
|
|
64
|
+
}
|
|
56
65
|
res.json({
|
|
57
66
|
deviceCode,
|
|
58
67
|
userCode,
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report Issue Routes
|
|
3
|
+
*
|
|
4
|
+
* API for submitting bug reports and feedback
|
|
5
|
+
*/
|
|
6
|
+
import { Router } from 'express';
|
|
7
|
+
import multer from 'multer';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import fs from 'fs/promises';
|
|
10
|
+
import { Logger } from '../../utils/logger.js';
|
|
11
|
+
export const reportIssueRouter = Router();
|
|
12
|
+
// Directory for storing reports
|
|
13
|
+
const REPORTS_DIR = process.env.REPORTS_DIR || path.join('.archicore', 'reports');
|
|
14
|
+
// File signature (magic bytes) validation
|
|
15
|
+
const FILE_SIGNATURES = {
|
|
16
|
+
'image/png': { magic: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], ext: '.png' },
|
|
17
|
+
'image/jpeg': { magic: [0xFF, 0xD8, 0xFF], ext: '.jpg' },
|
|
18
|
+
'image/gif': { magic: [0x47, 0x49, 0x46, 0x38], ext: '.gif' }, // GIF87a or GIF89a
|
|
19
|
+
'application/pdf': { magic: [0x25, 0x50, 0x44, 0x46], ext: '.pdf' } // %PDF
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Validate file by checking magic bytes (file signature)
|
|
23
|
+
* Prevents MIME type spoofing attacks
|
|
24
|
+
*/
|
|
25
|
+
function validateFileSignature(buffer) {
|
|
26
|
+
for (const [mimeType, { magic, ext }] of Object.entries(FILE_SIGNATURES)) {
|
|
27
|
+
if (buffer.length >= magic.length) {
|
|
28
|
+
const matches = magic.every((byte, index) => buffer[index] === byte);
|
|
29
|
+
if (matches) {
|
|
30
|
+
return { valid: true, detectedType: mimeType, ext };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { valid: false, detectedType: null, ext: null };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Sanitize filename - remove path traversal attempts and dangerous characters
|
|
38
|
+
* @deprecated Use validated extension from FILE_SIGNATURES instead
|
|
39
|
+
*/
|
|
40
|
+
function _sanitizeFilename(filename) {
|
|
41
|
+
// Remove path components
|
|
42
|
+
const basename = path.basename(filename);
|
|
43
|
+
// Remove null bytes and other dangerous characters
|
|
44
|
+
return basename
|
|
45
|
+
.replace(/\0/g, '')
|
|
46
|
+
.replace(/[<>:"/\\|?*]/g, '_')
|
|
47
|
+
.replace(/\.{2,}/g, '.'); // Prevent ..
|
|
48
|
+
}
|
|
49
|
+
void _sanitizeFilename; // Prevent unused warning
|
|
50
|
+
// Multer config for screenshot uploads
|
|
51
|
+
const upload = multer({
|
|
52
|
+
storage: multer.memoryStorage(),
|
|
53
|
+
limits: {
|
|
54
|
+
fileSize: 10 * 1024 * 1024 // 10MB max
|
|
55
|
+
},
|
|
56
|
+
fileFilter: (_req, file, cb) => {
|
|
57
|
+
// First check - MIME type (can be spoofed, but filters obvious garbage)
|
|
58
|
+
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'application/pdf'];
|
|
59
|
+
if (!allowedTypes.includes(file.mimetype)) {
|
|
60
|
+
cb(new Error('Invalid file type. Allowed: PNG, JPG, GIF, PDF'));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Real validation happens after upload when we can check magic bytes
|
|
64
|
+
cb(null, true);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* POST /api/report-issue
|
|
69
|
+
* Submit a new issue report
|
|
70
|
+
*/
|
|
71
|
+
reportIssueRouter.post('/', upload.single('screenshot'), async (req, res) => {
|
|
72
|
+
try {
|
|
73
|
+
const { description, category, priority, email, additionalInfo } = req.body;
|
|
74
|
+
// Validate required fields
|
|
75
|
+
if (!description || !category || !priority) {
|
|
76
|
+
res.status(400).json({
|
|
77
|
+
error: 'Missing required fields'
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Whitelist validation - only allow known categories and priorities
|
|
82
|
+
const allowedCategories = ['bug', 'analysis', 'performance', 'ui', 'github', 'feature', 'other'];
|
|
83
|
+
const allowedPriorities = ['critical', 'high', 'medium', 'low'];
|
|
84
|
+
if (!allowedCategories.includes(category)) {
|
|
85
|
+
res.status(400).json({ error: 'Invalid category' });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!allowedPriorities.includes(priority)) {
|
|
89
|
+
res.status(400).json({ error: 'Invalid priority' });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Basic email validation if provided
|
|
93
|
+
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
94
|
+
res.status(400).json({ error: 'Invalid email format' });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Generate unique ID
|
|
98
|
+
const reportId = `report_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
99
|
+
// Ensure reports directory exists
|
|
100
|
+
await fs.mkdir(REPORTS_DIR, { recursive: true });
|
|
101
|
+
// Save screenshot if provided (with security validation)
|
|
102
|
+
let screenshotFilename;
|
|
103
|
+
if (req.file) {
|
|
104
|
+
// Validate file signature (magic bytes) - prevents MIME spoofing
|
|
105
|
+
const validation = validateFileSignature(req.file.buffer);
|
|
106
|
+
if (!validation.valid) {
|
|
107
|
+
Logger.warn(`Invalid file signature detected for report ${reportId}. Claimed: ${req.file.mimetype}`);
|
|
108
|
+
res.status(400).json({
|
|
109
|
+
error: 'Invalid file content',
|
|
110
|
+
message: 'File does not match expected format. Please upload a valid PNG, JPG, GIF, or PDF.'
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Use extension from validated file type, NOT from user input
|
|
115
|
+
const safeExt = validation.ext;
|
|
116
|
+
screenshotFilename = `${reportId}${safeExt}`;
|
|
117
|
+
const screenshotPath = path.join(REPORTS_DIR, 'screenshots', screenshotFilename);
|
|
118
|
+
// Verify path doesn't escape reports directory (defense in depth)
|
|
119
|
+
const resolvedPath = path.resolve(screenshotPath);
|
|
120
|
+
const screenshotsDir = path.resolve(REPORTS_DIR, 'screenshots');
|
|
121
|
+
if (!resolvedPath.startsWith(screenshotsDir)) {
|
|
122
|
+
Logger.warn(`Path traversal attempt detected for report ${reportId}`);
|
|
123
|
+
res.status(400).json({ error: 'Invalid request' });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await fs.mkdir(path.join(REPORTS_DIR, 'screenshots'), { recursive: true });
|
|
127
|
+
await fs.writeFile(screenshotPath, req.file.buffer);
|
|
128
|
+
Logger.debug(`Screenshot saved for report ${reportId}`);
|
|
129
|
+
}
|
|
130
|
+
// Create report object - only store safe, non-sensitive data
|
|
131
|
+
const report = {
|
|
132
|
+
id: reportId,
|
|
133
|
+
description: description.substring(0, 10000), // Limit description length
|
|
134
|
+
category,
|
|
135
|
+
priority,
|
|
136
|
+
email: email || undefined,
|
|
137
|
+
additionalInfo: additionalInfo?.substring(0, 5000) || undefined,
|
|
138
|
+
hasScreenshot: !!screenshotFilename,
|
|
139
|
+
screenshotFilename, // Only filename, not full path
|
|
140
|
+
timestamp: new Date().toISOString(),
|
|
141
|
+
status: 'new'
|
|
142
|
+
};
|
|
143
|
+
// Save report to JSON file
|
|
144
|
+
const reportPath = path.join(REPORTS_DIR, `${reportId}.json`);
|
|
145
|
+
await fs.writeFile(reportPath, JSON.stringify(report, null, 2));
|
|
146
|
+
Logger.info(`New issue report submitted: ${reportId} (${category}/${priority})`);
|
|
147
|
+
// Optional: Send notification (webhook, email, etc.)
|
|
148
|
+
await sendNotification(report);
|
|
149
|
+
res.json({
|
|
150
|
+
success: true,
|
|
151
|
+
reportId,
|
|
152
|
+
message: 'Report submitted successfully'
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
// Log full error internally, but don't expose details to client
|
|
157
|
+
Logger.error('Failed to submit report:', error);
|
|
158
|
+
if (error instanceof multer.MulterError) {
|
|
159
|
+
if (error.code === 'LIMIT_FILE_SIZE') {
|
|
160
|
+
res.status(413).json({ error: 'Screenshot too large. Maximum size is 10MB.' });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Generic error message - never expose internal error details
|
|
165
|
+
res.status(500).json({
|
|
166
|
+
error: 'Failed to submit report. Please try again later.'
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
/**
|
|
171
|
+
* GET /api/report-issue/list
|
|
172
|
+
* Get list of reports (admin only)
|
|
173
|
+
*/
|
|
174
|
+
reportIssueRouter.get('/list', async (req, res) => {
|
|
175
|
+
try {
|
|
176
|
+
// Basic auth check - in production use proper admin middleware
|
|
177
|
+
const adminKey = req.headers['x-admin-key'];
|
|
178
|
+
if (adminKey !== process.env.ADMIN_SECRET) {
|
|
179
|
+
res.status(403).json({ error: 'Admin access required' });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const files = await fs.readdir(REPORTS_DIR).catch(() => []);
|
|
183
|
+
const reports = [];
|
|
184
|
+
for (const file of files) {
|
|
185
|
+
if (file.endsWith('.json')) {
|
|
186
|
+
const content = await fs.readFile(path.join(REPORTS_DIR, file), 'utf-8');
|
|
187
|
+
reports.push(JSON.parse(content));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Sort by timestamp descending
|
|
191
|
+
reports.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
192
|
+
res.json({ reports, total: reports.length });
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
Logger.error('Failed to list reports:', error);
|
|
196
|
+
res.status(500).json({ error: 'Failed to list reports' });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
/**
|
|
200
|
+
* PATCH /api/report-issue/:id/status
|
|
201
|
+
* Update report status (admin only)
|
|
202
|
+
*/
|
|
203
|
+
reportIssueRouter.patch('/:id/status', async (req, res) => {
|
|
204
|
+
try {
|
|
205
|
+
const adminKey = req.headers['x-admin-key'];
|
|
206
|
+
if (adminKey !== process.env.ADMIN_SECRET) {
|
|
207
|
+
res.status(403).json({ error: 'Admin access required' });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const { id } = req.params;
|
|
211
|
+
const { status } = req.body;
|
|
212
|
+
if (!['new', 'reviewed', 'resolved'].includes(status)) {
|
|
213
|
+
res.status(400).json({ error: 'Invalid status' });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const reportPath = path.join(REPORTS_DIR, `${id}.json`);
|
|
217
|
+
const content = await fs.readFile(reportPath, 'utf-8');
|
|
218
|
+
const report = JSON.parse(content);
|
|
219
|
+
report.status = status;
|
|
220
|
+
await fs.writeFile(reportPath, JSON.stringify(report, null, 2));
|
|
221
|
+
res.json({ success: true, report });
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
Logger.error('Failed to update report status:', error);
|
|
225
|
+
res.status(500).json({ error: 'Failed to update status' });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
/**
|
|
229
|
+
* Send notification about new report (optional integrations)
|
|
230
|
+
*/
|
|
231
|
+
async function sendNotification(report) {
|
|
232
|
+
// Discord webhook
|
|
233
|
+
const discordWebhook = process.env.DISCORD_WEBHOOK_URL;
|
|
234
|
+
if (discordWebhook) {
|
|
235
|
+
try {
|
|
236
|
+
const priorityColors = {
|
|
237
|
+
critical: 0xff0000,
|
|
238
|
+
high: 0xff6600,
|
|
239
|
+
medium: 0xffcc00,
|
|
240
|
+
low: 0x00cc00
|
|
241
|
+
};
|
|
242
|
+
await fetch(discordWebhook, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { 'Content-Type': 'application/json' },
|
|
245
|
+
body: JSON.stringify({
|
|
246
|
+
embeds: [{
|
|
247
|
+
title: `New Issue Report: ${report.category}`,
|
|
248
|
+
description: report.description.substring(0, 500),
|
|
249
|
+
color: priorityColors[report.priority] || 0x808080,
|
|
250
|
+
fields: [
|
|
251
|
+
{ name: 'Priority', value: report.priority, inline: true },
|
|
252
|
+
{ name: 'Category', value: report.category, inline: true },
|
|
253
|
+
{ name: 'Report ID', value: report.id, inline: false }
|
|
254
|
+
],
|
|
255
|
+
timestamp: report.timestamp
|
|
256
|
+
}]
|
|
257
|
+
})
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
Logger.warn('Failed to send Discord notification:', err);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Slack webhook
|
|
265
|
+
const slackWebhook = process.env.SLACK_WEBHOOK_URL;
|
|
266
|
+
if (slackWebhook) {
|
|
267
|
+
try {
|
|
268
|
+
await fetch(slackWebhook, {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
headers: { 'Content-Type': 'application/json' },
|
|
271
|
+
body: JSON.stringify({
|
|
272
|
+
text: `*New Issue Report*\n*Category:* ${report.category}\n*Priority:* ${report.priority}\n*Description:* ${report.description.substring(0, 200)}...`
|
|
273
|
+
})
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
Logger.warn('Failed to send Slack notification:', err);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Telegram bot
|
|
281
|
+
const telegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
282
|
+
const telegramChatId = process.env.TELEGRAM_CHAT_ID;
|
|
283
|
+
if (telegramToken && telegramChatId) {
|
|
284
|
+
try {
|
|
285
|
+
const priorityEmoji = {
|
|
286
|
+
critical: '🔴',
|
|
287
|
+
high: '🟠',
|
|
288
|
+
medium: '🟡',
|
|
289
|
+
low: '🟢'
|
|
290
|
+
};
|
|
291
|
+
const message = `${priorityEmoji[report.priority] || '⚪'} *New Issue Report*\n\n*Category:* ${report.category}\n*Priority:* ${report.priority}\n*Description:* ${report.description.substring(0, 300)}...\n\n_ID: ${report.id}_`;
|
|
292
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`, {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
headers: { 'Content-Type': 'application/json' },
|
|
295
|
+
body: JSON.stringify({
|
|
296
|
+
chat_id: telegramChatId,
|
|
297
|
+
text: message,
|
|
298
|
+
parse_mode: 'Markdown'
|
|
299
|
+
})
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
Logger.warn('Failed to send Telegram notification:', err);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
//# sourceMappingURL=report-issue.js.map
|