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:
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';
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user