archicore 0.3.6 → 0.3.7
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/interactive.js +220 -1
- package/dist/github/github-service.d.ts +4 -4
- package/dist/github/github-service.js +29 -13
- package/dist/gitlab/gitlab-service.js +48 -15
- package/dist/server/index.js +68 -1
- package/dist/server/routes/api.js +242 -2
- package/dist/server/routes/auth.d.ts +1 -0
- package/dist/server/routes/auth.js +11 -3
- package/dist/server/routes/gitlab.js +18 -46
- package/dist/server/routes/report-issue.js +103 -0
- package/dist/server/routes/upload.js +3 -3
- package/dist/server/services/encryption.js +20 -4
- package/dist/server/services/enterprise-indexer.d.ts +116 -0
- package/dist/server/services/enterprise-indexer.js +529 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
394
|
-
Logger.info(`Downloaded
|
|
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
|
-
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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);
|
|
@@ -19,14 +19,27 @@ class EncryptionService {
|
|
|
19
19
|
if (this.initialized)
|
|
20
20
|
return;
|
|
21
21
|
const encryptionKey = process.env.ENCRYPTION_KEY;
|
|
22
|
+
const encryptionSalt = process.env.ENCRYPTION_SALT;
|
|
23
|
+
// SECURITY: Require encryption key in production
|
|
22
24
|
if (!encryptionKey) {
|
|
25
|
+
if (process.env.NODE_ENV === 'production') {
|
|
26
|
+
Logger.error('CRITICAL: ENCRYPTION_KEY is required in production');
|
|
27
|
+
throw new Error('ENCRYPTION_KEY environment variable is required');
|
|
28
|
+
}
|
|
23
29
|
Logger.warn('ENCRYPTION_KEY not set - generating temporary key (NOT FOR PRODUCTION)');
|
|
24
|
-
// Generate a temporary key for development
|
|
25
30
|
this.key = crypto.randomBytes(32);
|
|
26
31
|
}
|
|
27
32
|
else {
|
|
33
|
+
// SECURITY: Require salt in production
|
|
34
|
+
if (!encryptionSalt) {
|
|
35
|
+
if (process.env.NODE_ENV === 'production') {
|
|
36
|
+
Logger.error('CRITICAL: ENCRYPTION_SALT is required in production');
|
|
37
|
+
throw new Error('ENCRYPTION_SALT environment variable is required');
|
|
38
|
+
}
|
|
39
|
+
Logger.warn('ENCRYPTION_SALT not set - using random salt (NOT FOR PRODUCTION)');
|
|
40
|
+
}
|
|
28
41
|
// Derive key from password using PBKDF2
|
|
29
|
-
const salt =
|
|
42
|
+
const salt = encryptionSalt || crypto.randomBytes(32).toString('hex');
|
|
30
43
|
this.key = crypto.pbkdf2Sync(encryptionKey, salt, 100000, 32, 'sha256');
|
|
31
44
|
}
|
|
32
45
|
this.initialized = true;
|
|
@@ -93,9 +106,12 @@ class EncryptionService {
|
|
|
93
106
|
* Hash a value (one-way, for comparison)
|
|
94
107
|
*/
|
|
95
108
|
hash(value) {
|
|
96
|
-
const salt = process.env.ENCRYPTION_SALT
|
|
109
|
+
const salt = process.env.ENCRYPTION_SALT;
|
|
110
|
+
if (!salt && process.env.NODE_ENV === 'production') {
|
|
111
|
+
throw new Error('ENCRYPTION_SALT is required for hashing in production');
|
|
112
|
+
}
|
|
97
113
|
return crypto
|
|
98
|
-
.createHmac('sha256', salt)
|
|
114
|
+
.createHmac('sha256', salt || 'dev-only-salt')
|
|
99
115
|
.update(value)
|
|
100
116
|
.digest('hex');
|
|
101
117
|
}
|