archicore 0.2.0 → 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.
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Report Issue Routes
3
+ *
4
+ * API for submitting bug reports and feedback
5
+ */
6
+ export declare const reportIssueRouter: import("express-serve-static-core").Router;
7
+ //# sourceMappingURL=report-issue.d.ts.map
@@ -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
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Audit Log Service for ArchiCore
3
+ *
4
+ * Logs user actions for security and compliance purposes
5
+ * Supports PostgreSQL (primary) with JSON file fallback
6
+ */
7
+ export type AuditAction = 'auth.login' | 'auth.logout' | 'auth.register' | 'auth.password_change' | 'auth.device_login' | 'user.update' | 'user.tier_change' | 'user.delete' | 'project.create' | 'project.delete' | 'project.analyze' | 'project.simulate' | 'project.ask' | 'project.docs' | 'github.connect' | 'github.disconnect' | 'github.repo_connect' | 'github.repo_disconnect' | 'github.webhook_received' | 'api.key_create' | 'api.key_revoke' | 'api.request' | 'admin.login' | 'admin.user_update' | 'admin.user_delete' | 'admin.tier_change' | 'admin.view_logs';
8
+ export type AuditSeverity = 'info' | 'warning' | 'critical';
9
+ export interface AuditLogEntry {
10
+ id: string;
11
+ timestamp: Date;
12
+ userId?: string;
13
+ username?: string;
14
+ action: AuditAction;
15
+ severity: AuditSeverity;
16
+ ip?: string;
17
+ userAgent?: string;
18
+ details?: Record<string, any>;
19
+ success: boolean;
20
+ errorMessage?: string;
21
+ }
22
+ export interface AuditLogQuery {
23
+ userId?: string;
24
+ action?: AuditAction;
25
+ severity?: AuditSeverity;
26
+ startDate?: Date;
27
+ endDate?: Date;
28
+ success?: boolean;
29
+ limit?: number;
30
+ offset?: number;
31
+ }
32
+ export declare class AuditService {
33
+ private dataDir;
34
+ private logs;
35
+ private jsonInitialized;
36
+ private saveTimeout;
37
+ constructor(dataDir?: string);
38
+ private useDatabase;
39
+ private ensureJsonInitialized;
40
+ private saveJson;
41
+ private generateId;
42
+ private getSeverity;
43
+ private rowToEntry;
44
+ /**
45
+ * Log an action
46
+ */
47
+ log(params: {
48
+ userId?: string;
49
+ username?: string;
50
+ action: AuditAction;
51
+ ip?: string;
52
+ userAgent?: string;
53
+ details?: Record<string, any>;
54
+ success?: boolean;
55
+ errorMessage?: string;
56
+ }): Promise<AuditLogEntry>;
57
+ /**
58
+ * Query audit logs
59
+ */
60
+ query(params?: AuditLogQuery): Promise<{
61
+ logs: AuditLogEntry[];
62
+ total: number;
63
+ }>;
64
+ /**
65
+ * Get recent logs for a user
66
+ */
67
+ getUserLogs(userId: string, limit?: number): Promise<AuditLogEntry[]>;
68
+ /**
69
+ * Get failed login attempts for an IP
70
+ */
71
+ getFailedLogins(ip: string, since: Date): Promise<number>;
72
+ /**
73
+ * Get statistics
74
+ */
75
+ getStats(days?: number): Promise<{
76
+ totalActions: number;
77
+ uniqueUsers: number;
78
+ failedLogins: number;
79
+ criticalEvents: number;
80
+ actionCounts: Record<string, number>;
81
+ }>;
82
+ /**
83
+ * Clear old logs (retention policy)
84
+ */
85
+ cleanup(retentionDays?: number): Promise<number>;
86
+ }
87
+ export declare const auditService: AuditService;
88
+ //# sourceMappingURL=audit-service.d.ts.map