archicore 0.3.6 → 0.3.8

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.
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'url';
21
21
  import { Logger } from '../utils/logger.js';
22
22
  import { apiRouter } from './routes/api.js';
23
23
  import { uploadRouter } from './routes/upload.js';
24
- import { authRouter } from './routes/auth.js';
24
+ import { authRouter, cleanupAuthIntervals } from './routes/auth.js';
25
25
  import { oauthRouter } from './routes/oauth.js';
26
26
  import { adminRouter } from './routes/admin.js';
27
27
  import { developerRouter } from './routes/developer.js';
@@ -159,6 +159,15 @@ export class ArchiCoreServer {
159
159
  this.setupErrorHandling();
160
160
  }
161
161
  setupMiddleware() {
162
+ // Trust proxy - required when running behind nginx/reverse proxy
163
+ // This allows express-rate-limit to correctly identify clients via X-Forwarded-For
164
+ // Set TRUST_PROXY=false to disable, or specify number of proxies (e.g., "1", "2")
165
+ const trustProxy = process.env.TRUST_PROXY;
166
+ if (trustProxy !== 'false') {
167
+ // Default: trust first proxy (typical setup with nginx)
168
+ const proxyCount = trustProxy ? parseInt(trustProxy, 10) : 1;
169
+ this.app.set('trust proxy', isNaN(proxyCount) ? trustProxy : proxyCount);
170
+ }
162
171
  // Security headers (helmet) - enabled by default for security
163
172
  // Set HELMET_ENABLED=false to disable (not recommended)
164
173
  const helmetEnabled = process.env.HELMET_ENABLED !== 'false';
@@ -419,6 +428,8 @@ export class ArchiCoreServer {
419
428
  });
420
429
  }
421
430
  async stop() {
431
+ // Cleanup intervals
432
+ cleanupAuthIntervals();
422
433
  // Disconnect cache and database
423
434
  await cache.disconnect();
424
435
  await db.close();
@@ -438,9 +449,43 @@ export class ArchiCoreServer {
438
449
  return this.app;
439
450
  }
440
451
  }
452
+ /**
453
+ * Validate required environment variables for production
454
+ */
455
+ function validateProductionEnvironment() {
456
+ if (process.env.NODE_ENV !== 'production') {
457
+ return; // Only enforce in production
458
+ }
459
+ const requiredVars = [
460
+ 'JWT_SECRET',
461
+ 'ENCRYPTION_KEY',
462
+ 'ENCRYPTION_SALT',
463
+ ];
464
+ const missingVars = [];
465
+ for (const varName of requiredVars) {
466
+ if (!process.env[varName]) {
467
+ missingVars.push(varName);
468
+ }
469
+ }
470
+ // Check for weak JWT_SECRET
471
+ const jwtSecret = process.env.JWT_SECRET;
472
+ if (jwtSecret && jwtSecret.length < 32) {
473
+ Logger.error('CRITICAL: JWT_SECRET must be at least 32 characters in production');
474
+ missingVars.push('JWT_SECRET (too short)');
475
+ }
476
+ if (missingVars.length > 0) {
477
+ Logger.error('CRITICAL: Missing required environment variables for production:');
478
+ missingVars.forEach(v => Logger.error(` - ${v}`));
479
+ Logger.error('Server cannot start in production mode without these variables.');
480
+ process.exit(1);
481
+ }
482
+ Logger.info('Production environment validation passed');
483
+ }
441
484
  // Запуск сервера при прямом вызове
442
485
  const isMainModule = process.argv[1]?.includes('server');
