Add frontend service with auth, MFA, and content management
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:
Debian
2026-01-07 04:57:08 +00:00
parent 8a5610a1e4
commit 890543fb77
33 changed files with 6851 additions and 0 deletions

90
frontend/src/config.ts Normal file
View 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
View 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;
}

View 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
View 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();

View 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;
}

View 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);
};
}

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;
}

View 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;
}
}

View 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);
}

View 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);
}

View 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;
}
}
}

View 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
View 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;
}

View 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;
}

View 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]',
},
});

View 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 };
}