@mmmbuto/nexuscrew 0.1.0-beta.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +54 -120
  2. package/bin/nexuscrew.js +9 -8
  3. package/frontend/dist/assets/{index-C7bAndew.js → index-8pw4eMB-.js} +1715 -1702
  4. package/frontend/dist/assets/index-BF0tdvNT.css +1 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/lib/config/manager.js +362 -0
  7. package/lib/config/models.js +408 -0
  8. package/lib/server/db/adapter.js +274 -0
  9. package/lib/server/db/drivers/sql-js.js +75 -0
  10. package/lib/server/db/migrate.js +174 -0
  11. package/lib/server/db/migrations/001_base_schema.sql +70 -0
  12. package/lib/server/middleware/auth.js +134 -0
  13. package/lib/server/middleware/rate-limit.js +63 -0
  14. package/lib/server/models/User.js +128 -0
  15. package/lib/server/routes/auth.js +168 -0
  16. package/lib/server/routes/keys.js +15 -0
  17. package/lib/server/routes/models.js +59 -4
  18. package/lib/server/routes/runtimes.js +34 -0
  19. package/lib/server/routes/send.js +28 -4
  20. package/lib/server/routes/sessions.js +34 -2
  21. package/lib/server/routes/speech.js +75 -0
  22. package/lib/server/routes/upload.js +134 -0
  23. package/lib/server/routes/wake-lock.js +95 -0
  24. package/lib/server/routes/workspaces.js +101 -0
  25. package/lib/server/server.js +48 -16
  26. package/lib/server/services/context-bridge.js +425 -0
  27. package/lib/server/services/runtime-manager.js +462 -0
  28. package/lib/server/services/summary-generator.js +309 -0
  29. package/lib/server/services/workspace-manager.js +79 -0
  30. package/lib/services/engine-discovery.js +198 -13
  31. package/lib/services/session-store.js +57 -61
  32. package/lib/utils/paths.js +107 -0
  33. package/lib/utils/termux.js +145 -0
  34. package/package.json +6 -2
  35. package/frontend/dist/assets/index-OENqI1_9.css +0 -1
