Completed intial SSH section with user/ssh backend
This commit is contained in:
58
src/backend/db/database.ts
Normal file
58
src/backend/db/database.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import userRoutes from './routes/users.js';
|
||||
import sshRoutes from './routes/ssh.js';
|
||||
import chalk from 'chalk';
|
||||
import cors from 'cors';
|
||||
|
||||
// Custom logger (adapted from starter.ts, with a database icon)
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5173',
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.use('/users', userRoutes);
|
||||
app.use('/ssh', sshRoutes);
|
||||
|
||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = 8081;
|
||||
app.listen(PORT);
|
||||
83
src/backend/db/db/index.ts
Normal file
83
src/backend/db/db/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import * as schema from './schema.js';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dbDir = path.resolve('./db/data');
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const sqlite = new Database('./db/data/db.sqlite');
|
||||
logger.success('Database connection established');
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
auth_method TEXT,
|
||||
key TEXT,
|
||||
save_auth_method INTEGER,
|
||||
is_pinned INTEGER,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
try {
|
||||
sqlite.prepare('SELECT is_admin FROM users LIMIT 1').get();
|
||||
} catch (e) {
|
||||
sqlite.exec('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;');
|
||||
}
|
||||
try {
|
||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (!row) {
|
||||
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
29
src/backend/db/db/schema.ts
Normal file
29
src/backend/db/db/schema.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(), // Unique user ID (nanoid)
|
||||
username: text('username').notNull(), // Username
|
||||
password_hash: text('password_hash').notNull(), // Hashed password
|
||||
is_admin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), // Admin flag
|
||||
});
|
||||
|
||||
export const sshData = sqliteTable('ssh_data', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name'),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'),
|
||||
ip: text('ip').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
username: text('username'),
|
||||
password: text('password'),
|
||||
authMethod: text('auth_method'),
|
||||
key: text('key', { length: 2048 }),
|
||||
saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }),
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
231
src/backend/db/routes/ssh.ts
Normal file
231
src/backend/db/routes/ssh.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import express from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import { sshData } from '../db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import chalk from 'chalk';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function isNonEmptyString(val: any): val is string {
|
||||
return typeof val === 'string' && val.trim().length > 0;
|
||||
}
|
||||
function isValidPort(val: any): val is number {
|
||||
return typeof val === 'number' && val > 0 && val < 65536;
|
||||
}
|
||||
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// JWT authentication middleware
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
logger.warn('Missing or invalid Authorization header');
|
||||
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
(req as any).userId = payload.userId;
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.warn('Invalid or expired token');
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
// Route: Create SSH data (requires JWT)
|
||||
// POST /ssh/host
|
||||
router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, saveAuthMethod, isPinned } = req.body;
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
|
||||
logger.warn('Invalid SSH data input');
|
||||
return res.status(400).json({ error: 'Invalid SSH data' });
|
||||
}
|
||||
|
||||
const sshDataObj: any = {
|
||||
userId: userId,
|
||||
name,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(',') : tags,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
authMethod,
|
||||
saveAuthMethod: saveAuthMethod ? 1 : 0,
|
||||
isPinned: isPinned ? 1 : 0,
|
||||
};
|
||||
|
||||
if (saveAuthMethod) {
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
} else if (authMethod === 'key') {
|
||||
sshDataObj.key = key;
|
||||
sshDataObj.password = null;
|
||||
}
|
||||
} else {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(sshData).values(sshDataObj);
|
||||
res.json({ message: 'SSH data created' });
|
||||
} catch (err) {
|
||||
logger.error('Failed to save SSH data', err);
|
||||
res.status(500).json({ error: 'Failed to save SSH data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Update SSH data (requires JWT)
|
||||
// PUT /ssh/host/:id
|
||||
router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, saveAuthMethod, isPinned } = req.body;
|
||||
const { id } = req.params;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) {
|
||||
logger.warn('Invalid SSH data input for update');
|
||||
return res.status(400).json({ error: 'Invalid SSH data' });
|
||||
}
|
||||
|
||||
const sshDataObj: any = {
|
||||
name,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(',') : tags,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
authMethod,
|
||||
saveAuthMethod: saveAuthMethod ? 1 : 0,
|
||||
isPinned: isPinned ? 1 : 0,
|
||||
};
|
||||
|
||||
if (saveAuthMethod) {
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
} else if (authMethod === 'key') {
|
||||
sshDataObj.key = key;
|
||||
sshDataObj.password = null;
|
||||
}
|
||||
} else {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.update(sshData)
|
||||
.set(sshDataObj)
|
||||
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
|
||||
res.json({ message: 'SSH data updated' });
|
||||
} catch (err) {
|
||||
logger.error('Failed to update SSH data', err);
|
||||
res.status(500).json({ error: 'Failed to update SSH data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get SSH data for the authenticated user (requires JWT)
|
||||
// GET /ssh/host
|
||||
router.get('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for SSH data fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
}
|
||||
try {
|
||||
const data = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH data', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get all unique folders for the authenticated user (requires JWT)
|
||||
// GET /ssh/folders
|
||||
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for SSH folder fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
}
|
||||
try {
|
||||
const data = await db
|
||||
.select({ folder: sshData.folder })
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
|
||||
const folderCounts: Record<string, number> = {};
|
||||
data.forEach(d => {
|
||||
if (d.folder && d.folder.trim() !== '') {
|
||||
folderCounts[d.folder] = (folderCounts[d.folder] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
|
||||
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH folders', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH folders' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Delete SSH host by id (requires JWT)
|
||||
// DELETE /ssh/host/:id
|
||||
router.delete('/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid userId or id for SSH host delete');
|
||||
return res.status(400).json({ error: 'Invalid userId or id' });
|
||||
}
|
||||
try {
|
||||
const result = await db.delete(sshData)
|
||||
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
|
||||
res.json({ message: 'SSH host deleted' });
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete SSH host', err);
|
||||
res.status(500).json({ error: 'Failed to delete SSH host' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
226
src/backend/db/routes/users.ts
Normal file
226
src/backend/db/routes/users.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import express from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import { users, settings } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import chalk from 'chalk';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { nanoid } from 'nanoid';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function isNonEmptyString(val: any): val is string {
|
||||
return typeof val === 'string' && val.trim().length > 0;
|
||||
}
|
||||
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// JWT authentication middleware
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
logger.warn('Missing or invalid Authorization header');
|
||||
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
(req as any).userId = payload.userId;
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.warn('Invalid or expired token');
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
// Route: Create user
|
||||
// POST /users/create
|
||||
router.post('/create', async (req, res) => {
|
||||
try {
|
||||
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (row && (row as any).value !== 'true') {
|
||||
return res.status(403).json({ error: 'Registration is currently disabled' });
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
const { username, password } = req.body;
|
||||
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
||||
logger.warn('Invalid user creation attempt');
|
||||
return res.status(400).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
if (existing && existing.length > 0) {
|
||||
logger.warn(`Attempt to create duplicate username: ${username}`);
|
||||
return res.status(409).json({ error: 'Username already exists' });
|
||||
}
|
||||
let isFirstUser = false;
|
||||
try {
|
||||
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||
isFirstUser = ((countResult as any)?.count || 0) === 0;
|
||||
} catch (e) {
|
||||
isFirstUser = true;
|
||||
}
|
||||
const saltRounds = parseInt(process.env.SALT || '10', 10);
|
||||
const password_hash = await bcrypt.hash(password, saltRounds);
|
||||
const id = nanoid();
|
||||
await db.insert(users).values({ id, username, password_hash, is_admin: isFirstUser });
|
||||
logger.success(`User created: ${username} (is_admin: ${isFirstUser})`);
|
||||
res.json({ message: 'User created', is_admin: isFirstUser });
|
||||
} catch (err) {
|
||||
logger.error('Failed to create user', err);
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get user JWT by username and password
|
||||
// POST /users/get
|
||||
router.post('/get', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
||||
logger.warn('Invalid get user attempt');
|
||||
return res.status(400).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
try {
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
if (!user || user.length === 0) {
|
||||
logger.warn(`User not found: ${username}`);
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
const userRecord = user[0];
|
||||
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
||||
if (!isMatch) {
|
||||
logger.warn(`Incorrect password for user: ${username}`);
|
||||
return res.status(401).json({ error: 'Incorrect password' });
|
||||
}
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { expiresIn: '50d' });
|
||||
logger.success(`User authenticated: ${username}`);
|
||||
res.json({ token });
|
||||
} catch (err) {
|
||||
logger.error('Failed to get user', err);
|
||||
res.status(500).json({ error: 'Failed to get user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get current user's username using JWT
|
||||
// GET /users/me
|
||||
router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId in JWT for /users/me');
|
||||
return res.status(401).json({ error: 'Invalid userId' });
|
||||
}
|
||||
try {
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
logger.warn(`User not found for /users/me: ${userId}`);
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
res.json({ username: user[0].username, is_admin: !!user[0].is_admin });
|
||||
} catch (err) {
|
||||
logger.error('Failed to get username', err);
|
||||
res.status(500).json({ error: 'Failed to get username' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Count users
|
||||
// GET /users/count
|
||||
router.get('/count', async (req, res) => {
|
||||
try {
|
||||
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||
const count = (countResult as any)?.count || 0;
|
||||
res.json({ count });
|
||||
} catch (err) {
|
||||
logger.error('Failed to count users', err);
|
||||
res.status(500).json({ error: 'Failed to count users' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: DB health check (actually queries DB)
|
||||
// GET /users/db-health
|
||||
router.get('/db-health', async (req, res) => {
|
||||
try {
|
||||
db.$client.prepare('SELECT 1').get();
|
||||
res.json({ status: 'ok' });
|
||||
} catch (err) {
|
||||
logger.error('DB health check failed', err);
|
||||
res.status(500).json({ error: 'Database not accessible' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get registration allowed status
|
||||
// GET /users/registration-allowed
|
||||
router.get('/registration-allowed', async (req, res) => {
|
||||
try {
|
||||
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
res.json({ allowed: row ? (row as any).value === 'true' : true });
|
||||
} catch (err) {
|
||||
logger.error('Failed to get registration allowed', err);
|
||||
res.status(500).json({ error: 'Failed to get registration allowed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Set registration allowed status (admin only)
|
||||
// PATCH /users/registration-allowed
|
||||
router.patch('/registration-allowed', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0 || !user[0].is_admin) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
const { allowed } = req.body;
|
||||
if (typeof allowed !== 'boolean') {
|
||||
return res.status(400).json({ error: 'Invalid value for allowed' });
|
||||
}
|
||||
db.$client.prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'").run(allowed ? 'true' : 'false');
|
||||
res.json({ allowed });
|
||||
} catch (err) {
|
||||
logger.error('Failed to set registration allowed', err);
|
||||
res.status(500).json({ error: 'Failed to set registration allowed' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,142 +0,0 @@
|
||||
const WebSocket = require('ws');
|
||||
const { Client } = require('ssh2');
|
||||
|
||||
const wss = new WebSocket.Server({ port: 8082 });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
let sshConn = null;
|
||||
let sshStream = null;
|
||||
|
||||
ws.on('close', () => {
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
ws.on('message', (msg) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(msg);
|
||||
} catch (e) {
|
||||
console.error('Invalid JSON received:', msg);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, data } = parsed;
|
||||
|
||||
switch (type) {
|
||||
case 'connectToHost':
|
||||
handleConnectToHost(data);
|
||||
break;
|
||||
|
||||
case 'resize':
|
||||
handleResize(data);
|
||||
break;
|
||||
|
||||
case 'disconnect':
|
||||
cleanupSSH();
|
||||
break;
|
||||
|
||||
case 'input':
|
||||
if (sshStream) sshStream.write(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown message type:', type);
|
||||
}
|
||||
});
|
||||
|
||||
function handleConnectToHost({ cols, rows, hostConfig }) {
|
||||
const { ip, port, username, password } = hostConfig;
|
||||
|
||||
sshConn = new Client();
|
||||
|
||||
sshConn.on('ready', () => {
|
||||
sshConn.shell({
|
||||
term: "xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
modes: {
|
||||
ECHO: 1,
|
||||
ECHOCTL: 0,
|
||||
ICANON: 1,
|
||||
TTY_OP_OSWRAP: 1
|
||||
}
|
||||
}, (err, stream) => {
|
||||
if (err) {
|
||||
console.error('Shell error:', err);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Shell error: ' + err.message }));
|
||||
return;
|
||||
}
|
||||
|
||||
sshStream = stream;
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
ws.send(JSON.stringify({ type: 'data', data: chunk.toString() }));
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
console.error('SSH stream error:', err.message);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'SSH stream error: ' + err.message }));
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: 'connected', message: 'SSH connected' }));
|
||||
// stream.write('\n'); // Force prompt to appear (removed to avoid double prompt)
|
||||
console.log('Sent connected message and newline to SSH stream');
|
||||
});
|
||||
});
|
||||
|
||||
sshConn.on('error', (err) => {
|
||||
console.error('SSH connection error:', err.message);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'SSH error: ' + err.message }));
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
sshConn.on('close', () => {
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
sshConn.connect({
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
keepaliveInterval: 5000,
|
||||
keepaliveCountMax: 10,
|
||||
readyTimeout: 10000,
|
||||
tcpKeepAlive: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleResize({ cols, rows }) {
|
||||
if (sshStream && sshStream.setWindow) {
|
||||
sshStream.setWindow(rows, cols, rows, cols);
|
||||
ws.send(JSON.stringify({ type: 'resized', cols, rows }));
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSSH() {
|
||||
if (sshStream) {
|
||||
try {
|
||||
sshStream.end();
|
||||
} catch (e) {
|
||||
console.error('Error closing stream:', e.message);
|
||||
}
|
||||
sshStream = null;
|
||||
}
|
||||
|
||||
if (sshConn) {
|
||||
try {
|
||||
sshConn.end();
|
||||
} catch (e) {
|
||||
console.error('Error closing connection:', e.message);
|
||||
}
|
||||
sshConn = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('WebSocket server running on ws://localhost:8082');
|
||||
200
src/backend/ssh/ssh.ts
Normal file
200
src/backend/ssh/ssh.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { WebSocketServer, WebSocket, type RawData } from 'ws';
|
||||
import { Client, type ClientChannel, type PseudoTtyOptions } from 'ssh2';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const wss = new WebSocketServer({ port: 8082 });
|
||||
|
||||
const sshIconSymbol = '🖥️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
|
||||
ws.on('close', () => {
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
ws.on('message', (msg: RawData) => {
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(msg.toString());
|
||||
} catch (e) {
|
||||
logger.error('Invalid JSON received: ' + msg.toString());
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, data } = parsed;
|
||||
|
||||
switch (type) {
|
||||
case 'connectToHost':
|
||||
handleConnectToHost(data);
|
||||
break;
|
||||
|
||||
case 'resize':
|
||||
handleResize(data);
|
||||
break;
|
||||
|
||||
case 'disconnect':
|
||||
cleanupSSH();
|
||||
break;
|
||||
|
||||
case 'input':
|
||||
if (sshStream) sshStream.write(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn('Unknown message type: ' + type);
|
||||
}
|
||||
});
|
||||
|
||||
function handleConnectToHost(data: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
hostConfig: {
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
authMethod?: string;
|
||||
};
|
||||
}) {
|
||||
const { cols, rows, hostConfig } = data;
|
||||
const { ip, port, username, password, key, authMethod } = hostConfig;
|
||||
|
||||
if (!username || typeof username !== 'string' || username.trim() === '') {
|
||||
logger.error('Invalid username provided');
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid username provided' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
|
||||
logger.error('Invalid IP provided');
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid IP provided' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!port || typeof port !== 'number' || port <= 0) {
|
||||
logger.error('Invalid port provided');
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid port provided' }));
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn = new Client();
|
||||
|
||||
sshConn.on('ready', () => {
|
||||
const pseudoTtyOpts: PseudoTtyOptions = {
|
||||
term: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
modes: {
|
||||
ECHO: 1,
|
||||
ECHOCTL: 0,
|
||||
ICANON: 1,
|
||||
}
|
||||
};
|
||||
|
||||
sshConn!.shell(pseudoTtyOpts, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('Shell error: ' + err.message);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Shell error: ' + err.message }));
|
||||
return;
|
||||
}
|
||||
|
||||
sshStream = stream;
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
ws.send(JSON.stringify({ type: 'data', data: chunk.toString() }));
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
logger.error('SSH stream error: ' + err.message);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'SSH stream error: ' + err.message }));
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: 'connected', message: 'SSH connected' }));
|
||||
});
|
||||
});
|
||||
|
||||
sshConn.on('error', (err: Error) => {
|
||||
logger.error('SSH connection error: ' + err.message);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'SSH error: ' + err.message }));
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
sshConn.on('close', () => {
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
const connectConfig: any = {
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
keepaliveInterval: 5000,
|
||||
keepaliveCountMax: 10,
|
||||
readyTimeout: 10000,
|
||||
};
|
||||
if (authMethod === 'key' && key) {
|
||||
connectConfig.privateKey = key;
|
||||
} else {
|
||||
connectConfig.password = password;
|
||||
}
|
||||
sshConn.connect(connectConfig);
|
||||
}
|
||||
|
||||
function handleResize(data: { cols: number; rows: number }) {
|
||||
if (sshStream && sshStream.setWindow) {
|
||||
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
|
||||
ws.send(JSON.stringify({ type: 'resized', cols: data.cols, rows: data.rows }));
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSSH() {
|
||||
if (sshStream) {
|
||||
try {
|
||||
sshStream.end();
|
||||
} catch (e: any) {
|
||||
logger.error('Error closing stream: ' + e.message);
|
||||
}
|
||||
sshStream = null;
|
||||
}
|
||||
|
||||
if (sshConn) {
|
||||
try {
|
||||
sshConn.end();
|
||||
} catch (e: any) {
|
||||
logger.error('Error closing connection: ' + e.message);
|
||||
}
|
||||
sshConn = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
53
src/backend/starter.ts
Normal file
53
src/backend/starter.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// npx tsc -p tsconfig.node.json
|
||||
// node ./dist/backend/starter.js
|
||||
|
||||
import './db/database.js'
|
||||
import './ssh/ssh.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const fixedIconSymbol = '🚀';
|
||||
|
||||
const getTimeStamp = (): string => {
|
||||
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
};
|
||||
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${fixedIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
logger.info("Starting all backend servers...");
|
||||
|
||||
logger.success("All servers started successfully");
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info("Shutting down servers...");
|
||||
process.exit(0);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to start servers:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user