@mmmbuto/nexuscrew 0.1.0-beta.1 → 0.2.1
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/README.md +67 -120
- package/bin/nexuscrew.js +468 -264
- package/frontend/dist/apple-touch-icon.png +0 -0
- package/frontend/dist/assets/{index-C7bAndew.js → index-BG1N7bSL.js} +1710 -1702
- package/frontend/dist/assets/index-CiAtinNP.css +1 -0
- package/frontend/dist/favicon.svg +20 -0
- package/frontend/dist/icon-192.png +0 -0
- package/frontend/dist/icon-512.png +0 -0
- package/frontend/dist/index.html +11 -3
- package/frontend/dist/site.webmanifest +29 -0
- package/lib/config/hosts.js +67 -0
- package/lib/config/manager.js +379 -0
- package/lib/config/models.js +408 -0
- package/lib/server/db/adapter.js +274 -0
- package/lib/server/db/drivers/sql-js.js +75 -0
- package/lib/server/db/migrate.js +174 -0
- package/lib/server/db/migrations/001_base_schema.sql +70 -0
- package/lib/server/middleware/auth.js +134 -0
- package/lib/server/middleware/rate-limit.js +63 -0
- package/lib/server/models/User.js +128 -0
- package/lib/server/routes/auth.js +168 -0
- package/lib/server/routes/hosts.js +11 -20
- package/lib/server/routes/keys.js +28 -0
- package/lib/server/routes/models.js +59 -4
- package/lib/server/routes/runtimes.js +34 -0
- package/lib/server/routes/send.js +76 -12
- package/lib/server/routes/sessions.js +39 -10
- package/lib/server/routes/speech.js +46 -0
- package/lib/server/routes/status.js +8 -6
- package/lib/server/routes/upload.js +135 -0
- package/lib/server/routes/wake-lock.js +95 -0
- package/lib/server/routes/workspaces.js +101 -0
- package/lib/server/server.js +66 -33
- package/lib/server/services/attachment-manager.js +57 -0
- package/lib/server/services/context-bridge.js +425 -0
- package/lib/server/services/runtime-manager.js +462 -0
- package/lib/server/services/speech-manager.js +76 -0
- package/lib/server/services/summary-generator.js +309 -0
- package/lib/server/services/workspace-manager.js +79 -0
- package/lib/services/engine-discovery.js +198 -13
- package/lib/services/log-watcher.js +40 -22
- package/lib/services/remote-pane-watcher.js +155 -0
- package/lib/services/session-store.js +60 -64
- package/lib/services/tmux-manager.js +127 -116
- package/lib/setup/postinstall.js +38 -0
- package/lib/utils/paths.js +124 -0
- package/lib/utils/termux.js +182 -0
- package/package.json +8 -4
- package/frontend/dist/assets/index-OENqI1_9.css +0 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const initSqlJs = require('sql.js');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
class SqlJsDriver {
|
|
5
|
+
constructor(dbPath) {
|
|
6
|
+
this.dbPath = dbPath;
|
|
7
|
+
this.db = null;
|
|
8
|
+
this.saveInterval = null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async init() {
|
|
12
|
+
const SQL = await initSqlJs();
|
|
13
|
+
|
|
14
|
+
if (fs.existsSync(this.dbPath)) {
|
|
15
|
+
const buffer = fs.readFileSync(this.dbPath);
|
|
16
|
+
this.db = new SQL.Database(buffer);
|
|
17
|
+
} else {
|
|
18
|
+
this.db = new SQL.Database();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Auto-save every 5 seconds
|
|
22
|
+
this.saveInterval = setInterval(() => this.save(), 5000);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
save() {
|
|
26
|
+
if (!this.db) return;
|
|
27
|
+
const data = this.db.export();
|
|
28
|
+
const buffer = Buffer.from(data);
|
|
29
|
+
fs.writeFileSync(this.dbPath, buffer);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
exec(sql) {
|
|
33
|
+
return this.db.run(sql);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
prepare(sql) {
|
|
37
|
+
const stmt = this.db.prepare(sql);
|
|
38
|
+
return {
|
|
39
|
+
run: (...params) => {
|
|
40
|
+
stmt.bind(params);
|
|
41
|
+
stmt.step();
|
|
42
|
+
stmt.reset();
|
|
43
|
+
this.save();
|
|
44
|
+
},
|
|
45
|
+
get: (...params) => {
|
|
46
|
+
stmt.bind(params);
|
|
47
|
+
const result = stmt.step() ? stmt.getAsObject() : null;
|
|
48
|
+
stmt.reset();
|
|
49
|
+
return result;
|
|
50
|
+
},
|
|
51
|
+
all: (...params) => {
|
|
52
|
+
stmt.bind(params);
|
|
53
|
+
const results = [];
|
|
54
|
+
while (stmt.step()) {
|
|
55
|
+
results.push(stmt.getAsObject());
|
|
56
|
+
}
|
|
57
|
+
stmt.reset();
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
close() {
|
|
64
|
+
if (this.db) {
|
|
65
|
+
this.save();
|
|
66
|
+
this.db.close();
|
|
67
|
+
}
|
|
68
|
+
if (this.saveInterval) {
|
|
69
|
+
clearInterval(this.saveInterval);
|
|
70
|
+
this.saveInterval = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = SqlJsDriver;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { initDb, getDb, saveDb } = require('./adapter');
|
|
4
|
+
|
|
5
|
+
const MIGRATIONS_DIR = path.join(__dirname, 'migrations');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Get list of migration files in order
|
|
9
|
+
*/
|
|
10
|
+
function getMigrationFiles() {
|
|
11
|
+
const files = fs.readdirSync(MIGRATIONS_DIR)
|
|
12
|
+
.filter(f => f.endsWith('.sql'))
|
|
13
|
+
.sort(); // Alphabetical order ensures numeric order (001_, 002_, etc.)
|
|
14
|
+
return files;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalize and split SQL into executable statements.
|
|
19
|
+
* Keeps CREATE/INSERT blocks while stripping comment-only lines.
|
|
20
|
+
*/
|
|
21
|
+
function parseSqlStatements(sql) {
|
|
22
|
+
return sql
|
|
23
|
+
.replace(/\r\n/g, '\n')
|
|
24
|
+
.split(';')
|
|
25
|
+
.map(stmt => stmt.trim())
|
|
26
|
+
.map(stmt => {
|
|
27
|
+
const lines = stmt
|
|
28
|
+
.split('\n')
|
|
29
|
+
.map(line => line.trim())
|
|
30
|
+
.filter(line => line.length > 0 && !line.startsWith('--'));
|
|
31
|
+
return lines.join('\n').trim();
|
|
32
|
+
})
|
|
33
|
+
.filter(stmt => stmt.length > 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get applied migrations from DB
|
|
38
|
+
*/
|
|
39
|
+
function getAppliedMigrations(db) {
|
|
40
|
+
try {
|
|
41
|
+
// Ensure migrations table exists
|
|
42
|
+
db.exec(`
|
|
43
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
name TEXT UNIQUE NOT NULL,
|
|
46
|
+
applied_at INTEGER NOT NULL
|
|
47
|
+
)
|
|
48
|
+
`);
|
|
49
|
+
|
|
50
|
+
const stmt = db.prepare('SELECT name FROM _migrations ORDER BY id');
|
|
51
|
+
return stmt.all().map(row => row.name);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error('[Migration] Error getting applied migrations:', error.message);
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Mark migration as applied
|
|
60
|
+
*/
|
|
61
|
+
function markMigrationApplied(db, name) {
|
|
62
|
+
const stmt = db.prepare('INSERT INTO _migrations (name, applied_at) VALUES (?, ?)');
|
|
63
|
+
stmt.run(name, Date.now());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run a single migration file
|
|
68
|
+
*/
|
|
69
|
+
function runMigrationFile(db, filename) {
|
|
70
|
+
const filePath = path.join(MIGRATIONS_DIR, filename);
|
|
71
|
+
const migrationSql = fs.readFileSync(filePath, 'utf8');
|
|
72
|
+
const statements = parseSqlStatements(migrationSql);
|
|
73
|
+
|
|
74
|
+
console.log(`[Migration] Running ${filename} (${statements.length} statements)`);
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < statements.length; i++) {
|
|
77
|
+
const stmt = statements[i];
|
|
78
|
+
|
|
79
|
+
if (/^SELECT/i.test(stmt)) {
|
|
80
|
+
// Skip verification SELECT statements
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
db.exec(stmt);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
// Handle "duplicate column" errors gracefully
|
|
88
|
+
if (error.message.includes('duplicate column')) {
|
|
89
|
+
console.log(`[Migration] Column already exists (skipping): ${error.message}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
console.error(`[Migration] Failed at statement ${i + 1}: ${error.message}`);
|
|
93
|
+
console.error('Statement preview:', stmt.substring(0, 200));
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
markMigrationApplied(db, filename);
|
|
99
|
+
console.log(`[Migration] ✅ ${filename} applied`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run database migrations.
|
|
104
|
+
* @param {Object} options
|
|
105
|
+
* @param {boolean} options.skipInit - skip initDb (adapter already initialized)
|
|
106
|
+
*/
|
|
107
|
+
async function runMigrations(options = {}) {
|
|
108
|
+
const { skipInit = false } = options;
|
|
109
|
+
|
|
110
|
+
console.log('[Migration] Starting database migration...');
|
|
111
|
+
|
|
112
|
+
if (!skipInit && !getDb()) {
|
|
113
|
+
await initDb({ skipMigrationCheck: true });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const db = getDb();
|
|
117
|
+
|
|
118
|
+
if (!db) {
|
|
119
|
+
throw new Error('[Migration] Database instance not initialized');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const migrationFiles = getMigrationFiles();
|
|
123
|
+
const appliedMigrations = getAppliedMigrations(db);
|
|
124
|
+
|
|
125
|
+
console.log(`[Migration] Found ${migrationFiles.length} migration files`);
|
|
126
|
+
console.log(`[Migration] Already applied: ${appliedMigrations.length}`);
|
|
127
|
+
|
|
128
|
+
const pendingMigrations = migrationFiles.filter(f => !appliedMigrations.includes(f));
|
|
129
|
+
|
|
130
|
+
if (pendingMigrations.length === 0) {
|
|
131
|
+
console.log('[Migration] No pending migrations');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log(`[Migration] Pending: ${pendingMigrations.join(', ')}`);
|
|
136
|
+
|
|
137
|
+
for (const file of pendingMigrations) {
|
|
138
|
+
runMigrationFile(db, file);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
saveDb();
|
|
142
|
+
|
|
143
|
+
console.log('[Migration] All migrations completed');
|
|
144
|
+
|
|
145
|
+
// Verification counts
|
|
146
|
+
try {
|
|
147
|
+
const verifyStmt = db.prepare(`
|
|
148
|
+
SELECT 'conversations' as table_name, COUNT(*) as count FROM conversations
|
|
149
|
+
UNION ALL
|
|
150
|
+
SELECT 'messages' as table_name, COUNT(*) as count FROM messages
|
|
151
|
+
`);
|
|
152
|
+
|
|
153
|
+
const counts = verifyStmt.all();
|
|
154
|
+
console.log('[Migration] Verification:');
|
|
155
|
+
counts.forEach(row => console.log(` ${row.table_name}: ${row.count} rows`));
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error('[Migration] Verification failed:', error.message);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Run if called directly
|
|
162
|
+
if (require.main === module) {
|
|
163
|
+
runMigrations()
|
|
164
|
+
.then(() => {
|
|
165
|
+
console.log('[Migration] Complete');
|
|
166
|
+
process.exit(0);
|
|
167
|
+
})
|
|
168
|
+
.catch(error => {
|
|
169
|
+
console.error('[Migration] Failed:', error);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { runMigrations };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- NexusCrew Base Schema Migration
|
|
2
|
+
-- This migration creates the initial database schema for NexusCrew
|
|
3
|
+
|
|
4
|
+
-- Conversations table (NexusCrew + auth schema)
|
|
5
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
title TEXT DEFAULT 'New Chat',
|
|
8
|
+
engine TEXT NOT NULL DEFAULT 'claude',
|
|
9
|
+
model TEXT,
|
|
10
|
+
tmux_window TEXT,
|
|
11
|
+
log_path TEXT,
|
|
12
|
+
workspace TEXT DEFAULT '',
|
|
13
|
+
host TEXT DEFAULT 'local',
|
|
14
|
+
status TEXT DEFAULT 'active',
|
|
15
|
+
pinned INTEGER DEFAULT 0,
|
|
16
|
+
metadata TEXT,
|
|
17
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
18
|
+
updated_at INTEGER DEFAULT (unixepoch())
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
-- Messages table
|
|
22
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
conversation_id TEXT NOT NULL,
|
|
25
|
+
role TEXT NOT NULL,
|
|
26
|
+
content TEXT DEFAULT '',
|
|
27
|
+
engine TEXT,
|
|
28
|
+
model TEXT,
|
|
29
|
+
tokens_in INTEGER DEFAULT 0,
|
|
30
|
+
tokens_out INTEGER DEFAULT 0,
|
|
31
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
32
|
+
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
-- Users table (auth)
|
|
36
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
username TEXT UNIQUE NOT NULL,
|
|
39
|
+
password_hash TEXT NOT NULL,
|
|
40
|
+
role TEXT NOT NULL DEFAULT 'user',
|
|
41
|
+
is_locked INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
last_failed_attempt INTEGER,
|
|
44
|
+
locked_until INTEGER,
|
|
45
|
+
created_at INTEGER NOT NULL,
|
|
46
|
+
last_login INTEGER
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
-- API Keys table
|
|
50
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
51
|
+
provider TEXT PRIMARY KEY,
|
|
52
|
+
api_key TEXT NOT NULL,
|
|
53
|
+
created_at INTEGER NOT NULL,
|
|
54
|
+
updated_at INTEGER NOT NULL
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
-- Session summaries table
|
|
58
|
+
CREATE TABLE IF NOT EXISTS session_summaries (
|
|
59
|
+
conversation_id TEXT PRIMARY KEY,
|
|
60
|
+
summary_short TEXT,
|
|
61
|
+
key_decisions TEXT,
|
|
62
|
+
files_modified TEXT,
|
|
63
|
+
updated_at INTEGER,
|
|
64
|
+
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
-- Indexes
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at);
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_conversations_status ON conversations(status, updated_at);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const jwt = require('jsonwebtoken');
|
|
3
|
+
const User = require('../models/User');
|
|
4
|
+
const { getConfig } = require('../../config/manager');
|
|
5
|
+
|
|
6
|
+
let warnedEphemeralSecret = false;
|
|
7
|
+
|
|
8
|
+
function getJwtSecret() {
|
|
9
|
+
if (process.env.JWT_SECRET) {
|
|
10
|
+
return process.env.JWT_SECRET;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const config = getConfig();
|
|
14
|
+
if (config.auth?.jwt_secret) {
|
|
15
|
+
return config.auth.jwt_secret;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!global.__NEXUSCREW_EPHEMERAL_JWT_SECRET__) {
|
|
19
|
+
global.__NEXUSCREW_EPHEMERAL_JWT_SECRET__ = crypto.randomBytes(48).toString('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!warnedEphemeralSecret) {
|
|
23
|
+
warnedEphemeralSecret = true;
|
|
24
|
+
console.warn('[Auth] JWT secret missing from env and config; using ephemeral in-memory secret for this process');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return global.__NEXUSCREW_EPHEMERAL_JWT_SECRET__;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get config user (admin from init) by id
|
|
32
|
+
*/
|
|
33
|
+
function findConfigUserById(id) {
|
|
34
|
+
if (id !== 'config-admin') return null;
|
|
35
|
+
|
|
36
|
+
const config = getConfig();
|
|
37
|
+
if (config.auth && config.auth.user) {
|
|
38
|
+
return {
|
|
39
|
+
id: 'config-admin',
|
|
40
|
+
username: config.auth.user,
|
|
41
|
+
role: 'admin',
|
|
42
|
+
is_locked: false,
|
|
43
|
+
created_at: Date.now()
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
|
50
|
+
|
|
51
|
+
function generateToken(user) {
|
|
52
|
+
return jwt.sign(
|
|
53
|
+
{
|
|
54
|
+
id: user.id,
|
|
55
|
+
username: user.username,
|
|
56
|
+
role: user.role
|
|
57
|
+
},
|
|
58
|
+
getJwtSecret(),
|
|
59
|
+
{ expiresIn: JWT_EXPIRES_IN }
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function verifyToken(token) {
|
|
64
|
+
try {
|
|
65
|
+
return jwt.verify(token, getJwtSecret());
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function authMiddleware(req, res, next) {
|
|
72
|
+
// BYPASS for localhost
|
|
73
|
+
const clientIp = req.ip || req.connection.remoteAddress || req.socket.remoteAddress;
|
|
74
|
+
if (clientIp === '127.0.0.1' || clientIp === '::1' || clientIp === '::ffff:127.0.0.1') {
|
|
75
|
+
req.user = { id: 'localhost', username: 'local', role: 'admin' };
|
|
76
|
+
return next();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const authHeader = req.headers.authorization;
|
|
80
|
+
|
|
81
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
82
|
+
return res.status(401).json({ error: 'No token provided' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const token = authHeader.substring(7);
|
|
86
|
+
const decoded = verifyToken(token);
|
|
87
|
+
|
|
88
|
+
if (!decoded) {
|
|
89
|
+
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Verify user still exists - check config user first, then database
|
|
93
|
+
let user = findConfigUserById(decoded.id);
|
|
94
|
+
let isConfigUser = !!user;
|
|
95
|
+
|
|
96
|
+
if (!user) {
|
|
97
|
+
user = User.findById(decoded.id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!user) {
|
|
101
|
+
return res.status(401).json({ error: 'User not found' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check if account is locked (only for DB users)
|
|
105
|
+
if (!isConfigUser && User.isAccountLocked(user)) {
|
|
106
|
+
return res.status(403).json({
|
|
107
|
+
error: 'Account locked',
|
|
108
|
+
locked_until: user.locked_until
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
req.user = {
|
|
113
|
+
id: user.id,
|
|
114
|
+
username: user.username,
|
|
115
|
+
role: user.role
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
next();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function adminOnly(req, res, next) {
|
|
122
|
+
if (req.user.role !== 'admin') {
|
|
123
|
+
return res.status(403).json({ error: 'Admin access required' });
|
|
124
|
+
}
|
|
125
|
+
next();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = {
|
|
129
|
+
generateToken,
|
|
130
|
+
verifyToken,
|
|
131
|
+
authMiddleware,
|
|
132
|
+
adminOnly,
|
|
133
|
+
getJwtSecret
|
|
134
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiting Middleware for Chat Endpoints
|
|
3
|
+
*
|
|
4
|
+
* Protects AI chat endpoints from abuse by limiting requests per user.
|
|
5
|
+
* Uses express-rate-limit with user-based keying via JWT.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const rateLimit = require('express-rate-limit');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Chat endpoints rate limiter
|
|
12
|
+
* - 10 requests per minute per user
|
|
13
|
+
* - Applies to: /api/v1/chat, /api/v1/codex, /api/v1/gemini, /api/v1/qwen
|
|
14
|
+
*/
|
|
15
|
+
const chatRateLimiter = rateLimit({
|
|
16
|
+
windowMs: 60 * 1000, // 1 minute window
|
|
17
|
+
max: 10, // 10 requests per window
|
|
18
|
+
standardHeaders: true, // Return rate limit info in headers
|
|
19
|
+
legacyHeaders: false,
|
|
20
|
+
|
|
21
|
+
// Key by user ID from JWT (set by authMiddleware)
|
|
22
|
+
keyGenerator: (req) => {
|
|
23
|
+
return req.user?.id || req.ip;
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
// Custom error response
|
|
27
|
+
handler: (req, res) => {
|
|
28
|
+
res.status(429).json({
|
|
29
|
+
error: 'Too many requests',
|
|
30
|
+
message: 'Rate limit exceeded. Please wait before sending more messages.',
|
|
31
|
+
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000)
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// Skip rate limiting for interrupt endpoints
|
|
36
|
+
skip: (req) => {
|
|
37
|
+
return req.path.endsWith('/interrupt');
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* General API rate limiter (for non-chat endpoints)
|
|
43
|
+
* - 60 requests per minute per IP
|
|
44
|
+
*/
|
|
45
|
+
const apiRateLimiter = rateLimit({
|
|
46
|
+
windowMs: 60 * 1000,
|
|
47
|
+
max: 60,
|
|
48
|
+
standardHeaders: true,
|
|
49
|
+
legacyHeaders: false,
|
|
50
|
+
keyGenerator: (req) => req.user?.id || req.ip,
|
|
51
|
+
handler: (req, res) => {
|
|
52
|
+
res.status(429).json({
|
|
53
|
+
error: 'Too many requests',
|
|
54
|
+
message: 'API rate limit exceeded.',
|
|
55
|
+
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000)
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
chatRateLimiter,
|
|
62
|
+
apiRateLimiter
|
|
63
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const { prepare, saveDb } = require('../db/adapter');
|
|
2
|
+
const bcrypt = require('bcryptjs');
|
|
3
|
+
const { v4: uuidv4 } = require('uuid');
|
|
4
|
+
|
|
5
|
+
class User {
|
|
6
|
+
static create(username, password, role = 'user') {
|
|
7
|
+
const id = uuidv4();
|
|
8
|
+
const passwordHash = bcrypt.hashSync(password, 10);
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
|
|
11
|
+
const stmt = prepare(`
|
|
12
|
+
INSERT INTO users (id, username, password_hash, role, created_at)
|
|
13
|
+
VALUES (?, ?, ?, ?, ?)
|
|
14
|
+
`);
|
|
15
|
+
|
|
16
|
+
stmt.run(id, username, passwordHash, role, now);
|
|
17
|
+
saveDb();
|
|
18
|
+
|
|
19
|
+
return { id, username, role, created_at: now };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static findByUsername(username) {
|
|
23
|
+
const stmt = prepare('SELECT * FROM users WHERE username = ?');
|
|
24
|
+
return stmt.get(username);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static findById(id) {
|
|
28
|
+
const stmt = prepare('SELECT * FROM users WHERE id = ?');
|
|
29
|
+
return stmt.get(id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static count() {
|
|
33
|
+
const stmt = prepare('SELECT COUNT(*) as count FROM users');
|
|
34
|
+
const result = stmt.get();
|
|
35
|
+
return result ? result.count : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static verifyPassword(user, password) {
|
|
39
|
+
return bcrypt.compareSync(password, user.password_hash);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static updateLastLogin(userId) {
|
|
43
|
+
const stmt = prepare('UPDATE users SET last_login = ? WHERE id = ?');
|
|
44
|
+
stmt.run(Date.now(), userId);
|
|
45
|
+
saveDb();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static incrementFailedAttempts(userId) {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const stmt = prepare(`
|
|
51
|
+
UPDATE users
|
|
52
|
+
SET failed_attempts = failed_attempts + 1,
|
|
53
|
+
last_failed_attempt = ?
|
|
54
|
+
WHERE id = ?
|
|
55
|
+
`);
|
|
56
|
+
stmt.run(now, userId);
|
|
57
|
+
saveDb();
|
|
58
|
+
|
|
59
|
+
// Lock account after 5 failed attempts for 15 minutes
|
|
60
|
+
const user = this.findById(userId);
|
|
61
|
+
if (user.failed_attempts >= 5) {
|
|
62
|
+
const lockUntil = now + (15 * 60 * 1000); // 15 minutes
|
|
63
|
+
const lockStmt = prepare(`
|
|
64
|
+
UPDATE users
|
|
65
|
+
SET is_locked = 1, locked_until = ?
|
|
66
|
+
WHERE id = ?
|
|
67
|
+
`);
|
|
68
|
+
lockStmt.run(lockUntil, userId);
|
|
69
|
+
saveDb();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static resetFailedAttempts(userId) {
|
|
74
|
+
const stmt = prepare(`
|
|
75
|
+
UPDATE users
|
|
76
|
+
SET failed_attempts = 0,
|
|
77
|
+
is_locked = 0,
|
|
78
|
+
locked_until = NULL
|
|
79
|
+
WHERE id = ?
|
|
80
|
+
`);
|
|
81
|
+
stmt.run(userId);
|
|
82
|
+
saveDb();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static isAccountLocked(user) {
|
|
86
|
+
if (!user.is_locked) return false;
|
|
87
|
+
if (!user.locked_until) return true;
|
|
88
|
+
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
if (now < user.locked_until) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Unlock account if lock period expired
|
|
95
|
+
this.resetFailedAttempts(user.id);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static logLoginAttempt(ipAddress, username, success) {
|
|
100
|
+
const stmt = prepare(`
|
|
101
|
+
INSERT INTO login_attempts (ip_address, username, success, timestamp)
|
|
102
|
+
VALUES (?, ?, ?, ?)
|
|
103
|
+
`);
|
|
104
|
+
stmt.run(ipAddress, username, success ? 1 : 0, Date.now());
|
|
105
|
+
saveDb();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static getRecentLoginAttempts(ipAddress, windowMs = 15 * 60 * 1000) {
|
|
109
|
+
const since = Date.now() - windowMs;
|
|
110
|
+
const stmt = prepare(`
|
|
111
|
+
SELECT COUNT(*) as count
|
|
112
|
+
FROM login_attempts
|
|
113
|
+
WHERE ip_address = ? AND timestamp > ?
|
|
114
|
+
`);
|
|
115
|
+
const result = stmt.get(ipAddress, since);
|
|
116
|
+
return result ? result.count : 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
static cleanupOldLoginAttempts(daysToKeep = 7) {
|
|
120
|
+
const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
|
|
121
|
+
const stmt = prepare('DELETE FROM login_attempts WHERE timestamp < ?');
|
|
122
|
+
stmt.run(cutoff);
|
|
123
|
+
saveDb();
|
|
124
|
+
return 0; // sql.js doesn't return changes count easily
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = User;
|