@@ -0,0 +1,168 @@
1
+ const express = require('express');
2
+ const rateLimit = require('express-rate-limit');
3
+ const bcrypt = require('bcryptjs');
4
+ const User = require('../models/User');
5
+ const { generateToken, authMiddleware } = require('../middleware/auth');
6
+ const { getConfig } = require('../../config/manager');
7
+
8
+ const router = express.Router();
9
+
10
+ /**
11
+ * Check config user (admin from init)
12
+ * Returns user object compatible with DB user structure
13
+ */
14
+ function findConfigUser(username) {
15
+ const config = getConfig();
16
+ if (config.auth && config.auth.user === username && config.auth.pass_hash) {
17
+ return {
18
+ id: 'config-admin',
19
+ username: config.auth.user,
20
+ password_hash: config.auth.pass_hash,
21
+ role: 'admin',
22
+ is_locked: false,
23
+ failed_attempts: 0,
24
+ created_at: Date.now()
25
+ };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /**
31
+ * Verify password for config user
32
+ */
33
+ function verifyConfigPassword(passHash, password) {
34
+ return bcrypt.compareSync(password, passHash);
35
+ }
36
+
37
+ // Rate limiter: max 5 login attempts per 15 minutes per IP
38
+ const loginLimiter = rateLimit({
39
+ windowMs: 15 * 60 * 1000, // 15 minutes
40
+ max: 10,
41
+ message: {
42
+ error: 'Too many login attempts, please try again later',
43
+ retry_after: 15 * 60
44
+ },
45
+ standardHeaders: true,
46
+ legacyHeaders: false
47
+ });
48
+
49
+ // POST /api/v1/auth/login
50
+ router.post('/login', loginLimiter, async (req, res) => {
51
+ try {
52
+ const { username, password } = req.body;
53
+ const ipAddress = req.ip || req.connection.remoteAddress;
54
+
55
+ if (!username || !password) {
56
+ return res.status(400).json({ error: 'Username and password required' });
57
+ }
58
+
59
+ // Check IP rate limiting (additional layer)
60
+ const recentAttempts = User.getRecentLoginAttempts(ipAddress);
61
+ if (recentAttempts > 20) {
62
+ return res.status(429).json({
63
+ error: 'Too many failed attempts from this IP',
64
+ retry_after: 15 * 60
65
+ });
66
+ }
67
+
68
+ // First check config user (admin from init)
69
+ let user = findConfigUser(username);
70
+ let isConfigUser = !!user;
71
+
72
+ // If not config user, check database
73
+ if (!user) {
74
+ user = User.findByUsername(username);
75
+ }
76
+
77
+ if (!user) {
78
+ // Log failed attempt even for non-existent user
79
+ User.logLoginAttempt(ipAddress, username, false);
80
+ return res.status(401).json({ error: 'Invalid credentials' });
81
+ }
82
+
83
+ // Check if account is locked (only for DB users)
84
+ if (!isConfigUser && User.isAccountLocked(user)) {
85
+ User.logLoginAttempt(ipAddress, username, false);
86
+ const remainingMs = user.locked_until - Date.now();
87
+ return res.status(403).json({
88
+ error: 'Account locked due to failed login attempts',
89
+ locked_until: user.locked_until,
90
+ retry_after: Math.ceil(remainingMs / 1000)
91
+ });
92
+ }
93
+
94
+ // Verify password
95
+ let isValid;
96
+ if (isConfigUser) {
97
+ isValid = verifyConfigPassword(user.password_hash, password);
98
+ } else {
99
+ isValid = User.verifyPassword(user, password);
100
+ }
101
+
102
+ if (!isValid) {
103
+ User.logLoginAttempt(ipAddress, username, false);
104
+ if (!isConfigUser) {
105
+ User.incrementFailedAttempts(user.id);
106
+ }
107
+ return res.status(401).json({ error: 'Invalid credentials' });
108
+ }
109
+
110
+ // Success
111
+ User.logLoginAttempt(ipAddress, username, true);
112
+ if (!isConfigUser) {
113
+ User.resetFailedAttempts(user.id);
114
+ User.updateLastLogin(user.id);
115
+ }
116
+
117
+ const token = generateToken(user);
118
+
119
+ res.json({
120
+ token,
121
+ user: {
122
+ id: user.id,
123
+ username: user.username,
124
+ role: user.role
125
+ }
126
+ });
127
+ } catch (error) {
128
+ console.error('Login error:', error);
129
+ res.status(500).json({ error: 'Internal server error' });
130
+ }
131
+ });
132
+
133
+ // GET /api/v1/auth/me
134
+ router.get('/me', authMiddleware, (req, res) => {
135
+ // Config user is already validated by authMiddleware
136
+ // Just return the user info from req.user
137
+ if (req.user.id === 'config-admin') {
138
+ const config = getConfig();
139
+ return res.json({
140
+ id: req.user.id,
141
+ username: req.user.username,
142
+ role: req.user.role,
143
+ created_at: Date.now(),
144
+ last_login: null
145
+ });
146
+ }
147
+
148
+ const user = User.findById(req.user.id);
149
+ if (!user) {
150
+ return res.status(404).json({ error: 'User not found' });
151
+ }
152
+
153
+ res.json({
154
+ id: user.id,
155
+ username: user.username,
156
+ role: user.role,
157
+ created_at: user.created_at,
158
+ last_login: user.last_login
159
+ });
160
+ });
161
+
162
+ // POST /api/v1/auth/logout
163
+ router.post('/logout', authMiddleware, (req, res) => {
164
+ // With JWT, logout is handled client-side by removing token
165
+ res.json({ message: 'Logged out successfully' });
166
+ });
167
+
168
+ module.exports = router;
@@ -0,0 +1,15 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const { getApiKey } = require('../db/adapter');
4
+
5
+ /**
6
+ * GET /api/v1/keys/check/:provider
7
+ * Check if an API key is configured for a provider
8
+ * Returns: { configured: boolean }
9
+ */
10
+ router.get('/check/:provider', (req, res) => {
11
+ const key = getApiKey(req.params.provider);
12
+ res.json({ configured: !!key });
13
+ });
14
+
15
+ module.exports = router;
@@ -1,14 +1,69 @@
1
1
  /**
2
2
  * GET /api/v1/models — List available models and engines
3
+ *
4
+ * Merges engine-discovery results with runtime-manager status
3
5
  */
4
6
 
5
7
  const { Router } = require('express');
8
+ const RuntimeManager = require('../services/runtime-manager');
6
9
  const router = Router();
7
10
 
8
- router.get('/', (req, res) => {
9
- const { engines } = req;
10
- const data = engines.getForFrontend();
11
- res.json(data);
11
+ const runtimeManager = new RuntimeManager();
12
+
13
+ router.get('/', async (req, res) => {
14
+ try {
15
+ // Get engine discovery results
16
+ const { engines } = req;
17
+ const discoveryData = engines.getForFrontend();
18
+
19
+ // Get runtime-aware inventory
20
+ const runtimeInventory = await runtimeManager.getRuntimeInventory();
21
+ const runtimeMap = Object.fromEntries(
22
+ runtimeInventory.map(r => [r.runtimeId, r])
23
+ );
24
+
25
+ // Merge data
26
+ const merged = {};
27
+
28
+ for (const [engineName, engineData] of Object.entries(discoveryData)) {
29
+ merged[engineName] = {
30
+ ...engineData,
31
+ models: engineData.models.map(model => {
32
+ const runtimeId = model.lane === 'custom'
33
+ ? `${engineName}-custom`
34
+ : `${engineName}-native`;
35
+
36
+ const runtime = runtimeMap[runtimeId];
37
+
38
+ return {
39
+ id: model.id,
40
+ label: model.name,
41
+ name: model.name,
42
+ lane: model.lane,
43
+ available: runtime?.available ?? false,
44
+ runtimeStatus: runtime?.status ?? 'unknown',
45
+ runtimeCommand: runtime?.command || null,
46
+ runtimeSource: runtime?.source || null,
47
+ providerId: model.providerId || null,
48
+ ...(model.alias && { alias: model.alias }),
49
+ ...(model.modelHint && { modelHint: model.modelHint }),
50
+ };
51
+ }),
52
+ aliases: engineData.aliases.map(alias => {
53
+ const runtime = runtimeMap[`${engineName}-custom`];
54
+ return {
55
+ ...alias,
56
+ available: runtime?.available ?? false,
57
+ };
58
+ })
59
+ };
60
+ }
61
+
62
+ res.json(merged);
63
+ } catch (error) {
64
+ console.error('[Models] Error merging discovery and runtime data:', error);
65
+ res.status(500).json({ error: 'Failed to fetch models' });
66
+ }
12
67
  });
13
68
 
14
69
  module.exports = router;
@@ -0,0 +1,34 @@
1
+ const express = require('express');
2
+ const RuntimeManager = require('../services/runtime-manager');
3
+
4
+ const router = express.Router();
5
+ const runtimeManager = new RuntimeManager();
6
+
7
+ router.get('/', async (_req, res) => {
8
+ try {
9
+ const inventory = await runtimeManager.getRuntimeInventory();
10
+ res.json({
11
+ platform: runtimeManager.platformId,
12
+ runtimes: inventory,
13
+ });
14
+ } catch (error) {
15
+ console.error('[Runtimes] Inventory error:', error);
16
+ res.status(500).json({ error: 'Failed to fetch runtime inventory' });
17
+ }
18
+ });
19
+
20
+ router.post('/check', async (_req, res) => {
21
+ try {
22
+ const inventory = await runtimeManager.getRuntimeInventory();
23
+ res.json({
24
+ platform: runtimeManager.platformId,
25
+ runtimes: inventory,
26
+ checkedAt: new Date().toISOString(),
27
+ });
28
+ } catch (error) {
29
+ console.error('[Runtimes] Check error:', error);
30
+ res.status(500).json({ error: 'Failed to check runtimes' });
31
+ }
32
+ });
33
+
34
+ module.exports = router;
@@ -68,11 +68,32 @@ router.post('/', async (req, res) => {
68
68
  });
69
69
  }
70
70
 
71
+ // Context Bridge: if engine changed, add context to message
72
+ let fullMessage = message;
73
+ if (!isNew && conversationId && conversation.engine && conversation.engine !== resolvedEngine) {
74
+ try {
75
+ const { default: ContextBridge } = require('../services/context-bridge');
76
+ const ctx = await ContextBridge.buildContext({
77
+ conversationId: conversation.id,
78
+ fromEngine: conversation.engine,
79
+ toEngine: resolvedEngine,
80
+ userMessage: message
81
+ });
82
+ if (ctx.prompt) {
83
+ fullMessage = ctx.prompt;
84
+ console.log(`[send] Applied context bridge: ${conversation.engine} -> ${resolvedEngine} (${ctx.contextTokens} tokens)`);
85
+ }
86
+ } catch (err) {
87
+ console.warn('[send] Context bridge failed, proceeding without context:', err.message);
88
+ // Continue without context - non-fatal
89
+ }
90
+ }
91
+
71
92
  // Save user message
72
93
  store.addMessage({
73
94
  conversationId: conversation.id,
74
95
  role: 'user',
75
- content: message,
96
+ content: fullMessage,
76
97
  engine: resolvedEngine,
77
98
  model: model
78
99
  });
@@ -92,6 +113,7 @@ router.post('/', async (req, res) => {
92
113
  } else if (engineInfo?.available) {
93
114
  command = buildEngineCommand(resolvedEngine, engineInfo, {
94
115
  model,
116
+ message: fullMessage, // Pass fullMessage for codex exec mode
95
117
  resume: resumeSession || (conversation.tmux_window ? conversation.id : null)
96
118
  });
97
119
  } else {
@@ -105,7 +127,7 @@ router.post('/', async (req, res) => {
105
127
 
106
128
  if (conversation.tmux_window && tmux.hasWindow(conversation.tmux_window, hostObj)) {
107
129
  // Resume existing window — send message directly
108
- tmux.sendKeys(conversation.tmux_window, message, { enter: true, host: hostName });
130
+ tmux.sendKeys(conversation.tmux_window, fullMessage, { enter: true, host: hostName });
109
131
  logPath = conversation.log_path;
110
132
  } else {
111
133
  // Create new tmux window
@@ -127,7 +149,7 @@ router.post('/', async (req, res) => {
127
149
  if (resolvedEngine !== 'codex') {
128
150
  // Interactive CLIs need a moment to start
129
151
  await new Promise(r => setTimeout(r, 1500));
130
- tmux.sendKeys(windowName, message, { enter: true, host: hostName });
152
+ tmux.sendKeys(windowName, fullMessage, { enter: true, host: hostName });
131
153
  }
132
154
  // Codex exec mode gets the message via the command itself
133
155
  }
@@ -255,7 +277,9 @@ function buildEngineCommand(engine, engineInfo, opts = {}) {
255
277
  return `${bin} --output-format json${opts.model ? ` --model ${opts.model}` : ''}`;
256
278
 
257
279
  case 'codex':
258
- return `${bin} exec --json${opts.model ? ` -m ${opts.model}` : ''}${opts.reasoningEffort ? ` --reasoning ${opts.reasoningEffort}` : ''}`;
280
+ // For codex, message is passed via --prompt flag in exec mode
281
+ const prompt = opts.message ? ` --prompt "${opts.message.replace(/"/g, '\\"')}"` : '';
282
+ return `${bin} exec --json${opts.model ? ` -m ${opts.model}` : ''}${opts.reasoningEffort ? ` --reasoning ${opts.reasoningEffort}` : ''}${prompt}`;
259
283
 
260
284
  case 'gemini':
261
285
  if (opts.resume) {
@@ -1,8 +1,9 @@
1
1
  /**
2
- * GET /api/v1/sessions — List/load sessions
2
+ * GET /api/v1/sessions — List/load sessions (with tmux_alive status)
3
3
  * GET /api/v1/sessions/:id/messages — Get session messages
4
4
  * GET /api/v1/sessions/:id/summary — Get session summary
5
5
  * POST /api/v1/sessions/:id/rename — Rename session
6
+ * POST /api/v1/sessions/:id/pin — Pin/unpin session
6
7
  * DELETE /api/v1/sessions/:id — Delete session
7
8
  */
8
9
 
@@ -11,15 +12,39 @@ const router = Router();
11
12
 
12
13
  // List conversations (optionally grouped by date)
13
14
  router.get('/', (req, res) => {
14
- const { store } = req;
15
+ const { store, tmux } = req;
15
16
  const { workspace, groupBy } = req.query;
16
17
 
17
18
  if (groupBy === 'date') {
18
19
  const grouped = store.listGroupedByDate({ workspace });
20
+
21
+ // Enrich with tmux_alive status
22
+ Object.keys(grouped).forEach(dateGroup => {
23
+ grouped[dateGroup].forEach(conv => {
24
+ if (conv.tmux_window) {
25
+ const host = conv.host !== 'local' ? conv.host : null;
26
+ conv.tmux_alive = tmux.hasWindow(conv.tmux_window, host);
27
+ } else {
28
+ conv.tmux_alive = false;
29
+ }
30
+ });
31
+ });
32
+
19
33
  return res.json(grouped);
20
34
  }
21
35
 
22
36
  const convs = store.listConversations({ workspace });
37
+
38
+ // Enrich with tmux_alive status
39
+ convs.forEach(conv => {
40
+ if (conv.tmux_window) {
41
+ const host = conv.host !== 'local' ? conv.host : null;
42
+ conv.tmux_alive = tmux.hasWindow(conv.tmux_window, host);
43
+ } else {
44
+ conv.tmux_alive = false;
45
+ }
46
+ });
47
+
23
48
  res.json({ conversations: convs });
24
49
  });
25
50
 
@@ -79,6 +104,13 @@ router.post('/:id/rename', (req, res) => {
79
104
  res.json({ success: true, title });
80
105
  });
81
106
 
107
+ // Pin/unpin conversation
108
+ router.post('/:id/pin', (req, res) => {
109
+ const { store } = req;
110
+ const result = store.pinConversation(req.params.id);
111
+ res.json({ pinned: result });
112
+ });
113
+
82
114
  // Delete conversation (kills tmux window too)
83
115
  router.delete('/:id', (req, res) => {
84
116
  const { store, tmux, logWatcher } = req;
@@ -0,0 +1,75 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const multer = require('multer');
4
+ const { getApiKey } = require('../db/adapter');
5
+
6
+ const upload = multer({
7
+ storage: multer.memoryStorage(),
8
+ limits: { fileSize: 25 * 1024 * 1024 } // 25MB max (Whisper limit)
9
+ });
10
+
11
+ /**
12
+ * POST /api/v1/speech/transcribe
13
+ * Transcribe audio using OpenAI Whisper API
14
+ * Body: multipart/form-data with 'audio' file and 'language' (optional)
15
+ * Returns: { text: string }
16
+ *
17
+ * Supported languages (aligned with UI): it, en, es, ja, ru, zh
18
+ */
19
+ router.post('/transcribe', upload.single('audio'), async (req, res) => {
20
+ try {
21
+ const apiKey = getApiKey('openai');
22
+ if (!apiKey) {
23
+ return res.status(400).json({ error: 'OpenAI API key not configured' });
24
+ }
25
+
26
+ if (!req.file) {
27
+ return res.status(400).json({ error: 'No audio file provided' });
28
+ }
29
+
30
+ // Build FormData using native Node.js 22+ FormData and File
31
+ const formData = new FormData();
32
+ const audioFile = new File([req.file.buffer], 'audio.webm', {
33
+ type: req.file.mimetype || 'audio/webm'
34
+ });
35
+ formData.append('file', audioFile);
36
+ formData.append('model', 'whisper-1');
37
+
38
+ // Use language from request - extract base language (it-IT -> it)
39
+ if (req.body.language) {
40
+ const lang = req.body.language.split('-')[0]; // it-IT -> it
41
+ formData.append('language', lang);
42
+ }
43
+
44
+ console.log('[Speech] Transcribing audio:', {
45
+ size: req.file.size,
46
+ mimetype: req.file.mimetype,
47
+ language: req.body.language || 'auto-detect'
48
+ });
49
+
50
+ // Use native fetch (Node.js 22+)
51
+ const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
52
+ method: 'POST',
53
+ headers: {
54
+ 'Authorization': `Bearer ${apiKey}`
55
+ },
56
+ body: formData
57
+ });
58
+
59
+ if (!response.ok) {
60
+ const error = await response.json();
61
+ console.error('[Speech] OpenAI error:', error);
62
+ return res.status(response.status).json(error);
63
+ }
64
+
65
+ const data = await response.json();
66
+ console.log('[Speech] Transcription success:', data.text?.substring(0, 50) + '...');
67
+ res.json(data);
68
+
69
+ } catch (error) {
70
+ console.error('[Speech] Transcription error:', error);
71
+ res.status(500).json({ error: error.message });
72
+ }
73
+ });
74
+
75
+ module.exports = router;
@@ -0,0 +1,134 @@
1
+ const express = require('express');
2
+ const multer = require('multer');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ const router = express.Router();
7
+
8
+ // NexusCrew: attachments in ~/.nexuscrew/attachments
9
+ const ATTACHMENTS_DIR = path.join(process.env.HOME, '.nexuscrew', 'attachments');
10
+
11
+ // Ensure directory exists
12
+ if (!fs.existsSync(ATTACHMENTS_DIR)) {
13
+ fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
14
+ console.log(`[Upload] Created attachments directory: ${ATTACHMENTS_DIR}`);
15
+ }
16
+
17
+ // Multer storage config
18
+ const storage = multer.diskStorage({
19
+ destination: (req, file, cb) => {
20
+ cb(null, ATTACHMENTS_DIR);
21
+ },
22
+ filename: (req, file, cb) => {
23
+ // Unique filename: timestamp_originalname
24
+ const timestamp = Date.now();
25
+ const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
26
+ cb(null, `${timestamp}_${safeName}`);
27
+ }
28
+ });
29
+
30
+ // File filter - allow images and documents
31
+ const fileFilter = (req, file, cb) => {
32
+ const allowedMimes = [
33
+ // Images
34
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
35
+ // Documents
36
+ 'text/plain', 'text/markdown', 'text/csv', 'text/html', 'text/css',
37
+ 'application/json', 'application/xml', 'application/pdf',
38
+ 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
39
+ 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
40
+ // Code files (often sent as octet-stream)
41
+ 'application/octet-stream', 'text/x-python', 'text/javascript', 'application/javascript'
42
+ ];
43
+
44
+ // Also allow by extension for code files
45
+ const allowedExts = ['.js', '.ts', '.py', '.java', '.c', '.cpp', '.h', '.rb', '.php', '.go', '.rs', '.sh', '.bash', '.zsh', '.md', '.txt', '.json', '.yaml', '.yml', '.toml', '.xml', '.html', '.css', '.sql', '.log'];
46
+ const ext = path.extname(file.originalname).toLowerCase();
47
+
48
+ if (allowedMimes.includes(file.mimetype) || allowedExts.includes(ext)) {
49
+ cb(null, true);
50
+ } else {
51
+ cb(new Error(`File type not allowed: ${file.mimetype} (${ext})`), false);
52
+ }
53
+ };
54
+
55
+ // Multer config - 50MB limit
56
+ const upload = multer({
57
+ storage,
58
+ fileFilter,
59
+ limits: { fileSize: 50 * 1024 * 1024 }
60
+ });
61
+
62
+ // POST /api/v1/upload - Single file upload
63
+ router.post('/', upload.single('file'), (req, res) => {
64
+ try {
65
+ if (!req.file) {
66
+ return res.status(400).json({ error: 'No file uploaded' });
67
+ }
68
+
69
+ const filePath = req.file.path;
70
+ const fileName = req.file.filename;
71
+ const originalName = req.file.originalname;
72
+ const mimeType = req.file.mimetype;
73
+ const size = req.file.size;
74
+
75
+ console.log(`[Upload] File saved: ${filePath} (${size} bytes, ${mimeType})`);
76
+
77
+ res.json({
78
+ success: true,
79
+ file: {
80
+ path: filePath,
81
+ name: fileName,
82
+ originalName,
83
+ mimeType,
84
+ size
85
+ }
86
+ });
87
+ } catch (error) {
88
+ console.error('[Upload] Error:', error);
89
+ res.status(500).json({ error: error.message });
90
+ }
91
+ });
92
+
93
+ // POST /api/v1/upload/multiple - Multiple files upload
94
+ router.post('/multiple', upload.array('files', 10), (req, res) => {
95
+ try {
96
+ if (!req.files || req.files.length === 0) {
97
+ return res.status(400).json({ error: 'No files uploaded' });
98
+ }
99
+
100
+ const files = req.files.map(f => ({
101
+ path: f.path,
102
+ name: f.filename,
103
+ originalName: f.originalname,
104
+ mimeType: f.mimetype,
105
+ size: f.size
106
+ }));
107
+
108
+ console.log(`[Upload] ${files.length} files saved`);
109
+
110
+ res.json({
111
+ success: true,
112
+ files
113
+ });
114
+ } catch (error) {
115
+ console.error('[Upload] Error:', error);
116
+ res.status(500).json({ error: error.message });
117
+ }
118
+ });
119
+
120
+ // Error handler for multer
121
+ router.use((error, req, res, next) => {
122
+ if (error instanceof multer.MulterError) {
123
+ if (error.code === 'LIMIT_FILE_SIZE') {
124
+ return res.status(400).json({ error: 'File too large (max 50MB)' });
125
+ }
126
+ return res.status(400).json({ error: error.message });
127
+ }
128
+ if (error) {
129
+ return res.status(400).json({ error: error.message });
130
+ }
131
+ next();
132
+ });
133
+
134
+ module.exports = router;