443
486
  if (isMainModule) {
487
+ // Validate production environment before starting
488
+ validateProductionEnvironment();
444
489
  const port = parseInt(process.env.PORT || '3000', 10);
445
490
  const host = process.env.HOST || '0.0.0.0';
446
491
  const server = new ArchiCoreServer({
@@ -458,5 +503,27 @@ if (isMainModule) {
458
503
  await server.stop();
459
504
  process.exit(0);
460
505
  });
506
+ process.on('SIGTERM', async () => {
507
+ Logger.info('SIGTERM received, shutting down gracefully...');
508
+ await server.stop();
509
+ process.exit(0);
510
+ });
511
+ // Handle uncaught exceptions - log and exit gracefully
512
+ process.on('uncaughtException', async (error) => {
513
+ Logger.error('Uncaught Exception:', error);
514
+ try {
515
+ await server.stop();
516
+ }
517
+ catch (stopError) {
518
+ Logger.error('Error during shutdown after uncaughtException:', stopError);
519
+ }
520
+ process.exit(1);
521
+ });
522
+ // Handle unhandled promise rejections
523
+ process.on('unhandledRejection', (reason, promise) => {
524
+ Logger.error('Unhandled Promise Rejection:', reason);
525
+ Logger.error('Promise:', promise);
526
+ // Don't exit - just log, but track for debugging
527
+ });
461
528
  }
462
529
  //# sourceMappingURL=index.js.map
@@ -11,6 +11,7 @@ import { ExportManager } from '../../export/index.js';
11
11
  import { authMiddleware } from './auth.js';
12
12
  import { FileUtils } from '../../utils/file-utils.js';
13
13
  import { taskQueue } from '../services/task-queue.js';
14
+ import { EnterpriseIndexer, TIER_CONFIG } from '../services/enterprise-indexer.js';
14
15
  export const apiRouter = Router();
15
16
  // Singleton сервиса проектов
16
17
  const projectService = new ProjectService();
@@ -761,8 +762,10 @@ apiRouter.post('/projects/:id/documentation', authMiddleware, checkProjectAccess
761
762
  */
762
763
  apiRouter.get('/projects/:id/index-progress', async (req, res) => {
763
764
  const { id } = req.params;
764
- // Handle auth via query param (EventSource doesn't support headers)
765
- const token = req.query.token || req.headers.authorization?.substring(7);
765
+ // Handle auth via query param, header, or cookie (EventSource doesn't support custom headers)
766
+ const token = req.query.token
767
+ || req.headers.authorization?.substring(7)
768
+ || req.cookies?.archicore_token;
766
769
  if (!token) {
767
770
  res.status(401).json({ error: 'Authentication required' });
768
771
  return;
@@ -1035,6 +1038,243 @@ apiRouter.get('/queue/stats', authMiddleware, async (req, res) => {
1035
1038
  res.status(500).json({ error: 'Failed to get queue stats' });
1036
1039
  }
1037
1040
  });
1041
+ // ==================== ENTERPRISE ENDPOINTS ====================
1042
+ /**
1043
+ * GET /api/projects/:id/enterprise/estimate
1044
+ * Get estimation for enterprise-scale project analysis
1045
+ */
1046
+ apiRouter.get('/projects/:id/enterprise/estimate', authMiddleware, checkProjectAccess, async (req, res) => {
1047
+ try {
1048
+ const { id } = req.params;
1049
+ const project = await projectService.getProject(id);
1050
+ if (!project) {
1051
+ res.status(404).json({ error: 'Project not found' });
1052
+ return;
1053
+ }
1054
+ const indexer = new EnterpriseIndexer(project.path);
1055
+ await indexer.initialize();
1056
+ const estimate = await indexer.getProjectSize();
1057
+ res.json({
1058
+ projectId: id,
1059
+ projectName: project.name,
1060
+ ...estimate,
1061
+ tiers: {
1062
+ quick: {
1063
+ ...TIER_CONFIG.quick,
1064
+ estimatedFiles: Math.min(estimate.totalFiles, TIER_CONFIG.quick.maxFiles),
1065
+ },
1066
+ standard: {
1067
+ ...TIER_CONFIG.standard,
1068
+ estimatedFiles: Math.min(estimate.totalFiles, TIER_CONFIG.standard.maxFiles),
1069
+ },
1070
+ deep: {
1071
+ ...TIER_CONFIG.deep,
1072
+ estimatedFiles: Math.min(estimate.totalFiles, TIER_CONFIG.deep.maxFiles),
1073
+ },
1074
+ },
1075
+ });
1076
+ }
1077
+ catch (error) {
1078
+ Logger.error('Failed to estimate project:', error);
1079
+ res.status(500).json({ error: 'Failed to estimate project' });
1080
+ }
1081
+ });
1082
+ /**
1083
+ * POST /api/projects/:id/enterprise/index
1084
+ * Index large project with enterprise options
1085
+ */
1086
+ apiRouter.post('/projects/:id/enterprise/index', authMiddleware, checkProjectAccess, async (req, res) => {
1087
+ try {
1088
+ const { id } = req.params;
1089
+ const userId = getUserId(req);
1090
+ const options = req.body;
1091
+ // Check tier access
1092
+ if (options.tier === 'deep' && req.user?.tier !== 'enterprise') {
1093
+ res.status(403).json({
1094
+ error: 'Deep analysis requires Enterprise tier',
1095
+ upgradeUrl: '/pricing'
1096
+ });
1097
+ return;
1098
+ }
1099
+ const project = await projectService.getProject(id);
1100
+ if (!project) {
1101
+ res.status(404).json({ error: 'Project not found' });
1102
+ return;
1103
+ }
1104
+ // Create async task for enterprise indexing
1105
+ const task = taskQueue.createTask('enterprise-index', id, userId || 'anonymous');
1106
+ // Store options in task
1107
+ task.enterpriseOptions = options;
1108
+ res.json({
1109
+ taskId: task.id,
1110
+ status: task.status,
1111
+ message: 'Enterprise indexing task queued. Poll /api/tasks/:taskId for status.',
1112
+ options: {
1113
+ tier: options.tier || 'standard',
1114
+ sampling: options.sampling,
1115
+ incremental: options.incremental,
1116
+ },
1117
+ });
1118
+ }
1119
+ catch (error) {
1120
+ Logger.error('Failed to start enterprise indexing:', error);
1121
+ res.status(500).json({ error: 'Failed to start enterprise indexing' });
1122
+ }
1123
+ });
1124
+ /**
1125
+ * GET /api/projects/:id/enterprise/files
1126
+ * Get list of files that would be analyzed based on current options
1127
+ */
1128
+ apiRouter.get('/projects/:id/enterprise/files', authMiddleware, checkProjectAccess, async (req, res) => {
1129
+ try {
1130
+ const { id } = req.params;
1131
+ const tier = req.query.tier || 'standard';
1132
+ const strategy = req.query.strategy || 'smart';
1133
+ const project = await projectService.getProject(id);
1134
+ if (!project) {
1135
+ res.status(404).json({ error: 'Project not found' });
1136
+ return;
1137
+ }
1138
+ const indexer = new EnterpriseIndexer(project.path, {
1139
+ tier,
1140
+ sampling: {
1141
+ enabled: true,
1142
+ maxFiles: TIER_CONFIG[tier].maxFiles,
1143
+ strategy
1144
+ }
1145
+ });
1146
+ await indexer.initialize();
1147
+ const files = await indexer.getFilesToIndex();
1148
+ // Return relative paths
1149
+ const relativePaths = files.map(f => {
1150
+ const rel = f.replace(project.path, '').replace(/^[\/\\]/, '');
1151
+ return rel;
1152
+ });
1153
+ res.json({
1154
+ projectId: id,
1155
+ tier,
1156
+ strategy,
1157
+ totalSelected: files.length,
1158
+ maxAllowed: TIER_CONFIG[tier].maxFiles,
1159
+ files: relativePaths.slice(0, 100), // Return first 100 for preview
1160
+ hasMore: relativePaths.length > 100
1161
+ });
1162
+ }
1163
+ catch (error) {
1164
+ Logger.error('Failed to get enterprise files:', error);
1165
+ res.status(500).json({ error: 'Failed to get files' });
1166
+ }
1167
+ });
1168
+ /**
1169
+ * POST /api/projects/:id/enterprise/incremental
1170
+ * Run incremental indexing (only changed files)
1171
+ */
1172
+ apiRouter.post('/projects/:id/enterprise/incremental', authMiddleware, checkProjectAccess, async (req, res) => {
1173
+ try {
1174
+ const { id } = req.params;
1175
+ const { since } = req.body;
1176
+ if (!since) {
1177
+ res.status(400).json({ error: 'since parameter required (ISO date or commit hash)' });
1178
+ return;
1179
+ }
1180
+ const project = await projectService.getProject(id);
1181
+ if (!project) {
1182
+ res.status(404).json({ error: 'Project not found' });
1183
+ return;
1184
+ }
1185
+ const indexer = new EnterpriseIndexer(project.path, {
1186
+ tier: 'standard',
1187
+ incremental: { enabled: true, since },
1188
+ sampling: { enabled: false, maxFiles: 50000, strategy: 'smart' }
1189
+ });
1190
+ await indexer.initialize();
1191
+ const changedFiles = await indexer.getChangedFiles(since);
1192
+ if (changedFiles.length === 0) {
1193
+ res.json({
1194
+ message: 'No changed files found',
1195
+ since,
1196
+ changedFiles: 0
1197
+ });
1198
+ return;
1199
+ }
1200
+ // Return changed files info
1201
+ const relativePaths = changedFiles.map(f => f.replace(project.path, '').replace(/^[\/\\]/, ''));
1202
+ res.json({
1203
+ projectId: id,
1204
+ since,
1205
+ changedFiles: changedFiles.length,
1206
+ files: relativePaths.slice(0, 50),
1207
+ hasMore: relativePaths.length > 50,
1208
+ message: `Found ${changedFiles.length} changed files. Use POST /api/projects/:id/enterprise/index with incremental.enabled=true to index them.`
1209
+ });
1210
+ }
1211
+ catch (error) {
1212
+ Logger.error('Failed to check incremental changes:', error);
1213
+ res.status(500).json({ error: 'Failed to check incremental changes' });
1214
+ }
1215
+ });
1216
+ /**
1217
+ * GET /api/enterprise/tiers
1218
+ * Get available analysis tiers info
1219
+ */
1220
+ apiRouter.get('/enterprise/tiers', async (_req, res) => {
1221
+ res.json({
1222
+ tiers: TIER_CONFIG,
1223
+ recommendations: {
1224
+ quick: 'Best for initial exploration of large projects (50K+ files)',
1225
+ standard: 'Recommended for most projects - balances speed and depth',
1226
+ deep: 'For critical projects requiring comprehensive analysis (Enterprise only)'
1227
+ },
1228
+ samplingStrategies: {
1229
+ smart: 'AI-powered selection based on importance, imports, and git activity',
1230
+ 'hot-files': 'Files with most git commits in the last year',
1231
+ 'directory-balanced': 'Equal representation from each directory',
1232
+ random: 'Random sampling'
1233
+ }
1234
+ });
1235
+ });
1236
+ // Register enterprise indexing task executor
1237
+ taskQueue.registerExecutor('enterprise-index', async (task, updateProgress) => {
1238
+ try {
1239
+ const project = await projectService.getProject(task.projectId);
1240
+ if (!project) {
1241
+ return { success: false, error: 'Project not found' };
1242
+ }
1243
+ const options = task.enterpriseOptions || {};
1244
+ updateProgress({ phase: 'initializing', current: 5, message: 'Initializing enterprise indexer...' });
1245
+ const indexer = new EnterpriseIndexer(project.path, options);
1246
+ await indexer.initialize();
1247
+ updateProgress({ phase: 'scanning', current: 10, message: 'Scanning project files...' });
1248
+ const files = await indexer.getFilesToIndex();
1249
+ const tierConfig = indexer.getTierConfig();
1250
+ updateProgress({
1251
+ phase: 'indexing',
1252
+ current: 15,
1253
+ message: `Selected ${files.length} files using ${options.sampling?.strategy || 'smart'} strategy`
1254
+ });
1255
+ // Return the selected files info - actual indexing happens in project service
1256
+ // This task prepares the files, project service handles the rest
1257
+ return {
1258
+ success: true,
1259
+ data: {
1260
+ tier: options.tier || 'standard',
1261
+ selectedFiles: files.length,
1262
+ maxFiles: tierConfig.maxFiles,
1263
+ strategy: options.sampling?.strategy || 'smart',
1264
+ skipEmbeddings: tierConfig.skipEmbeddings,
1265
+ skipSecurity: tierConfig.skipSecurity,
1266
+ skipDuplication: tierConfig.skipDuplication,
1267
+ files: files.slice(0, 100), // First 100 for reference
1268
+ }
1269
+ };
1270
+ }
1271
+ catch (error) {
1272
+ return {
1273
+ success: false,
1274
+ error: error instanceof Error ? error.message : String(error),
1275
+ };
1276
+ }
1277
+ });
1038
1278
  // Helper function to format time
1039
1279
  function formatTime(seconds) {
1040
1280
  if (seconds < 60) {
@@ -4,6 +4,7 @@
4
4
  import { Request, Response, NextFunction } from 'express';
5
5
  import { User as ArchiCoreUser } from '../../types/user.js';
6
6
  export declare const authRouter: import("express-serve-static-core").Router;
7
+ export declare function cleanupAuthIntervals(): void;
7
8
  declare global {
8
9
  namespace Express {
9
10
  interface Request {
@@ -2,6 +2,7 @@
2
2
  * Authentication API Routes for ArchiCore
3
3
  */
4
4
  import { Router } from 'express';
5
+ import crypto from 'crypto';
5
6
  import { AuthService } from '../services/auth-service.js';
6
7
  import { auditService } from '../services/audit-service.js';
7
8
  import { Logger } from '../../utils/logger.js';
@@ -64,12 +65,14 @@ async function deleteVerificationCode(email) {
64
65
  await cache.del(key);
65
66
  verificationCodes.delete(email);
66
67
  }
67
- // Generate 6-digit verification code
68
+ // Generate 6-digit verification code using cryptographically secure random
68
69
  function generateVerificationCode() {
69
- return Math.floor(100000 + Math.random() * 900000).toString();
70
+ // Use crypto.randomInt for secure random number generation
71
+ const code = crypto.randomInt(100000, 1000000);
72
+ return code.toString();
70
73
  }
71
74
  // Cleanup expired memory codes
72
- setInterval(() => {
75
+ const cleanupIntervalId = setInterval(() => {
73
76
  const now = Date.now();
74
77
  for (const [email, data] of verificationCodes.entries()) {
75
78
  if (data.expiresAt < now) {
@@ -77,6 +80,11 @@ setInterval(() => {
77
80
  }
78
81
  }
79
82
  }, 60 * 1000); // Clean up every minute
83
+ // Export cleanup function for graceful shutdown
84
+ export function cleanupAuthIntervals() {
85
+ clearInterval(cleanupIntervalId);
86
+ Logger.info('Auth cleanup intervals cleared');
87
+ }
80
88
  // Middleware to check authentication (supports both Bearer token and httpOnly cookie)
81
89
  export async function authMiddleware(req, res, next) {
82
90
  const authHeader = req.headers.authorization;
@@ -10,7 +10,9 @@
10
10
  import { Router } from 'express';
11
11
  import { mkdir } from 'fs/promises';
12
12
  import { join } from 'path';
13
- import AdmZip from 'adm-zip';
13
+ import { Readable } from 'stream';
14
+ import { pipeline } from 'stream/promises';
15
+ import * as tar from 'tar';
14
16
  import { GitLabService } from '../../gitlab/gitlab-service.js';
15
17
  import { ProjectService } from '../services/project-service.js';
16
18
  import { AuthService } from '../services/auth-service.js';
@@ -106,31 +108,18 @@ gitlabRouter.post('/connect', authMiddleware, async (req, res) => {
106
108
  // Download and create ArchiCore project
107
109
  const targetBranch = branch || project.default_branch || 'main';
108
110
  Logger.progress(`Downloading GitLab repository: ${project.path_with_namespace} (branch: ${targetBranch})`);
109
- const zipBuffer = await gitlabService.downloadRepository(req.user.id, instance.id, project.id, targetBranch);
111
+ const tarGzBuffer = await gitlabService.downloadRepository(req.user.id, instance.id, project.id, targetBranch);
112
+ Logger.info(`Downloaded tar.gz: ${tarGzBuffer.length} bytes`);
110
113
  // Create projects directory
111
114
  const projectsDir = process.env.PROJECTS_DIR || join('.archicore', 'projects');
112
115
  await mkdir(projectsDir, { recursive: true });
113
- // Extract to project directory
116
+ // Extract tar.gz to project directory
114
117
  const projectPath = join(projectsDir, project.name);
115
118
  await mkdir(projectPath, { recursive: true });
116
- const zip = new AdmZip(zipBuffer);
117
- const zipEntries = zip.getEntries();
118
- // Extract files
119
- for (const entry of zipEntries) {
120
- if (entry.isDirectory) {
121
- const dirPath = join(projectPath, entry.entryName);
122
- await mkdir(dirPath, { recursive: true });
123
- }
124
- else {
125
- const filePath = join(projectPath, entry.entryName);
126
- const fileDir = join(projectPath, entry.entryName.split('/').slice(0, -1).join('/'));
127
- await mkdir(fileDir, { recursive: true });
128
- const content = entry.getData();
129
- const { writeFile } = await import('fs/promises');
130
- await writeFile(filePath, content);
131
- }
132
- }
133
- // GitLab creates a folder like "project-branch" inside, find it
119
+ // Extract tar.gz archive from buffer
120
+ const tarStream = Readable.from(tarGzBuffer);
121
+ await pipeline(tarStream, tar.extract({ cwd: projectPath }));
122
+ // GitLab creates a folder like "project-branch-sha" inside, find it
134
123
  const { readdir, stat } = await import('fs/promises');
135
124
  const contents = await readdir(projectPath);
136
125
  let actualPath = projectPath;
@@ -390,36 +379,19 @@ gitlabRouter.post('/repositories/connect', authMiddleware, async (req, res) => {
390
379
  const project = await gitlabService.getProject(req.user.id, instanceId, projectId);
391
380
  const targetBranch = branch || project.default_branch || 'main';
392
381
  Logger.progress(`Downloading GitLab repository: ${project.path_with_namespace} (branch: ${targetBranch})`);
393
- const zipBuffer = await gitlabService.downloadRepository(req.user.id, instanceId, projectId, targetBranch);
394
- Logger.info(`Downloaded ZIP: ${zipBuffer.length} bytes`);
382
+ const tarGzBuffer = await gitlabService.downloadRepository(req.user.id, instanceId, projectId, targetBranch);
383
+ Logger.info(`Downloaded tar.gz: ${tarGzBuffer.length} bytes`);
395
384
  // Create projects directory
396
385
  const projectsDir = process.env.PROJECTS_DIR || join('.archicore', 'projects');
397
386
  await mkdir(projectsDir, { recursive: true });
398
- // Extract to project directory
387
+ // Extract tar.gz to project directory
399
388
  const projectPath = join(projectsDir, project.name);
400
389
  await mkdir(projectPath, { recursive: true });
401
- const zip = new AdmZip(zipBuffer);
402
- const zipEntries = zip.getEntries();
403
- Logger.info(`ZIP contains ${zipEntries.length} entries`);
404
- // Extract files
405
- let extractedCount = 0;
406
- for (const entry of zipEntries) {
407
- if (entry.isDirectory) {
408
- const dirPath = join(projectPath, entry.entryName);
409
- await mkdir(dirPath, { recursive: true });
410
- }
411
- else {
412
- const filePath = join(projectPath, entry.entryName);
413
- const fileDir = join(projectPath, entry.entryName.split('/').slice(0, -1).join('/'));
414
- await mkdir(fileDir, { recursive: true });
415
- const content = entry.getData();
416
- const { writeFile } = await import('fs/promises');
417
- await writeFile(filePath, content);
418
- extractedCount++;
419
- }
420
- }
421
- Logger.info(`Extraction complete: ${extractedCount} files extracted`);
422
- // GitLab creates a folder like "project-branch" inside, find it
390
+ // Extract tar.gz archive from buffer
391
+ const tarStream = Readable.from(tarGzBuffer);
392
+ await pipeline(tarStream, tar.extract({ cwd: projectPath }));
393
+ Logger.info(`Extraction complete`);
394
+ // GitLab creates a folder like "project-branch-sha" inside, find it
423
395
  const { readdir, stat } = await import('fs/promises');
424
396
  const contents = await readdir(projectPath);
425
397
  let actualPath = projectPath;
@@ -7,7 +7,31 @@ import { Router } from 'express';
7
7
  import multer from 'multer';
8
8
  import path from 'path';
9
9
  import fs from 'fs/promises';
10
+ import nodemailer from 'nodemailer';
10
11
  import { Logger } from '../../utils/logger.js';
12
+ // SMTP transporter (lazy initialized)
13
+ let mailTransporter = null;
14
+ function getMailTransporter() {
15
+ if (mailTransporter)
16
+ return mailTransporter;
17
+ const smtpHost = process.env.SMTP_HOST;
18
+ const smtpPort = process.env.SMTP_PORT;
19
+ const smtpUser = process.env.SMTP_USER;
20
+ const smtpPass = process.env.SMTP_PASS;
21
+ if (!smtpHost || !smtpUser || !smtpPass) {
22
+ return null;
23
+ }
24
+ mailTransporter = nodemailer.createTransport({
25
+ host: smtpHost,
26
+ port: parseInt(smtpPort || '465', 10),
27
+ secure: process.env.SMTP_SECURE !== 'false',
28
+ auth: {
29
+ user: smtpUser,
30
+ pass: smtpPass,
31
+ },
32
+ });
33
+ return mailTransporter;
34
+ }
11
35
  export const reportIssueRouter = Router();
12
36
  // Directory for storing reports
13
37
  const REPORTS_DIR = process.env.REPORTS_DIR || path.join('.archicore', 'reports');
@@ -229,6 +253,85 @@ reportIssueRouter.patch('/:id/status', async (req, res) => {
229
253
  * Send notification about new report (optional integrations)
230
254
  */
231
255
  async function sendNotification(report) {
256
+ // Email notification (primary)
257
+ const transporter = getMailTransporter();
258
+ const emailTo = process.env.REPORT_EMAIL || process.env.SMTP_USER || 'info@archicore.io';
259
+ const emailFrom = process.env.EMAIL_FROM || process.env.SMTP_USER;
260
+ const emailFromName = process.env.EMAIL_FROM_NAME || 'ArchiCore';
261
+ if (transporter && emailFrom) {
262
+ try {
263
+ const priorityColors = {
264
+ critical: '#ff0000',
265
+ high: '#ff6600',
266
+ medium: '#ffcc00',
267
+ low: '#00cc00'
268
+ };
269
+ const htmlContent = `
270
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
271
+ <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0;">
272
+ <h1 style="color: white; margin: 0;">New Issue Report</h1>
273
+ </div>
274
+ <div style="background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-top: none;">
275
+ <table style="width: 100%; border-collapse: collapse;">
276
+ <tr>
277
+ <td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Report ID:</strong></td>
278
+ <td style="padding: 10px; border-bottom: 1px solid #eee; font-family: monospace;">${report.id}</td>
279
+ </tr>
280
+ <tr>
281
+ <td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Category:</strong></td>
282
+ <td style="padding: 10px; border-bottom: 1px solid #eee;">${report.category}</td>
283
+ </tr>
284
+ <tr>
285
+ <td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Priority:</strong></td>
286
+ <td style="padding: 10px; border-bottom: 1px solid #eee;">
287
+ <span style="background: ${priorityColors[report.priority] || '#808080'}; color: white; padding: 3px 8px; border-radius: 4px; font-weight: bold;">
288
+ ${report.priority.toUpperCase()}
289
+ </span>
290
+ </td>
291
+ </tr>
292
+ ${report.email ? `
293
+ <tr>
294
+ <td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Reporter Email:</strong></td>
295
+ <td style="padding: 10px; border-bottom: 1px solid #eee;"><a href="mailto:${report.email}">${report.email}</a></td>
296
+ </tr>
297
+ ` : ''}
298
+ <tr>
299
+ <td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Submitted:</strong></td>
300
+ <td style="padding: 10px; border-bottom: 1px solid #eee;">${new Date(report.timestamp).toLocaleString()}</td>
301
+ </tr>
302
+ </table>
303
+ <div style="margin-top: 20px; padding: 15px; background: white; border: 1px solid #ddd; border-radius: 4px;">
304
+ <strong>Description:</strong>
305
+ <p style="white-space: pre-wrap; margin: 10px 0 0 0;">${report.description}</p>
306
+ </div>
307
+ ${report.additionalInfo ? `
308
+ <div style="margin-top: 15px; padding: 15px; background: white; border: 1px solid #ddd; border-radius: 4px;">
309
+ <strong>Additional Info:</strong>
310
+ <p style="white-space: pre-wrap; margin: 10px 0 0 0;">${report.additionalInfo}</p>
311
+ </div>
312
+ ` : ''}
313
+ ${report.hasScreenshot ? `
314
+ <p style="margin-top: 15px; color: #666;"><em>📎 Screenshot attached to this report</em></p>
315
+ ` : ''}
316
+ </div>
317
+ <div style="background: #333; color: #999; padding: 15px; border-radius: 0 0 8px 8px; text-align: center; font-size: 12px;">
318
+ ArchiCore Issue Tracker - Automated Notification
319
+ </div>
320
+ </div>
321
+ `;
322
+ await transporter.sendMail({
323
+ from: `"${emailFromName}" <${emailFrom}>`,
324
+ to: emailTo,
325
+ subject: `[${report.priority.toUpperCase()}] New Issue Report: ${report.category}`,
326
+ html: htmlContent,
327
+ text: `New Issue Report\n\nID: ${report.id}\nCategory: ${report.category}\nPriority: ${report.priority}\nEmail: ${report.email || 'Not provided'}\n\nDescription:\n${report.description}\n\nAdditional Info:\n${report.additionalInfo || 'None'}`
328
+ });
329
+ Logger.info(`Email notification sent for report ${report.id}`);
330
+ }
331
+ catch (err) {
332
+ Logger.warn('Failed to send email notification:', err);
333
+ }
334
+ }
232
335
  // Discord webhook
233
336
  const discordWebhook = process.env.DISCORD_WEBHOOK_URL;
234
337
  if (discordWebhook) {
@@ -111,7 +111,7 @@ uploadRouter.post('/', authMiddleware, upload.single('file'), async (req, res) =
111
111
  * - threats: SecurityThreat[]
112
112
  * - warnings: string[]
113
113
  */
114
- uploadRouter.post('/scan', upload.single('file'), async (req, res) => {
114
+ uploadRouter.post('/scan', authMiddleware, upload.single('file'), async (req, res) => {
115
115
  try {
116
116
  if (!req.file) {
117
117
  res.status(400).json({ error: 'No file uploaded' });
@@ -151,7 +151,7 @@ uploadRouter.post('/scan', upload.single('file'), async (req, res) => {
151
151
  * GET /api/upload/projects
152
152
  * Получить список загруженных проектов
153
153
  */
154
- uploadRouter.get('/projects', async (_req, res) => {
154
+ uploadRouter.get('/projects', authMiddleware, async (_req, res) => {
155
155
  try {
156
156
  const projects = await uploadService.listUploadedProjects();
157
157
  res.json({ projects });
@@ -165,7 +165,7 @@ uploadRouter.get('/projects', async (_req, res) => {
165
165
  * DELETE /api/upload/:uploadId
166
166
  * Удалить загруженный проект
167
167
  */
168
- uploadRouter.delete('/:uploadId', async (req, res) => {
168
+ uploadRouter.delete('/:uploadId', authMiddleware, async (req, res) => {
169
169
  try {
170
170
  const { uploadId } = req.params;
171
171
  await uploadService.deleteProject(uploadId);