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

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