Add frontend service with auth, MFA, and content management
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Node.js/Express backend with TypeScript - SQLite database for users, sessions, and content metadata - Authentication with TOTP and WebAuthn MFA support - Admin user auto-created on first startup - User content gallery with view/delete functionality - RunPod API proxy (keeps API keys server-side) - Docker setup with CI/CD for Gitea registry 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
90
frontend/src/config.ts
Normal file
90
frontend/src/config.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalEnv(name: string, defaultValue: string): string {
|
||||
return process.env[name] || defaultValue;
|
||||
}
|
||||
|
||||
function optionalEnvInt(name: string, defaultValue: number): number {
|
||||
const value = process.env[name];
|
||||
if (!value) return defaultValue;
|
||||
const parsed = parseInt(value, 10);
|
||||
if (isNaN(parsed)) return defaultValue;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionalEnvBool(name: string, defaultValue: boolean): boolean {
|
||||
const value = process.env[name];
|
||||
if (!value) return defaultValue;
|
||||
return value.toLowerCase() === 'true' || value === '1';
|
||||
}
|
||||
|
||||
const dataDir = optionalEnv('DATA_DIR', './data');
|
||||
|
||||
// Ensure data directories exist
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
const contentDir = join(dataDir, 'content');
|
||||
if (!existsSync(contentDir)) {
|
||||
mkdirSync(contentDir, { recursive: true });
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Server
|
||||
nodeEnv: optionalEnv('NODE_ENV', 'development'),
|
||||
port: optionalEnvInt('PORT', 3000),
|
||||
isProduction: optionalEnv('NODE_ENV', 'development') === 'production',
|
||||
|
||||
// Paths
|
||||
dataDir,
|
||||
contentDir,
|
||||
dbPath: join(dataDir, 'app.db'),
|
||||
|
||||
// Session
|
||||
sessionSecret: requireEnv('SESSION_SECRET'),
|
||||
sessionMaxAge: optionalEnvInt('SESSION_MAX_AGE_HOURS', 24) * 60 * 60 * 1000,
|
||||
|
||||
// Initial Admin
|
||||
adminUsername: optionalEnv('ADMIN_USERNAME', 'admin'),
|
||||
adminPassword: optionalEnv('ADMIN_PASSWORD', ''),
|
||||
adminEmail: optionalEnv('ADMIN_EMAIL', ''),
|
||||
|
||||
// RunPod
|
||||
runpod: {
|
||||
apiKey: requireEnv('RUNPOD_API_KEY'),
|
||||
endpointId: requireEnv('RUNPOD_ENDPOINT_ID'),
|
||||
baseUrl: 'https://api.runpod.ai/v2',
|
||||
pollIntervalMs: optionalEnvInt('RUNPOD_POLL_INTERVAL_MS', 5000),
|
||||
maxTimeoutMs: optionalEnvInt('RUNPOD_MAX_TIMEOUT_MS', 600000),
|
||||
},
|
||||
|
||||
// WebAuthn
|
||||
webauthn: {
|
||||
rpId: optionalEnv('WEBAUTHN_RP_ID', 'localhost'),
|
||||
rpName: optionalEnv('WEBAUTHN_RP_NAME', 'ComfyUI Video Generator'),
|
||||
origin: optionalEnv('WEBAUTHN_ORIGIN', 'http://localhost:3000'),
|
||||
},
|
||||
|
||||
// Security
|
||||
encryptionKey: requireEnv('ENCRYPTION_KEY'),
|
||||
trustProxy: optionalEnvBool('TRUST_PROXY', true),
|
||||
|
||||
// Rate Limiting
|
||||
rateLimit: {
|
||||
windowMs: optionalEnvInt('RATE_LIMIT_WINDOW_MS', 60000),
|
||||
maxRequests: optionalEnvInt('RATE_LIMIT_MAX_REQUESTS', 100),
|
||||
},
|
||||
loginRateLimit: {
|
||||
windowMs: optionalEnvInt('LOGIN_RATE_LIMIT_WINDOW_MS', 900000),
|
||||
maxRequests: optionalEnvInt('LOGIN_RATE_LIMIT_MAX', 5),
|
||||
},
|
||||
};
|
||||
135
frontend/src/db/index.ts
Normal file
135
frontend/src/db/index.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized. Call initDatabase() first.');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function initDatabase(): Database.Database {
|
||||
if (db) {
|
||||
return db;
|
||||
}
|
||||
|
||||
db = new Database(config.dbPath);
|
||||
|
||||
// Enable foreign keys and WAL mode for better performance
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Run migrations
|
||||
runMigrations(db);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function runMigrations(database: Database.Database): void {
|
||||
// Ensure migrations table exists
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Get applied migrations
|
||||
const appliedMigrations = new Set(
|
||||
database.prepare('SELECT version FROM schema_migrations').all()
|
||||
.map((row: any) => row.version)
|
||||
);
|
||||
|
||||
// Migration files in order
|
||||
const migrations = [
|
||||
{ version: 1, file: '001_initial.sql' },
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (!appliedMigrations.has(migration.version)) {
|
||||
console.log(`Applying migration ${migration.version}: ${migration.file}`);
|
||||
|
||||
const sql = readFileSync(
|
||||
join(__dirname, 'migrations', migration.file),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
database.exec(sql);
|
||||
|
||||
database.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(migration.version);
|
||||
|
||||
console.log(`Migration ${migration.version} applied successfully`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Type definitions for database rows
|
||||
export interface UserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
password_hash: string;
|
||||
is_admin: number;
|
||||
is_active: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_login_at: string | null;
|
||||
}
|
||||
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
user_id: number;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
mfa_verified: number;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MfaCredentialRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
type: 'totp' | 'webauthn';
|
||||
name: string | null;
|
||||
totp_secret: string | null;
|
||||
credential_id: string | null;
|
||||
public_key: string | null;
|
||||
counter: number;
|
||||
transports: string | null;
|
||||
is_active: number;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export interface GeneratedContentRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
filename: string;
|
||||
original_filename: string | null;
|
||||
prompt: string | null;
|
||||
negative_prompt: string | null;
|
||||
resolution: number | null;
|
||||
steps: number | null;
|
||||
split_step: number | null;
|
||||
runpod_job_id: string | null;
|
||||
file_size: number | null;
|
||||
duration_seconds: number | null;
|
||||
mime_type: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
76
frontend/src/db/migrations/001_initial.sql
Normal file
76
frontend/src/db/migrations/001_initial.sql
Normal file
@@ -0,0 +1,76 @@
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
last_login_at TEXT
|
||||
);
|
||||
|
||||
-- Sessions table
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
mfa_verified INTEGER DEFAULT 0,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- MFA credentials table (supports both TOTP and WebAuthn)
|
||||
CREATE TABLE IF NOT EXISTS mfa_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('totp', 'webauthn')),
|
||||
name TEXT,
|
||||
totp_secret TEXT,
|
||||
credential_id TEXT,
|
||||
public_key TEXT,
|
||||
counter INTEGER DEFAULT 0,
|
||||
transports TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Generated content metadata
|
||||
CREATE TABLE IF NOT EXISTS generated_content (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
original_filename TEXT,
|
||||
prompt TEXT,
|
||||
negative_prompt TEXT,
|
||||
resolution INTEGER,
|
||||
steps INTEGER,
|
||||
split_step INTEGER,
|
||||
runpod_job_id TEXT,
|
||||
file_size INTEGER,
|
||||
duration_seconds REAL,
|
||||
mime_type TEXT DEFAULT 'video/mp4',
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'completed', 'failed')),
|
||||
error_message TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Schema migrations tracking
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_mfa_credentials_user_id ON mfa_credentials(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_generated_content_user_id ON generated_content(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_generated_content_created_at ON generated_content(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_generated_content_status ON generated_content(status);
|
||||
137
frontend/src/index.ts
Normal file
137
frontend/src/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import helmet from 'helmet';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { config } from './config.js';
|
||||
import { initDatabase, closeDatabase } from './db/index.js';
|
||||
import { createInitialAdmin } from './services/initService.js';
|
||||
import { SQLiteSessionStore } from './services/sessionService.js';
|
||||
import { apiRateLimiter } from './middleware/rateLimit.js';
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
|
||||
import authRoutes from './routes/auth.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
import contentRoutes from './routes/content.js';
|
||||
import generateRoutes from './routes/generate.js';
|
||||
import healthRoutes from './routes/health.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const app = express();
|
||||
|
||||
// Trust proxy (for Cloudflare)
|
||||
if (config.trustProxy) {
|
||||
app.set('trust proxy', 1);
|
||||
}
|
||||
|
||||
// Security headers
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
mediaSrc: ["'self'", 'data:', 'blob:'],
|
||||
connectSrc: ["'self'"],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}));
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json({ limit: '15mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Session middleware
|
||||
const sessionStore = new SQLiteSessionStore();
|
||||
|
||||
app.use(session({
|
||||
secret: config.sessionSecret,
|
||||
name: 'sid',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: sessionStore,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: 'strict',
|
||||
maxAge: config.sessionMaxAge,
|
||||
},
|
||||
}));
|
||||
|
||||
// Rate limiting for API routes
|
||||
app.use('/api/', apiRateLimiter);
|
||||
|
||||
// API routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/content', contentRoutes);
|
||||
app.use('/api/generate', generateRoutes);
|
||||
|
||||
// Health check routes (no rate limiting)
|
||||
app.use('/health', healthRoutes);
|
||||
|
||||
// Static files
|
||||
app.use(express.static(join(__dirname, '..', 'public')));
|
||||
|
||||
// SPA fallback - serve index.html for all non-API routes
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api/') || req.path.startsWith('/health')) {
|
||||
return next();
|
||||
}
|
||||
res.sendFile(join(__dirname, '..', 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use(errorHandler);
|
||||
|
||||
// Startup
|
||||
async function start() {
|
||||
try {
|
||||
logger.info('Starting ComfyUI Frontend Service...');
|
||||
|
||||
// Initialize database
|
||||
initDatabase();
|
||||
logger.info({ dbPath: config.dbPath }, 'Database initialized');
|
||||
|
||||
// Create initial admin user if needed
|
||||
await createInitialAdmin();
|
||||
|
||||
// Start server
|
||||
const server = app.listen(config.port, () => {
|
||||
logger.info({ port: config.port, env: config.nodeEnv }, 'Server started');
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
logger.info({ signal }, 'Shutdown signal received');
|
||||
|
||||
server.close(() => {
|
||||
logger.info('HTTP server closed');
|
||||
sessionStore.close();
|
||||
closeDatabase();
|
||||
logger.info('Database closed');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force exit after 10 seconds
|
||||
setTimeout(() => {
|
||||
logger.error('Forced shutdown after timeout');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to start server');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
85
frontend/src/middleware/auth.ts
Normal file
85
frontend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { getDb, type UserRow } from '../db/index.js';
|
||||
import type { AuthenticatedRequest, User } from '../types/index.js';
|
||||
|
||||
function rowToUser(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
isAdmin: Boolean(row.is_admin),
|
||||
isActive: Boolean(row.is_active),
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
lastLoginAt: row.last_login_at ? new Date(row.last_login_at) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.session?.userId) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if MFA is required but not completed
|
||||
if (req.session.mfaRequired && !req.session.mfaVerified) {
|
||||
res.status(403).json({ error: 'MFA verification required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Load user from database
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT * FROM users WHERE id = ? AND is_active = 1').get(req.session.userId) as UserRow | undefined;
|
||||
|
||||
if (!row) {
|
||||
req.session.destroy(() => {});
|
||||
res.status(401).json({ error: 'User not found or inactive' });
|
||||
return;
|
||||
}
|
||||
|
||||
(req as AuthenticatedRequest).user = rowToUser(row);
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
|
||||
if (!authReq.user) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authReq.user.isAdmin) {
|
||||
res.status(403).json({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Middleware for routes that allow partial auth (pre-MFA)
|
||||
export function requirePartialAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.session?.userId) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Load user from database (even if MFA not completed)
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT * FROM users WHERE id = ? AND is_active = 1').get(req.session.userId) as UserRow | undefined;
|
||||
|
||||
if (!row) {
|
||||
req.session.destroy(() => {});
|
||||
res.status(401).json({ error: 'User not found or inactive' });
|
||||
return;
|
||||
}
|
||||
|
||||
(req as AuthenticatedRequest).user = rowToUser(row);
|
||||
next();
|
||||
}
|
||||
|
||||
// Helper to check if user owns a resource or is admin
|
||||
export function canAccessResource(user: User | undefined, resourceUserId: number): boolean {
|
||||
if (!user) return false;
|
||||
return user.isAdmin || user.id === resourceUserId;
|
||||
}
|
||||
64
frontend/src/middleware/errorHandler.ts
Normal file
64
frontend/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Request, Response, NextFunction, ErrorRequestHandler } from 'express';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
export interface AppError extends Error {
|
||||
statusCode?: number;
|
||||
isOperational?: boolean;
|
||||
}
|
||||
|
||||
export function createError(message: string, statusCode: number = 500): AppError {
|
||||
const error: AppError = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
error.isOperational = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (
|
||||
err: AppError | Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
): void => {
|
||||
// Handle Zod validation errors
|
||||
if (err instanceof ZodError) {
|
||||
const message = err.errors.map(e => e.message).join(', ');
|
||||
res.status(400).json({ error: message });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get status code and message
|
||||
const statusCode = 'statusCode' in err ? err.statusCode || 500 : 500;
|
||||
const isOperational = 'isOperational' in err ? err.isOperational : false;
|
||||
|
||||
// Log error
|
||||
if (statusCode >= 500 || !isOperational) {
|
||||
logger.error({
|
||||
err,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
statusCode,
|
||||
}, 'Unhandled error');
|
||||
} else {
|
||||
logger.warn({
|
||||
message: err.message,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
statusCode,
|
||||
}, 'Operational error');
|
||||
}
|
||||
|
||||
// Send response
|
||||
res.status(statusCode).json({
|
||||
error: isOperational ? err.message : 'Internal server error',
|
||||
});
|
||||
};
|
||||
|
||||
// Async route handler wrapper
|
||||
export function asyncHandler<T>(
|
||||
fn: (req: Request, res: Response, next: NextFunction) => Promise<T>
|
||||
): (req: Request, res: Response, next: NextFunction) => void {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
54
frontend/src/middleware/rateLimit.ts
Normal file
54
frontend/src/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { config } from '../config.js';
|
||||
|
||||
// General API rate limiter
|
||||
export const apiRateLimiter = rateLimit({
|
||||
windowMs: config.rateLimit.windowMs,
|
||||
max: config.rateLimit.maxRequests,
|
||||
message: { error: 'Too many requests, please try again later' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
// Use CF-Connecting-IP if behind Cloudflare, otherwise use IP
|
||||
return (req.headers['cf-connecting-ip'] as string) || req.ip || 'unknown';
|
||||
},
|
||||
});
|
||||
|
||||
// Strict rate limiter for login attempts
|
||||
export const loginRateLimiter = rateLimit({
|
||||
windowMs: config.loginRateLimit.windowMs,
|
||||
max: config.loginRateLimit.maxRequests,
|
||||
message: { error: 'Too many login attempts, please try again later' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
return (req.headers['cf-connecting-ip'] as string) || req.ip || 'unknown';
|
||||
},
|
||||
skipSuccessfulRequests: false,
|
||||
});
|
||||
|
||||
// MFA verification rate limiter
|
||||
export const mfaRateLimiter = rateLimit({
|
||||
windowMs: 5 * 60 * 1000, // 5 minutes
|
||||
max: 5,
|
||||
message: { error: 'Too many MFA attempts, please try again later' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
// Use session ID for MFA attempts (per-session limiting)
|
||||
return req.session?.id || req.ip || 'unknown';
|
||||
},
|
||||
});
|
||||
|
||||
// Generation rate limiter (more restrictive)
|
||||
export const generationRateLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 10,
|
||||
message: { error: 'Generation limit reached, please try again later' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
// Use user ID for generation limits
|
||||
return req.session?.userId?.toString() || req.ip || 'unknown';
|
||||
},
|
||||
});
|
||||
207
frontend/src/routes/auth.ts
Normal file
207
frontend/src/routes/auth.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Router } from 'express';
|
||||
import { verifyUserPassword } from '../services/userService.js';
|
||||
import {
|
||||
userHasMfa,
|
||||
getUserMfaTypes,
|
||||
verifyTotpCode,
|
||||
startWebAuthnAuthentication,
|
||||
verifyWebAuthnAuthentication,
|
||||
} from '../services/mfaService.js';
|
||||
import { requireAuth, requirePartialAuth } from '../middleware/auth.js';
|
||||
import { loginRateLimiter, mfaRateLimiter } from '../middleware/rateLimit.js';
|
||||
import { asyncHandler } from '../middleware/errorHandler.js';
|
||||
import { validateRequest, loginSchema, totpVerifySchema } from '../utils/validators.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { AuthenticatedRequest } from '../types/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Login
|
||||
router.post('/login', loginRateLimiter, asyncHandler(async (req, res) => {
|
||||
const validation = validateRequest(loginSchema, req.body);
|
||||
if (!validation.success) {
|
||||
res.status(400).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password } = validation.data;
|
||||
|
||||
const user = await verifyUserPassword(username, password);
|
||||
if (!user) {
|
||||
logger.warn({ username }, 'Failed login attempt');
|
||||
res.status(401).json({ error: 'Invalid username or password' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has MFA enabled
|
||||
const hasMfa = userHasMfa(user.id);
|
||||
|
||||
if (hasMfa) {
|
||||
// Set up partial session for MFA
|
||||
req.session.userId = user.id;
|
||||
req.session.isAdmin = user.isAdmin;
|
||||
req.session.mfaRequired = true;
|
||||
req.session.mfaVerified = false;
|
||||
|
||||
const mfaTypes = getUserMfaTypes(user.id);
|
||||
|
||||
res.json({
|
||||
requiresMfa: true,
|
||||
mfaTypes,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No MFA, complete login
|
||||
req.session.userId = user.id;
|
||||
req.session.isAdmin = user.isAdmin;
|
||||
req.session.mfaRequired = false;
|
||||
req.session.mfaVerified = true;
|
||||
|
||||
logger.info({ userId: user.id, username }, 'User logged in');
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
// Verify TOTP
|
||||
router.post('/mfa/totp', mfaRateLimiter, requirePartialAuth, asyncHandler(async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
|
||||
if (!req.session.mfaRequired) {
|
||||
res.status(400).json({ error: 'MFA not required for this session' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateRequest(totpVerifySchema, req.body);
|
||||
if (!validation.success) {
|
||||
res.status(400).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const { code } = validation.data;
|
||||
const isValid = await verifyTotpCode(authReq.user!.id, code);
|
||||
|
||||
if (!isValid) {
|
||||
logger.warn({ userId: authReq.user!.id }, 'Failed TOTP verification');
|
||||
res.status(401).json({ error: 'Invalid TOTP code' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Complete MFA verification
|
||||
req.session.mfaVerified = true;
|
||||
req.session.mfaRequired = false;
|
||||
|
||||
logger.info({ userId: authReq.user!.id }, 'TOTP verification successful');
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: authReq.user!.id,
|
||||
username: authReq.user!.username,
|
||||
email: authReq.user!.email,
|
||||
isAdmin: authReq.user!.isAdmin,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
// Get WebAuthn challenge
|
||||
router.post('/mfa/webauthn/challenge', mfaRateLimiter, requirePartialAuth, asyncHandler(async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
|
||||
if (!req.session.mfaRequired) {
|
||||
res.status(400).json({ error: 'MFA not required for this session' });
|
||||
return;
|
||||
}
|
||||
|
||||
const options = await startWebAuthnAuthentication(authReq.user!.id);
|
||||
|
||||
// Store challenge in session
|
||||
req.session.webauthnChallenge = options.challenge;
|
||||
|
||||
res.json(options);
|
||||
}));
|
||||
|
||||
// Verify WebAuthn
|
||||
router.post('/mfa/webauthn/verify', mfaRateLimiter, requirePartialAuth, asyncHandler(async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
|
||||
if (!req.session.mfaRequired) {
|
||||
res.status(400).json({ error: 'MFA not required for this session' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.session.webauthnChallenge) {
|
||||
res.status(400).json({ error: 'No WebAuthn challenge found. Request a challenge first.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await verifyWebAuthnAuthentication(
|
||||
authReq.user!.id,
|
||||
req.body,
|
||||
req.session.webauthnChallenge
|
||||
);
|
||||
|
||||
// Clear challenge
|
||||
delete req.session.webauthnChallenge;
|
||||
|
||||
if (!isValid) {
|
||||
logger.warn({ userId: authReq.user!.id }, 'Failed WebAuthn verification');
|
||||
res.status(401).json({ error: 'WebAuthn verification failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Complete MFA verification
|
||||
req.session.mfaVerified = true;
|
||||
req.session.mfaRequired = false;
|
||||
|
||||
logger.info({ userId: authReq.user!.id }, 'WebAuthn verification successful');
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: authReq.user!.id,
|
||||
username: authReq.user!.username,
|
||||
email: authReq.user!.email,
|
||||
isAdmin: authReq.user!.isAdmin,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
// Logout
|
||||
router.post('/logout', (req, res) => {
|
||||
const userId = req.session?.userId;
|
||||
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
logger.error({ err }, 'Session destruction error');
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
logger.info({ userId }, 'User logged out');
|
||||
}
|
||||
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Get current user
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: authReq.user!.id,
|
||||
username: authReq.user!.username,
|
||||
email: authReq.user!.email,
|
||||
isAdmin: authReq.user!.isAdmin,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
237
frontend/src/routes/content.ts
Normal file
237
frontend/src/routes/content.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Router } from 'express';
|
||||
import { createReadStream, statSync } from 'fs';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { canAccessResource } from '../middleware/auth.js';
|
||||
import { asyncHandler } from '../middleware/errorHandler.js';
|
||||
import {
|
||||
listContent,
|
||||
getContentById,
|
||||
getContentFilePath,
|
||||
deleteContent,
|
||||
} from '../services/contentService.js';
|
||||
import { validateRequest, contentListSchema } from '../utils/validators.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { AuthenticatedRequest } from '../types/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require auth
|
||||
router.use(requireAuth);
|
||||
|
||||
// List content
|
||||
router.get('/', (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const validation = validateRequest(contentListSchema, req.query);
|
||||
|
||||
if (!validation.success) {
|
||||
res.status(400).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const { page = 1, limit = 20, status, userId } = validation.data;
|
||||
|
||||
// Non-admins can only see their own content
|
||||
const filterUserId = authReq.user!.isAdmin && userId ? userId : authReq.user!.id;
|
||||
|
||||
// Admin can see all content if no userId filter
|
||||
const params = authReq.user!.isAdmin && !userId
|
||||
? { status, page, limit }
|
||||
: { userId: filterUserId, status, page, limit };
|
||||
|
||||
const { content, total } = listContent(params);
|
||||
|
||||
res.json({
|
||||
content: content.map(c => ({
|
||||
id: c.id,
|
||||
filename: c.filename,
|
||||
prompt: c.prompt,
|
||||
resolution: c.resolution,
|
||||
steps: c.steps,
|
||||
status: c.status,
|
||||
fileSize: c.fileSize,
|
||||
createdAt: c.createdAt,
|
||||
userId: c.userId,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Get content details
|
||||
router.get('/:id', (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const contentId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(contentId)) {
|
||||
res.status(400).json({ error: 'Invalid content ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = getContentById(contentId);
|
||||
if (!content) {
|
||||
res.status(404).json({ error: 'Content not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAccessResource(authReq.user, content.userId)) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
content: {
|
||||
id: content.id,
|
||||
userId: content.userId,
|
||||
filename: content.filename,
|
||||
originalFilename: content.originalFilename,
|
||||
prompt: content.prompt,
|
||||
negativePrompt: content.negativePrompt,
|
||||
resolution: content.resolution,
|
||||
steps: content.steps,
|
||||
splitStep: content.splitStep,
|
||||
runpodJobId: content.runpodJobId,
|
||||
fileSize: content.fileSize,
|
||||
mimeType: content.mimeType,
|
||||
status: content.status,
|
||||
errorMessage: content.errorMessage,
|
||||
createdAt: content.createdAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Download content file
|
||||
router.get('/:id/download', (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const contentId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(contentId)) {
|
||||
res.status(400).json({ error: 'Invalid content ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = getContentById(contentId);
|
||||
if (!content) {
|
||||
res.status(404).json({ error: 'Content not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAccessResource(authReq.user, content.userId)) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.status !== 'completed') {
|
||||
res.status(400).json({ error: 'Content not ready for download' });
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = getContentFilePath(content);
|
||||
if (!filePath) {
|
||||
res.status(404).json({ error: 'Content file not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
|
||||
res.setHeader('Content-Type', content.mimeType);
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${content.filename}"`);
|
||||
|
||||
createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
// Stream content (for video playback)
|
||||
router.get('/:id/stream', (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const contentId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(contentId)) {
|
||||
res.status(400).json({ error: 'Invalid content ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = getContentById(contentId);
|
||||
if (!content) {
|
||||
res.status(404).json({ error: 'Content not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAccessResource(authReq.user, content.userId)) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.status !== 'completed') {
|
||||
res.status(400).json({ error: 'Content not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = getContentFilePath(content);
|
||||
if (!filePath) {
|
||||
res.status(404).json({ error: 'Content file not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
const range = req.headers.range;
|
||||
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunkSize = end - start + 1;
|
||||
|
||||
res.status(206);
|
||||
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Content-Length', chunkSize);
|
||||
res.setHeader('Content-Type', content.mimeType);
|
||||
|
||||
createReadStream(filePath, { start, end }).pipe(res);
|
||||
} else {
|
||||
res.setHeader('Content-Length', fileSize);
|
||||
res.setHeader('Content-Type', content.mimeType);
|
||||
|
||||
createReadStream(filePath).pipe(res);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete content
|
||||
router.delete('/:id', (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const contentId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(contentId)) {
|
||||
res.status(400).json({ error: 'Invalid content ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = getContentById(contentId);
|
||||
if (!content) {
|
||||
res.status(404).json({ error: 'Content not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAccessResource(authReq.user, content.userId)) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const success = deleteContent(contentId);
|
||||
|
||||
if (!success) {
|
||||
res.status(500).json({ error: 'Failed to delete content' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ userId: authReq.user!.id, contentId }, 'Content deleted');
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
174
frontend/src/routes/generate.ts
Normal file
174
frontend/src/routes/generate.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { generationRateLimiter } from '../middleware/rateLimit.js';
|
||||
import { asyncHandler } from '../middleware/errorHandler.js';
|
||||
import { validateRequest, generationRequestSchema } from '../utils/validators.js';
|
||||
import { submitJob, getJobStatus, pollForCompletion } from '../services/runpodService.js';
|
||||
import {
|
||||
createPendingContent,
|
||||
updateContentStatus,
|
||||
saveContentFile,
|
||||
getContentById,
|
||||
} from '../services/contentService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { AuthenticatedRequest } from '../types/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require auth
|
||||
router.use(requireAuth);
|
||||
|
||||
// Submit generation job
|
||||
router.post('/', generationRateLimiter, asyncHandler(async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const validation = validateRequest(generationRequestSchema, req.body);
|
||||
|
||||
if (!validation.success) {
|
||||
res.status(400).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const { image, prompt, negativePrompt, resolution, steps, splitStep, timeout } = validation.data;
|
||||
|
||||
// Create pending content record
|
||||
const content = createPendingContent({
|
||||
userId: authReq.user!.id,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
resolution,
|
||||
steps,
|
||||
splitStep,
|
||||
});
|
||||
|
||||
logger.info({
|
||||
userId: authReq.user!.id,
|
||||
contentId: content.id,
|
||||
prompt: prompt.substring(0, 50),
|
||||
}, 'Generation job started');
|
||||
|
||||
try {
|
||||
// Submit job to RunPod
|
||||
const job = await submitJob({
|
||||
image,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
resolution,
|
||||
steps,
|
||||
splitStep,
|
||||
timeout,
|
||||
});
|
||||
|
||||
// Update content with job ID
|
||||
updateContentStatus(content.id, 'processing', { runpodJobId: job.id });
|
||||
|
||||
res.json({
|
||||
contentId: content.id,
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, contentId: content.id }, 'Failed to submit generation job');
|
||||
updateContentStatus(content.id, 'failed', { errorMessage: String(error) });
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to submit generation job',
|
||||
contentId: content.id,
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
// Poll job status and optionally wait for completion
|
||||
router.get('/:jobId/status', asyncHandler(async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const { jobId } = req.params;
|
||||
const wait = req.query.wait === 'true';
|
||||
|
||||
try {
|
||||
let status;
|
||||
|
||||
if (wait) {
|
||||
// Long poll - wait for completion
|
||||
status = await pollForCompletion(jobId, 60000, 2000); // 1 minute timeout for long poll
|
||||
} else {
|
||||
status = await getJobStatus(jobId);
|
||||
}
|
||||
|
||||
// If completed, process the output
|
||||
if (status.status === 'COMPLETED' && status.output) {
|
||||
// Find the content record for this job
|
||||
const { getDb } = await import('../db/index.js');
|
||||
const db = getDb();
|
||||
const row = db.prepare(
|
||||
'SELECT id FROM generated_content WHERE runpod_job_id = ? AND user_id = ?'
|
||||
).get(jobId, authReq.user!.id) as { id: number } | undefined;
|
||||
|
||||
if (row && status.output.outputs && status.output.outputs.length > 0) {
|
||||
const output = status.output.outputs[0];
|
||||
|
||||
if (output.data) {
|
||||
// Save base64 data to file
|
||||
saveContentFile(row.id, output.data);
|
||||
} else if (output.path) {
|
||||
// File was saved to volume - update status
|
||||
updateContentStatus(row.id, 'completed', { fileSize: output.size });
|
||||
}
|
||||
}
|
||||
} else if (status.status === 'FAILED') {
|
||||
// Update content status to failed
|
||||
const { getDb } = await import('../db/index.js');
|
||||
const db = getDb();
|
||||
const row = db.prepare(
|
||||
'SELECT id FROM generated_content WHERE runpod_job_id = ? AND user_id = ?'
|
||||
).get(jobId, authReq.user!.id) as { id: number } | undefined;
|
||||
|
||||
if (row) {
|
||||
updateContentStatus(row.id, 'failed', {
|
||||
errorMessage: status.error || status.output?.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
jobId,
|
||||
status: status.status,
|
||||
output: status.output,
|
||||
error: status.error,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, jobId }, 'Failed to get job status');
|
||||
res.status(500).json({ error: 'Failed to get job status' });
|
||||
}
|
||||
}));
|
||||
|
||||
// Get content status by content ID
|
||||
router.get('/content/:contentId/status', (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const contentId = parseInt(req.params.contentId, 10);
|
||||
|
||||
if (isNaN(contentId)) {
|
||||
res.status(400).json({ error: 'Invalid content ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = getContentById(contentId);
|
||||
|
||||
if (!content) {
|
||||
res.status(404).json({ error: 'Content not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check ownership
|
||||
if (content.userId !== authReq.user!.id && !authReq.user!.isAdmin) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
contentId: content.id,
|
||||
status: content.status,
|
||||
runpodJobId: content.runpodJobId,
|
||||
errorMessage: content.errorMessage,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
22
frontend/src/routes/health.ts
Normal file
22
frontend/src/routes/health.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Router } from 'express';
|
||||
import { getDb } from '../db/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Basic health check
|
||||
router.get('/', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// Readiness check (verifies DB connection)
|
||||
router.get('/ready', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
db.prepare('SELECT 1').get();
|
||||
res.json({ status: 'ready', database: 'connected' });
|
||||
} catch (error) {
|
||||
res.status(503).json({ status: 'not ready', database: 'disconnected' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
381
frontend/src/routes/users.ts
Normal file
381
frontend/src/routes/users.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
createUser,
|
||||
getUserById,
|
||||
getAllUsers,
|
||||
updateUser,
|
||||
updatePassword,
|
||||
deleteUser,
|
||||
usernameExists,
|
||||
emailExists,
|
||||
} from '../services/userService.js';
|
||||
import {
|
||||
getUserMfaCredentials,
|
||||
generateTotpSecret,
|
||||
verifyAndEnableTotp,
|
||||
startWebAuthnRegistration,
|
||||
completeWebAuthnRegistration,
|
||||
deleteMfaCredential,
|
||||
cancelPendingTotp,
|
||||
} from '../services/mfaService.js';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth.js';
|
||||
import { asyncHandler, createError } from '../middleware/errorHandler.js';
|
||||
import {
|
||||
validateRequest,
|
||||
createUserSchema,
|
||||
updateUserSchema,
|
||||
changePasswordSchema,
|
||||
totpVerifySchema,
|
||||
mfaNameSchema,
|
||||
} from '../utils/validators.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { AuthenticatedRequest } from '../types/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require admin
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
// List all users
|
||||
router.get('/', (req, res) => {
|
||||
const users = getAllUsers();
|
||||
res.json({
|
||||
users: users.map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
isAdmin: u.isAdmin,
|
||||
isActive: u.isActive,
|
||||
createdAt: u.createdAt,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Create user
|
||||
router.post('/', asyncHandler(async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const validation = validateRequest(createUserSchema, req.body);
|
||||
|
||||
if (!validation.success) {
|
||||
res.status(400).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password, email, isAdmin } = validation.data;
|
||||
|
||||
// Check if username exists
|
||||
if (usernameExists(username)) {
|
||||
res.status(409).json({ error: 'Username already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
if (email && emailExists(email)) {
|
||||
res.status(409).json({ error: 'Email already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await createUser(username, password, email, isAdmin);
|
||||
|
||||
logger.info({ adminId: authReq.user!.id, newUserId: user.id, username }, 'User created by admin');
|
||||
|
||||
res.status(201).json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
// Get user
|
||||
router.get('/:id', (req, res) => {
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
if (isNaN(userId)) {
|
||||
res.status(400).json({ error: 'Invalid user ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUserById(userId);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const mfaCredentials = getUserMfaCredentials(userId);
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
mfaCredentials: mfaCredentials.map(c => ({
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
name: c.name,
|
||||
createdAt: c.createdAt,
|
||||
lastUsedAt: c.lastUsedAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Update user
|
||||
router.put('/:id', asyncHandler(async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
res.status(400).json({ error: 'Invalid user ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateRequest(updateUserSchema, req.body);
|
||||
if (!validation.success) {
|
||||
res.status(400).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const existingUser = getUserById(userId);
|
||||
if (!existingUser) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
if (validation.data.username && usernameExists(validation.data.username, userId)) {
|
||||
res.status(409).json({ error: 'Username already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (validation.data.email && emailExists(validation.data.email, userId)) {
|
||||
res.status(409).json({ error: 'Email already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await updateUser(userId, validation.data);
|
||||
|
||||
logger.info({ adminId: authReq.user!.id, userId }, 'User updated by admin');
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user!.id,
|
||||
username: user!.username,
|
||||
email: user!.email,
|
||||
isAdmin: user!.isAdmin,
|
||||
isActive: user!.isActive,
|
||||
createdAt: user!.createdAt,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
// Reset user password
|
||||
router.post('/:id/reset-password', asyncHandler(async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
res.status(400).json({ error: 'Invalid user ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { newPassword } = req.body;
|
||||
if (!newPassword || newPassword.length < 12) {
|
||||
res.status(400).json({ error: 'Password must be at least 12 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUserById(userId);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
await updatePassword(userId, newPassword);
|
||||
|
||||
logger.info({ adminId: authReq.user!.id, userId }, 'User password reset by admin');
|
||||
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
// Delete user
|
||||
router.delete('/:id', (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
res.status(400).json({ error: 'Invalid user ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent self-deletion
|
||||
if (userId === authReq.user!.id) {
|
||||
res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUserById(userId);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
deleteUser(userId);
|
||||
|
||||
logger.info({ adminId: authReq.user!.id, userId, username: user.username }, 'User deleted by admin');
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ==================== MFA Management ====================
|
||||
|
||||
// Setup TOTP for user
|
||||
router.post('/:id/mfa/totp/setup', asyncHandler(async (req, res) => {
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
res.status(400).json({ error: 'Invalid user ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUserById(userId);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateRequest(mfaNameSchema, req.body);
|
||||
const name = validation.success ? validation.data.name : 'Default';
|
||||
|
||||
// Cancel any pending TOTP setup
|
||||
cancelPendingTotp(userId);
|
||||
|
||||
const { secret, uri } = await generateTotpSecret(userId, name);
|
||||
|
||||
res.json({
|
||||
secret,
|
||||
uri,
|
||||
message: 'Scan the QR code or enter the secret in your authenticator app, then verify with a code',
|
||||
});
|
||||
}));
|
||||
|
||||
// Verify and enable TOTP
|
||||
router.post('/:id/mfa/totp/verify', asyncHandler(async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
res.status(400).json({ error: 'Invalid user ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateRequest(totpVerifySchema, req.body);
|
||||
if (!validation.success) {
|
||||
res.status(400).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await verifyAndEnableTotp(userId, validation.data.code);
|
||||
|
||||
if (!isValid) {
|
||||
res.status(400).json({ error: 'Invalid TOTP code' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ adminId: authReq.user!.id, userId }, 'TOTP enabled for user');
|
||||
|
||||
res.json({ success: true, message: 'TOTP enabled successfully' });
|
||||
}));
|
||||
|
||||
// Start WebAuthn registration
|
||||
router.post('/:id/mfa/webauthn/register', asyncHandler(async (req, res) => {
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
res.status(400).json({ error: 'Invalid user ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUserById(userId);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const options = await startWebAuthnRegistration(userId);
|
||||
|
||||
// Store challenge in session
|
||||
req.session.webauthnChallenge = options.challenge;
|
||||
|
||||
res.json(options);
|
||||
}));
|
||||
|
||||
// Complete WebAuthn registration
|
||||
router.post('/:id/mfa/webauthn/complete', asyncHandler(async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
res.status(400).json({ error: 'Invalid user ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.session.webauthnChallenge) {
|
||||
res.status(400).json({ error: 'No WebAuthn challenge found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateRequest(mfaNameSchema, { name: req.body.name });
|
||||
const name = validation.success ? validation.data.name : 'Security Key';
|
||||
|
||||
const success = await completeWebAuthnRegistration(
|
||||
userId,
|
||||
req.body,
|
||||
req.session.webauthnChallenge,
|
||||
name
|
||||
);
|
||||
|
||||
delete req.session.webauthnChallenge;
|
||||
|
||||
if (!success) {
|
||||
res.status(400).json({ error: 'WebAuthn registration failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ adminId: authReq.user!.id, userId }, 'WebAuthn credential registered for user');
|
||||
|
||||
res.json({ success: true, message: 'WebAuthn credential registered successfully' });
|
||||
}));
|
||||
|
||||
// Delete MFA credential
|
||||
router.delete('/:id/mfa/:credentialId', (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
const credentialId = parseInt(req.params.credentialId, 10);
|
||||
|
||||
if (isNaN(userId) || isNaN(credentialId)) {
|
||||
res.status(400).json({ error: 'Invalid ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const success = deleteMfaCredential(userId, credentialId);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({ error: 'MFA credential not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ adminId: authReq.user!.id, userId, credentialId }, 'MFA credential deleted');
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
201
frontend/src/services/contentService.ts
Normal file
201
frontend/src/services/contentService.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { writeFileSync, unlinkSync, existsSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getDb, type GeneratedContentRow } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import type { GeneratedContent } from '../types/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
function rowToContent(row: GeneratedContentRow): GeneratedContent {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
filename: row.filename,
|
||||
originalFilename: row.original_filename,
|
||||
prompt: row.prompt,
|
||||
negativePrompt: row.negative_prompt,
|
||||
resolution: row.resolution,
|
||||
steps: row.steps,
|
||||
splitStep: row.split_step,
|
||||
runpodJobId: row.runpod_job_id,
|
||||
fileSize: row.file_size,
|
||||
durationSeconds: row.duration_seconds,
|
||||
mimeType: row.mime_type,
|
||||
status: row.status,
|
||||
errorMessage: row.error_message,
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateContentParams {
|
||||
userId: number;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
resolution?: number;
|
||||
steps?: number;
|
||||
splitStep?: number;
|
||||
originalFilename?: string;
|
||||
}
|
||||
|
||||
export function createPendingContent(params: CreateContentParams): GeneratedContent {
|
||||
const db = getDb();
|
||||
const filename = `${randomUUID()}.mp4`;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO generated_content (
|
||||
user_id, filename, original_filename, prompt, negative_prompt,
|
||||
resolution, steps, split_step, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')
|
||||
`).run(
|
||||
params.userId,
|
||||
filename,
|
||||
params.originalFilename || null,
|
||||
params.prompt,
|
||||
params.negativePrompt || null,
|
||||
params.resolution || null,
|
||||
params.steps || null,
|
||||
params.splitStep || null
|
||||
);
|
||||
|
||||
const row = db.prepare('SELECT * FROM generated_content WHERE id = ?').get(result.lastInsertRowid) as GeneratedContentRow;
|
||||
return rowToContent(row);
|
||||
}
|
||||
|
||||
export function updateContentStatus(
|
||||
id: number,
|
||||
status: 'processing' | 'completed' | 'failed',
|
||||
updates?: {
|
||||
runpodJobId?: string;
|
||||
fileSize?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
): GeneratedContent | null {
|
||||
const db = getDb();
|
||||
|
||||
const setParts = ['status = ?'];
|
||||
const values: (string | number | null)[] = [status];
|
||||
|
||||
if (updates?.runpodJobId !== undefined) {
|
||||
setParts.push('runpod_job_id = ?');
|
||||
values.push(updates.runpodJobId);
|
||||
}
|
||||
if (updates?.fileSize !== undefined) {
|
||||
setParts.push('file_size = ?');
|
||||
values.push(updates.fileSize);
|
||||
}
|
||||
if (updates?.errorMessage !== undefined) {
|
||||
setParts.push('error_message = ?');
|
||||
values.push(updates.errorMessage);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
|
||||
db.prepare(`UPDATE generated_content SET ${setParts.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
return getContentById(id);
|
||||
}
|
||||
|
||||
export function saveContentFile(contentId: number, data: Buffer | string): string | null {
|
||||
const content = getContentById(contentId);
|
||||
if (!content) return null;
|
||||
|
||||
const filePath = join(config.contentDir, content.filename);
|
||||
|
||||
try {
|
||||
const buffer = typeof data === 'string' ? Buffer.from(data, 'base64') : data;
|
||||
writeFileSync(filePath, buffer);
|
||||
|
||||
const stats = statSync(filePath);
|
||||
updateContentStatus(contentId, 'completed', { fileSize: stats.size });
|
||||
|
||||
logger.info({ contentId, filename: content.filename, size: stats.size }, 'Content file saved');
|
||||
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
logger.error({ error, contentId }, 'Failed to save content file');
|
||||
updateContentStatus(contentId, 'failed', { errorMessage: 'Failed to save file' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getContentById(id: number): GeneratedContent | null {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT * FROM generated_content WHERE id = ?').get(id) as GeneratedContentRow | undefined;
|
||||
return row ? rowToContent(row) : null;
|
||||
}
|
||||
|
||||
export function getContentFilePath(content: GeneratedContent): string | null {
|
||||
const filePath = join(config.contentDir, content.filename);
|
||||
return existsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
export interface ListContentParams {
|
||||
userId?: number;
|
||||
status?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function listContent(params: ListContentParams): { content: GeneratedContent[]; total: number } {
|
||||
const db = getDb();
|
||||
|
||||
const whereParts: string[] = [];
|
||||
const values: (string | number)[] = [];
|
||||
|
||||
if (params.userId !== undefined) {
|
||||
whereParts.push('user_id = ?');
|
||||
values.push(params.userId);
|
||||
}
|
||||
if (params.status) {
|
||||
whereParts.push('status = ?');
|
||||
values.push(params.status);
|
||||
}
|
||||
|
||||
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
// Get total count
|
||||
const countRow = db.prepare(`SELECT COUNT(*) as count FROM generated_content ${whereClause}`).get(...values) as { count: number };
|
||||
const total = countRow.count;
|
||||
|
||||
// Get paginated results
|
||||
const page = params.page || 1;
|
||||
const limit = params.limit || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM generated_content ${whereClause}
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||
`).all(...values, limit, offset) as GeneratedContentRow[];
|
||||
|
||||
return {
|
||||
content: rows.map(rowToContent),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteContent(id: number): boolean {
|
||||
const content = getContentById(id);
|
||||
if (!content) return false;
|
||||
|
||||
// Delete file if exists
|
||||
const filePath = join(config.contentDir, content.filename);
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
unlinkSync(filePath);
|
||||
logger.info({ contentId: id, filename: content.filename }, 'Content file deleted');
|
||||
} catch (error) {
|
||||
logger.error({ error, contentId: id }, 'Failed to delete content file');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete database record
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM generated_content WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function getUserContentCount(userId: number): number {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT COUNT(*) as count FROM generated_content WHERE user_id = ?').get(userId) as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
32
frontend/src/services/initService.ts
Normal file
32
frontend/src/services/initService.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { config } from '../config.js';
|
||||
import { countUsers, createUser } from './userService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export async function createInitialAdmin(): Promise<void> {
|
||||
const userCount = countUsers();
|
||||
|
||||
if (userCount > 0) {
|
||||
logger.info('Users already exist, skipping initial admin creation');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.adminPassword) {
|
||||
logger.warn('No ADMIN_PASSWORD set, skipping initial admin creation');
|
||||
logger.warn('Set ADMIN_PASSWORD environment variable to create the initial admin user');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const admin = await createUser(
|
||||
config.adminUsername,
|
||||
config.adminPassword,
|
||||
config.adminEmail || null,
|
||||
true
|
||||
);
|
||||
|
||||
logger.info({ username: admin.username }, 'Initial admin user created');
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to create initial admin user');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
330
frontend/src/services/mfaService.ts
Normal file
330
frontend/src/services/mfaService.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { TOTP, Secret } from 'otpauth';
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
type VerifiedRegistrationResponse,
|
||||
type VerifiedAuthenticationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
|
||||
import { getDb, type MfaCredentialRow } from '../db/index.js';
|
||||
import { encrypt, decrypt, generateSecureToken } from '../utils/crypto.js';
|
||||
import { config } from '../config.js';
|
||||
import type { MfaCredential, TotpCredential, WebAuthnCredential } from '../types/index.js';
|
||||
|
||||
// Helper to convert row to credential
|
||||
function rowToCredential(row: MfaCredentialRow): MfaCredential {
|
||||
const base: MfaCredential = {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
isActive: Boolean(row.is_active),
|
||||
createdAt: new Date(row.created_at),
|
||||
lastUsedAt: row.last_used_at ? new Date(row.last_used_at) : null,
|
||||
};
|
||||
|
||||
if (row.type === 'webauthn') {
|
||||
return {
|
||||
...base,
|
||||
type: 'webauthn',
|
||||
credentialId: row.credential_id!,
|
||||
publicKey: row.public_key!,
|
||||
counter: row.counter,
|
||||
transports: row.transports ? JSON.parse(row.transports) : [],
|
||||
} as WebAuthnCredential;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
// Check if user has any active MFA credentials
|
||||
export function userHasMfa(userId: number): boolean {
|
||||
const db = getDb();
|
||||
const row = db.prepare(
|
||||
'SELECT id FROM mfa_credentials WHERE user_id = ? AND is_active = 1 LIMIT 1'
|
||||
).get(userId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
// Get all MFA credentials for a user
|
||||
export function getUserMfaCredentials(userId: number): MfaCredential[] {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(
|
||||
'SELECT * FROM mfa_credentials WHERE user_id = ? AND is_active = 1'
|
||||
).all(userId) as MfaCredentialRow[];
|
||||
return rows.map(rowToCredential);
|
||||
}
|
||||
|
||||
// Get MFA credential types for a user
|
||||
export function getUserMfaTypes(userId: number): ('totp' | 'webauthn')[] {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(
|
||||
'SELECT DISTINCT type FROM mfa_credentials WHERE user_id = ? AND is_active = 1'
|
||||
).all(userId) as { type: 'totp' | 'webauthn' }[];
|
||||
return rows.map(r => r.type);
|
||||
}
|
||||
|
||||
// ==================== TOTP ====================
|
||||
|
||||
export async function generateTotpSecret(userId: number, name: string = 'Default'): Promise<{ secret: string; uri: string }> {
|
||||
const secret = new Secret({ size: 20 });
|
||||
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT username FROM users WHERE id = ?').get(userId) as { username: string } | undefined;
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const totp = new TOTP({
|
||||
issuer: config.webauthn.rpName,
|
||||
label: user.username,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret,
|
||||
});
|
||||
|
||||
const encryptedSecret = await encrypt(secret.base32);
|
||||
|
||||
// Store temporarily (not active until verified)
|
||||
db.prepare(`
|
||||
INSERT INTO mfa_credentials (user_id, type, name, totp_secret, is_active)
|
||||
VALUES (?, 'totp', ?, ?, 0)
|
||||
`).run(userId, name, encryptedSecret);
|
||||
|
||||
return {
|
||||
secret: secret.base32,
|
||||
uri: totp.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyAndEnableTotp(userId: number, code: string): Promise<boolean> {
|
||||
const db = getDb();
|
||||
|
||||
// Get the unverified TOTP credential
|
||||
const row = db.prepare(`
|
||||
SELECT * FROM mfa_credentials
|
||||
WHERE user_id = ? AND type = 'totp' AND is_active = 0
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`).get(userId) as MfaCredentialRow | undefined;
|
||||
|
||||
if (!row || !row.totp_secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decryptedSecret = await decrypt(row.totp_secret);
|
||||
const secret = Secret.fromBase32(decryptedSecret);
|
||||
|
||||
const totp = new TOTP({
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret,
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token: code, window: 1 });
|
||||
if (delta === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Activate the credential
|
||||
db.prepare('UPDATE mfa_credentials SET is_active = 1 WHERE id = ?').run(row.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function verifyTotpCode(userId: number, code: string): Promise<boolean> {
|
||||
const db = getDb();
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM mfa_credentials
|
||||
WHERE user_id = ? AND type = 'totp' AND is_active = 1
|
||||
`).all(userId) as MfaCredentialRow[];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.totp_secret) continue;
|
||||
|
||||
const decryptedSecret = await decrypt(row.totp_secret);
|
||||
const secret = Secret.fromBase32(decryptedSecret);
|
||||
|
||||
const totp = new TOTP({
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret,
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token: code, window: 1 });
|
||||
if (delta !== null) {
|
||||
// Update last used time
|
||||
db.prepare("UPDATE mfa_credentials SET last_used_at = datetime('now') WHERE id = ?").run(row.id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== WebAuthn ====================
|
||||
|
||||
export async function startWebAuthnRegistration(userId: number): Promise<any> {
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(userId) as { id: number; username: string } | undefined;
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Get existing credentials to exclude
|
||||
const existingCredentials = db.prepare(`
|
||||
SELECT credential_id, transports FROM mfa_credentials
|
||||
WHERE user_id = ? AND type = 'webauthn' AND is_active = 1
|
||||
`).all(userId) as { credential_id: string; transports: string | null }[];
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: config.webauthn.rpName,
|
||||
rpID: config.webauthn.rpId,
|
||||
userID: new TextEncoder().encode(user.id.toString()),
|
||||
userName: user.username,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: existingCredentials.map(cred => ({
|
||||
id: cred.credential_id,
|
||||
transports: cred.transports ? JSON.parse(cred.transports) : undefined,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function completeWebAuthnRegistration(
|
||||
userId: number,
|
||||
response: any,
|
||||
expectedChallenge: string,
|
||||
name: string = 'Security Key'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const verification: VerifiedRegistrationResponse = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin: config.webauthn.origin,
|
||||
expectedRPID: config.webauthn.rpId,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
|
||||
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO mfa_credentials (user_id, type, name, credential_id, public_key, counter, transports, is_active)
|
||||
VALUES (?, 'webauthn', ?, ?, ?, ?, ?, 1)
|
||||
`).run(
|
||||
userId,
|
||||
name,
|
||||
credentialID,
|
||||
Buffer.from(credentialPublicKey).toString('base64'),
|
||||
counter,
|
||||
JSON.stringify([])
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startWebAuthnAuthentication(userId: number): Promise<any> {
|
||||
const db = getDb();
|
||||
|
||||
const credentials = db.prepare(`
|
||||
SELECT credential_id, transports FROM mfa_credentials
|
||||
WHERE user_id = ? AND type = 'webauthn' AND is_active = 1
|
||||
`).all(userId) as { credential_id: string; transports: string | null }[];
|
||||
|
||||
if (credentials.length === 0) {
|
||||
throw new Error('No WebAuthn credentials found');
|
||||
}
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.webauthn.rpId,
|
||||
allowCredentials: credentials.map(cred => ({
|
||||
id: cred.credential_id,
|
||||
transports: cred.transports ? JSON.parse(cred.transports) : undefined,
|
||||
})),
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnAuthentication(
|
||||
userId: number,
|
||||
response: any,
|
||||
expectedChallenge: string
|
||||
): Promise<boolean> {
|
||||
const db = getDb();
|
||||
|
||||
// Find the credential being used
|
||||
const credentialId = response.id;
|
||||
const row = db.prepare(`
|
||||
SELECT * FROM mfa_credentials
|
||||
WHERE user_id = ? AND type = 'webauthn' AND credential_id = ? AND is_active = 1
|
||||
`).get(userId, credentialId) as MfaCredentialRow | undefined;
|
||||
|
||||
if (!row || !row.public_key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const verification: VerifiedAuthenticationResponse = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin: config.webauthn.origin,
|
||||
expectedRPID: config.webauthn.rpId,
|
||||
authenticator: {
|
||||
credentialID: row.credential_id!,
|
||||
credentialPublicKey: Buffer.from(row.public_key, 'base64'),
|
||||
counter: row.counter,
|
||||
transports: row.transports ? JSON.parse(row.transports) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update counter and last used time
|
||||
db.prepare(`
|
||||
UPDATE mfa_credentials
|
||||
SET counter = ?, last_used_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(verification.authenticationInfo.newCounter, row.id);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete MFA credential
|
||||
export function deleteMfaCredential(userId: number, credentialId: number): boolean {
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
'DELETE FROM mfa_credentials WHERE id = ? AND user_id = ?'
|
||||
).run(credentialId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// Cancel pending TOTP setup
|
||||
export function cancelPendingTotp(userId: number): void {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
"DELETE FROM mfa_credentials WHERE user_id = ? AND type = 'totp' AND is_active = 0"
|
||||
).run(userId);
|
||||
}
|
||||
80
frontend/src/services/runpodService.ts
Normal file
80
frontend/src/services/runpodService.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { config } from '../config.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { GenerationRequest, RunPodJob, RunPodJobStatus } from '../types/index.js';
|
||||
|
||||
const { baseUrl, apiKey, endpointId } = config.runpod;
|
||||
|
||||
async function runpodFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const url = `${baseUrl}/${endpointId}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function submitJob(input: GenerationRequest): Promise<RunPodJob> {
|
||||
logger.info({ prompt: input.prompt?.substring(0, 50) }, 'Submitting job to RunPod');
|
||||
|
||||
const response = await runpodFetch('/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ input }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.error({ status: response.status, body: text }, 'RunPod API error');
|
||||
throw new Error(`RunPod API error: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
logger.info({ jobId: result.id, status: result.status }, 'Job submitted');
|
||||
|
||||
return result as RunPodJob;
|
||||
}
|
||||
|
||||
export async function getJobStatus(jobId: string): Promise<RunPodJobStatus> {
|
||||
const response = await runpodFetch(`/status/${jobId}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`RunPod API error: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<RunPodJobStatus>;
|
||||
}
|
||||
|
||||
export async function pollForCompletion(
|
||||
jobId: string,
|
||||
maxTimeoutMs: number = config.runpod.maxTimeoutMs,
|
||||
pollIntervalMs: number = config.runpod.pollIntervalMs
|
||||
): Promise<RunPodJobStatus> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxTimeoutMs) {
|
||||
const status = await getJobStatus(jobId);
|
||||
|
||||
if (status.status === 'COMPLETED' || status.status === 'FAILED') {
|
||||
return status;
|
||||
}
|
||||
|
||||
logger.debug({ jobId, status: status.status, elapsed: Date.now() - startTime }, 'Job still in progress');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
throw new Error(`Job ${jobId} timed out after ${maxTimeoutMs}ms`);
|
||||
}
|
||||
|
||||
export async function submitAndWait(input: GenerationRequest): Promise<RunPodJobStatus> {
|
||||
const job = await submitJob(input);
|
||||
return pollForCompletion(job.id);
|
||||
}
|
||||
152
frontend/src/services/sessionService.ts
Normal file
152
frontend/src/services/sessionService.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { getDb, type SessionRow } from '../db/index.js';
|
||||
import { generateSessionId } from '../utils/crypto.js';
|
||||
import type { Session } from '../types/index.js';
|
||||
import { Store } from 'express-session';
|
||||
|
||||
function rowToSession(row: SessionRow): Session {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
ipAddress: row.ip_address,
|
||||
userAgent: row.user_agent,
|
||||
mfaVerified: Boolean(row.mfa_verified),
|
||||
expiresAt: new Date(row.expires_at),
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSession(
|
||||
userId: number,
|
||||
expiresInMs: number,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Session {
|
||||
const db = getDb();
|
||||
const id = generateSessionId();
|
||||
const expiresAt = new Date(Date.now() + expiresInMs).toISOString();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(id, userId, ipAddress || null, userAgent || null, expiresAt);
|
||||
|
||||
const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as SessionRow;
|
||||
return rowToSession(row);
|
||||
}
|
||||
|
||||
export function getSession(id: string): Session | null {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as SessionRow | undefined;
|
||||
return row ? rowToSession(row) : null;
|
||||
}
|
||||
|
||||
export function updateSessionMfaVerified(id: string, verified: boolean): boolean {
|
||||
const db = getDb();
|
||||
const result = db.prepare('UPDATE sessions SET mfa_verified = ? WHERE id = ?').run(verified ? 1 : 0, id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function deleteSession(id: string): boolean {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function deleteUserSessions(userId: number): number {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
export function cleanExpiredSessions(): number {
|
||||
const db = getDb();
|
||||
const result = db.prepare("DELETE FROM sessions WHERE expires_at < datetime('now')").run();
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
// Express session store implementation
|
||||
export class SQLiteSessionStore extends Store {
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Clean up expired sessions every hour
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
cleanExpiredSessions();
|
||||
}, 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
get(sid: string, callback: (err: any, session?: any) => void): void {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`
|
||||
SELECT * FROM sessions WHERE id = ? AND expires_at > datetime('now')
|
||||
`).get(sid) as SessionRow | undefined;
|
||||
|
||||
if (!row) {
|
||||
return callback(null, null);
|
||||
}
|
||||
|
||||
const session = {
|
||||
cookie: {
|
||||
expires: new Date(row.expires_at),
|
||||
},
|
||||
userId: row.user_id,
|
||||
mfaRequired: false,
|
||||
mfaVerified: Boolean(row.mfa_verified),
|
||||
};
|
||||
|
||||
callback(null, session);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
}
|
||||
|
||||
set(sid: string, session: any, callback?: (err?: any) => void): void {
|
||||
try {
|
||||
const db = getDb();
|
||||
const expiresAt = session.cookie?.expires
|
||||
? new Date(session.cookie.expires).toISOString()
|
||||
: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO sessions (id, user_id, mfa_verified, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(sid, session.userId || 0, session.mfaVerified ? 1 : 0, expiresAt);
|
||||
|
||||
callback?.();
|
||||
} catch (error) {
|
||||
callback?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(sid: string, callback?: (err?: any) => void): void {
|
||||
try {
|
||||
deleteSession(sid);
|
||||
callback?.();
|
||||
} catch (error) {
|
||||
callback?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
touch(sid: string, session: any, callback?: (err?: any) => void): void {
|
||||
try {
|
||||
const db = getDb();
|
||||
const expiresAt = session.cookie?.expires
|
||||
? new Date(session.cookie.expires).toISOString()
|
||||
: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
db.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(expiresAt, sid);
|
||||
callback?.();
|
||||
} catch (error) {
|
||||
callback?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
178
frontend/src/services/userService.ts
Normal file
178
frontend/src/services/userService.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { getDb, type UserRow } from '../db/index.js';
|
||||
import { hashPassword, verifyPassword } from '../utils/crypto.js';
|
||||
import type { User, UserWithPassword } from '../types/index.js';
|
||||
|
||||
function rowToUser(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
isAdmin: Boolean(row.is_admin),
|
||||
isActive: Boolean(row.is_active),
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
lastLoginAt: row.last_login_at ? new Date(row.last_login_at) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToUserWithPassword(row: UserRow): UserWithPassword {
|
||||
return {
|
||||
...rowToUser(row),
|
||||
passwordHash: row.password_hash,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createUser(
|
||||
username: string,
|
||||
password: string,
|
||||
email?: string | null,
|
||||
isAdmin: boolean = false
|
||||
): Promise<User> {
|
||||
const db = getDb();
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (username, email, password_hash, is_admin)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(username, email || null, passwordHash, isAdmin ? 1 : 0);
|
||||
|
||||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid) as UserRow;
|
||||
return rowToUser(row);
|
||||
}
|
||||
|
||||
export function getUserById(id: number): User | null {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow | undefined;
|
||||
return row ? rowToUser(row) : null;
|
||||
}
|
||||
|
||||
export function getUserByUsername(username: string): User | null {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as UserRow | undefined;
|
||||
return row ? rowToUser(row) : null;
|
||||
}
|
||||
|
||||
export function getUserByUsernameWithPassword(username: string): UserWithPassword | null {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as UserRow | undefined;
|
||||
return row ? rowToUserWithPassword(row) : null;
|
||||
}
|
||||
|
||||
export function getAllUsers(): User[] {
|
||||
const db = getDb();
|
||||
const rows = db.prepare('SELECT * FROM users ORDER BY created_at DESC').all() as UserRow[];
|
||||
return rows.map(rowToUser);
|
||||
}
|
||||
|
||||
export function countUsers(): number {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
id: number,
|
||||
updates: {
|
||||
username?: string;
|
||||
email?: string | null;
|
||||
isAdmin?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
): Promise<User | null> {
|
||||
const db = getDb();
|
||||
|
||||
const setParts: string[] = [];
|
||||
const values: (string | number | null)[] = [];
|
||||
|
||||
if (updates.username !== undefined) {
|
||||
setParts.push('username = ?');
|
||||
values.push(updates.username);
|
||||
}
|
||||
if (updates.email !== undefined) {
|
||||
setParts.push('email = ?');
|
||||
values.push(updates.email);
|
||||
}
|
||||
if (updates.isAdmin !== undefined) {
|
||||
setParts.push('is_admin = ?');
|
||||
values.push(updates.isAdmin ? 1 : 0);
|
||||
}
|
||||
if (updates.isActive !== undefined) {
|
||||
setParts.push('is_active = ?');
|
||||
values.push(updates.isActive ? 1 : 0);
|
||||
}
|
||||
|
||||
if (setParts.length === 0) {
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
setParts.push("updated_at = datetime('now')");
|
||||
values.push(id);
|
||||
|
||||
db.prepare(`UPDATE users SET ${setParts.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
export async function updatePassword(id: number, newPassword: string): Promise<boolean> {
|
||||
const db = getDb();
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?
|
||||
`).run(passwordHash, id);
|
||||
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export async function verifyUserPassword(username: string, password: string): Promise<User | null> {
|
||||
const user = getUserByUsernameWithPassword(username);
|
||||
if (!user || !user.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(user.passwordHash, password);
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE users SET last_login_at = datetime('now') WHERE id = ?").run(user.id);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
lastLoginAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteUser(id: number): boolean {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function usernameExists(username: string, excludeId?: number): boolean {
|
||||
const db = getDb();
|
||||
if (excludeId) {
|
||||
const row = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, excludeId);
|
||||
return !!row;
|
||||
}
|
||||
const row = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function emailExists(email: string, excludeId?: number): boolean {
|
||||
const db = getDb();
|
||||
if (excludeId) {
|
||||
const row = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, excludeId);
|
||||
return !!row;
|
||||
}
|
||||
const row = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
return !!row;
|
||||
}
|
||||
120
frontend/src/types/index.ts
Normal file
120
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
// User types
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastLoginAt: Date | null;
|
||||
}
|
||||
|
||||
export interface UserWithPassword extends User {
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
// Session types
|
||||
export interface Session {
|
||||
id: string;
|
||||
userId: number;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
mfaVerified: boolean;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// MFA types
|
||||
export interface MfaCredential {
|
||||
id: number;
|
||||
userId: number;
|
||||
type: 'totp' | 'webauthn';
|
||||
name: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
lastUsedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface TotpCredential extends MfaCredential {
|
||||
type: 'totp';
|
||||
totpSecret: string;
|
||||
}
|
||||
|
||||
export interface WebAuthnCredential extends MfaCredential {
|
||||
type: 'webauthn';
|
||||
credentialId: string;
|
||||
publicKey: string;
|
||||
counter: number;
|
||||
transports: string[];
|
||||
}
|
||||
|
||||
// Content types
|
||||
export interface GeneratedContent {
|
||||
id: number;
|
||||
userId: number;
|
||||
filename: string;
|
||||
originalFilename: string | null;
|
||||
prompt: string | null;
|
||||
negativePrompt: string | null;
|
||||
resolution: number | null;
|
||||
steps: number | null;
|
||||
splitStep: number | null;
|
||||
runpodJobId: string | null;
|
||||
fileSize: number | null;
|
||||
durationSeconds: number | null;
|
||||
mimeType: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Generation request types
|
||||
export interface GenerationRequest {
|
||||
image: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
resolution?: number;
|
||||
steps?: number;
|
||||
splitStep?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface RunPodJob {
|
||||
id: string;
|
||||
status: 'IN_QUEUE' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED';
|
||||
}
|
||||
|
||||
export interface RunPodJobStatus extends RunPodJob {
|
||||
output?: {
|
||||
status: string;
|
||||
prompt_id: string;
|
||||
outputs: Array<{
|
||||
type: string;
|
||||
filename: string;
|
||||
data?: string;
|
||||
path?: string;
|
||||
size?: number;
|
||||
}>;
|
||||
error?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Express session extension
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId?: number;
|
||||
isAdmin?: boolean;
|
||||
mfaRequired?: boolean;
|
||||
mfaVerified?: boolean;
|
||||
webauthnChallenge?: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Extended Request type
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: User;
|
||||
}
|
||||
81
frontend/src/utils/crypto.ts
Normal file
81
frontend/src/utils/crypto.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { randomBytes, createCipheriv, createDecipheriv, scrypt } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
import argon2 from 'argon2';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const scryptAsync = promisify(scrypt);
|
||||
|
||||
// Password hashing with Argon2id
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536,
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
return await argon2.verify(hash, password);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate cryptographically secure random strings
|
||||
export function generateSecureToken(length: number = 32): string {
|
||||
return randomBytes(length).toString('hex');
|
||||
}
|
||||
|
||||
export function generateSessionId(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
// AES-256-GCM encryption for TOTP secrets
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
async function deriveKey(): Promise<Buffer> {
|
||||
const keyHex = config.encryptionKey;
|
||||
if (keyHex.length !== 64) {
|
||||
throw new Error('ENCRYPTION_KEY must be 64 hex characters (32 bytes)');
|
||||
}
|
||||
return Buffer.from(keyHex, 'hex');
|
||||
}
|
||||
|
||||
export async function encrypt(plaintext: string): Promise<string> {
|
||||
const key = await deriveKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Format: iv:authTag:ciphertext (all hex)
|
||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||
}
|
||||
|
||||
export async function decrypt(ciphertext: string): Promise<string> {
|
||||
const key = await deriveKey();
|
||||
const parts = ciphertext.split(':');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid ciphertext format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const authTag = Buffer.from(parts[1], 'hex');
|
||||
const encrypted = parts[2];
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
18
frontend/src/utils/logger.ts
Normal file
18
frontend/src/utils/logger.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import pino from 'pino';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export const logger = pino({
|
||||
level: config.isProduction ? 'info' : 'debug',
|
||||
transport: config.isProduction
|
||||
? undefined
|
||||
: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
},
|
||||
redact: {
|
||||
paths: ['password', 'passwordHash', 'totpSecret', 'apiKey', 'sessionSecret'],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
});
|
||||
83
frontend/src/utils/validators.ts
Normal file
83
frontend/src/utils/validators.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// User validation
|
||||
export const usernameSchema = z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(32, 'Username must be at most 32 characters')
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and hyphens');
|
||||
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(12, 'Password must be at least 12 characters')
|
||||
.max(128, 'Password must be at most 128 characters');
|
||||
|
||||
export const emailSchema = z
|
||||
.string()
|
||||
.email('Invalid email address')
|
||||
.optional()
|
||||
.nullable();
|
||||
|
||||
// Auth schemas
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
export const totpVerifySchema = z.object({
|
||||
code: z.string().length(6, 'TOTP code must be 6 digits').regex(/^\d+$/, 'TOTP code must be numeric'),
|
||||
});
|
||||
|
||||
// User management schemas
|
||||
export const createUserSchema = z.object({
|
||||
username: usernameSchema,
|
||||
password: passwordSchema,
|
||||
email: emailSchema,
|
||||
isAdmin: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
username: usernameSchema.optional(),
|
||||
email: emailSchema,
|
||||
isAdmin: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, 'Current password is required'),
|
||||
newPassword: passwordSchema,
|
||||
});
|
||||
|
||||
// Content schemas
|
||||
export const contentListSchema = z.object({
|
||||
page: z.coerce.number().int().positive().optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
|
||||
status: z.enum(['pending', 'processing', 'completed', 'failed']).optional(),
|
||||
userId: z.coerce.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
// Generation schemas
|
||||
export const generationRequestSchema = z.object({
|
||||
image: z.string().min(1, 'Image is required'),
|
||||
prompt: z.string().min(1, 'Prompt is required').max(2000, 'Prompt is too long'),
|
||||
negativePrompt: z.string().max(2000).optional().default(''),
|
||||
resolution: z.number().int().min(480).max(1080).optional().default(720),
|
||||
steps: z.number().int().min(1).max(50).optional().default(8),
|
||||
splitStep: z.number().int().min(1).max(20).optional().default(4),
|
||||
timeout: z.number().int().min(60).max(600).optional().default(600),
|
||||
});
|
||||
|
||||
// MFA schemas
|
||||
export const mfaNameSchema = z.object({
|
||||
name: z.string().min(1).max(64).optional().default('Default'),
|
||||
});
|
||||
|
||||
// Validation helper
|
||||
export function validateRequest<T>(schema: z.ZodSchema<T>, data: unknown): { success: true; data: T } | { success: false; error: string } {
|
||||
const result = schema.safeParse(data);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
const errorMessage = result.error.errors.map(e => e.message).join(', ');
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
Reference in New Issue
Block a user