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

31
frontend/.env.example Normal file
View File

@@ -0,0 +1,31 @@
# Server Configuration
NODE_ENV=production
PORT=3000
DATA_DIR=/app/data
# Session Configuration
SESSION_SECRET=change-this-to-a-secure-random-string-at-least-32-chars
# Initial Admin User (only used on first startup if no users exist)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-this-secure-password
ADMIN_EMAIL=admin@example.com
# RunPod API Configuration
RUNPOD_API_KEY=your-runpod-api-key
RUNPOD_ENDPOINT_ID=your-endpoint-id
# WebAuthn Configuration
WEBAUTHN_RP_ID=localhost
WEBAUTHN_RP_NAME=ComfyUI Video Generator
WEBAUTHN_ORIGIN=http://localhost:3000
# Security
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
TRUST_PROXY=true
# Rate Limiting
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100
LOGIN_RATE_LIMIT_MAX=5
LOGIN_RATE_LIMIT_WINDOW_MS=900000

View File

@@ -0,0 +1,49 @@
name: Build and Push Frontend Docker Image
on:
push:
branches:
- main
paths:
- 'frontend/**'
- '.gitea/workflows/build-frontend.yaml'
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: gitea.voyager.sh
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: gitea.voyager.sh/nick/comfyui-frontend
tags: |
type=sha,prefix=
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./frontend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

6
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
data/
.env
*.log
.DS_Store

64
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,64 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
# Install build dependencies for native modules (argon2, better-sqlite3)
RUN apk add --no-cache python3 make g++ sqlite-dev
# Copy package files
COPY package*.json ./
# Install all dependencies (including dev)
RUN npm ci
# Copy source
COPY tsconfig.json ./
COPY src/ ./src/
# Build TypeScript
RUN npm run build
# Prune dev dependencies
RUN npm prune --production
# Production stage
FROM node:22-alpine
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache sqlite-libs
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Copy built files
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
# Copy public files
COPY public/ ./public/
# Copy database migrations
COPY src/db/migrations/ ./dist/db/migrations/
# Create data directory
RUN mkdir -p /app/data/content && chown -R appuser:appgroup /app/data
# Switch to non-root user
USER appuser
# Environment
ENV NODE_ENV=production
ENV DATA_DIR=/app/data
ENV PORT=3000
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,32 @@
version: '3.8'
services:
frontend:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- SESSION_SECRET=${SESSION_SECRET:-dev-session-secret-change-in-production}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme123456}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@localhost}
- RUNPOD_API_KEY=${RUNPOD_API_KEY:?RUNPOD_API_KEY is required}
- RUNPOD_ENDPOINT_ID=${RUNPOD_ENDPOINT_ID:?RUNPOD_ENDPOINT_ID is required}
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
- WEBAUTHN_RP_NAME=${WEBAUTHN_RP_NAME:-ComfyUI Video Generator}
- WEBAUTHN_ORIGIN=${WEBAUTHN_ORIGIN:-http://localhost:3000}
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-0000000000000000000000000000000000000000000000000000000000000000}
- TRUST_PROXY=false
volumes:
- frontend-data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
frontend-data:

2241
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "comfyui-frontend",
"version": "1.0.0",
"description": "Frontend service for ComfyUI image-to-video generation",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@simplewebauthn/server": "^10.0.1",
"argon2": "^0.41.1",
"better-sqlite3": "^11.6.0",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-session": "^1.18.1",
"helmet": "^8.0.0",
"otpauth": "^9.3.5",
"pino": "^9.6.0",
"pino-http": "^10.4.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.1",
"@types/node": "^22.10.5",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
},
"engines": {
"node": ">=20.0.0"
}
}

View File

@@ -0,0 +1,624 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #667eea;
--primary-dark: #5a6fd6;
--secondary: #764ba2;
--success: #28a745;
--danger: #dc3545;
--warning: #ffc107;
--gray-100: #f8f9fa;
--gray-200: #e9ecef;
--gray-300: #dee2e6;
--gray-400: #ced4da;
--gray-500: #adb5bd;
--gray-600: #6c757d;
--gray-700: #495057;
--gray-800: #343a40;
--gray-900: #212529;
--radius: 8px;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.15);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--gray-100);
color: var(--gray-800);
min-height: 100vh;
}
.hidden { display: none !important; }
/* Auth Pages */
.auth-container {
max-width: 400px;
margin: 100px auto;
padding: 40px;
background: white;
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.auth-header {
text-align: center;
margin-bottom: 30px;
}
.auth-header h1 {
font-size: 24px;
color: var(--gray-800);
margin-bottom: 8px;
}
.auth-header p {
color: var(--gray-600);
}
.auth-form .form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: var(--gray-700);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius);
font-size: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
/* Buttons */
.btn {
padding: 12px 20px;
border: none;
border-radius: var(--radius);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--secondary));
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: var(--gray-600);
color: white;
}
.btn-secondary:hover {
background: var(--gray-700);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-link {
background: none;
color: var(--primary);
padding: 8px;
}
.btn-link:hover {
text-decoration: underline;
}
.btn-block {
width: 100%;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Error/Status Messages */
.error-message {
color: var(--danger);
padding: 10px;
text-align: center;
font-size: 14px;
}
.status-message {
padding: 15px;
border-radius: var(--radius);
margin-bottom: 15px;
}
.status-message.info {
background: #d1ecf1;
color: #0c5460;
}
.status-message.success {
background: #d4edda;
color: #155724;
}
.status-message.error {
background: #f8d7da;
color: #721c24;
}
/* MFA */
.mfa-section {
margin-bottom: 20px;
}
.mfa-section + .mfa-section {
padding-top: 20px;
border-top: 1px solid var(--gray-200);
}
/* Navbar */
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 30px;
background: white;
box-shadow: var(--shadow);
}
.navbar-brand {
font-size: 20px;
font-weight: 700;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.navbar-menu {
display: flex;
align-items: center;
gap: 20px;
}
.nav-link {
color: var(--gray-600);
text-decoration: none;
padding: 8px 12px;
border-radius: var(--radius);
transition: all 0.2s;
}
.nav-link:hover,
.nav-link.active {
color: var(--primary);
background: var(--gray-100);
}
.navbar-user {
display: flex;
align-items: center;
gap: 12px;
padding-left: 20px;
border-left: 1px solid var(--gray-200);
}
#current-user {
font-weight: 500;
color: var(--gray-700);
}
/* Main Content */
.main-content {
padding: 30px;
max-width: 1400px;
margin: 0 auto;
}
.content-section {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-header h2 {
font-size: 24px;
color: var(--gray-800);
}
/* Cards */
.card {
background: white;
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
}
.card h2 {
font-size: 16px;
color: var(--gray-700);
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid var(--primary);
}
.section-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.section-grid .full-width {
grid-column: 1 / -1;
}
/* File Upload */
.file-upload {
position: relative;
}
.file-upload input[type="file"] {
display: none;
}
.file-upload-label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
border: 2px dashed var(--gray-300);
border-radius: var(--radius);
cursor: pointer;
transition: all 0.2s;
}
.file-upload-label:hover {
border-color: var(--primary);
background: var(--gray-100);
}
.file-upload.has-file .file-upload-label {
display: none;
}
.upload-icon {
font-size: 48px;
color: var(--gray-400);
margin-bottom: 10px;
}
.upload-text {
color: var(--gray-600);
}
.preview-image {
max-width: 100%;
max-height: 300px;
border-radius: var(--radius);
object-fit: contain;
}
/* Output */
.output-video {
width: 100%;
max-height: 500px;
border-radius: var(--radius);
background: var(--gray-900);
}
/* Gallery */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.gallery-item {
background: white;
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
transition: transform 0.2s;
}
.gallery-item:hover {
transform: translateY(-4px);
}
.gallery-item-media {
position: relative;
aspect-ratio: 16/9;
background: var(--gray-900);
}
.gallery-item-media video {
width: 100%;
height: 100%;
object-fit: cover;
}
.gallery-item-status {
position: absolute;
top: 10px;
right: 10px;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.gallery-item-status.completed { background: var(--success); color: white; }
.gallery-item-status.processing { background: var(--warning); color: var(--gray-900); }
.gallery-item-status.pending { background: var(--gray-500); color: white; }
.gallery-item-status.failed { background: var(--danger); color: white; }
.gallery-item-info {
padding: 15px;
}
.gallery-item-prompt {
font-size: 13px;
color: var(--gray-700);
margin-bottom: 10px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.gallery-item-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--gray-500);
}
.gallery-item-actions {
display: flex;
gap: 8px;
}
/* Admin */
.admin-tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.tab-btn {
padding: 10px 20px;
border: none;
background: var(--gray-200);
border-radius: var(--radius);
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.tab-btn.active {
background: var(--primary);
color: white;
}
.admin-tab {
animation: fadeIn 0.3s ease;
}
.users-list {
background: white;
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid var(--gray-200);
}
.user-item:last-child {
border-bottom: none;
}
.user-info h4 {
font-size: 14px;
margin-bottom: 4px;
}
.user-info p {
font-size: 12px;
color: var(--gray-500);
}
.user-badges {
display: flex;
gap: 8px;
margin-top: 4px;
}
.badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.badge-admin { background: var(--primary); color: white; }
.badge-inactive { background: var(--danger); color: white; }
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: var(--radius);
padding: 30px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-header h3 {
font-size: 18px;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--gray-500);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--gray-200);
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 20px;
}
.pagination button {
padding: 8px 12px;
border: 1px solid var(--gray-300);
background: white;
border-radius: var(--radius);
cursor: pointer;
}
.pagination button.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Filter */
.filter-group select {
padding: 8px 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius);
}
/* Loading Spinner */
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--gray-200);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 768px) {
.section-grid {
grid-template-columns: 1fr;
}
.navbar {
flex-direction: column;
gap: 15px;
}
.navbar-menu {
flex-wrap: wrap;
justify-content: center;
}
.form-row {
grid-template-columns: 1fr;
}
}

181
frontend/public/index.html Normal file
View File

@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ComfyUI Video Generator</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div id="app">
<!-- Login Page -->
<div id="login-page" class="page">
<div class="auth-container">
<div class="auth-header">
<h1>ComfyUI Video Generator</h1>
<p>Sign in to continue</p>
</div>
<form id="login-form" class="auth-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
</form>
<div id="login-error" class="error-message"></div>
</div>
</div>
<!-- MFA Page -->
<div id="mfa-page" class="page hidden">
<div class="auth-container">
<div class="auth-header">
<h1>Two-Factor Authentication</h1>
<p>Enter your verification code</p>
</div>
<div id="mfa-options">
<div id="totp-section" class="mfa-section hidden">
<form id="totp-form" class="auth-form">
<div class="form-group">
<label for="totp-code">Authenticator Code</label>
<input type="text" id="totp-code" name="code" pattern="[0-9]{6}" maxlength="6" required autocomplete="one-time-code" inputmode="numeric">
</div>
<button type="submit" class="btn btn-primary btn-block">Verify</button>
</form>
</div>
<div id="webauthn-section" class="mfa-section hidden">
<button id="webauthn-btn" class="btn btn-secondary btn-block">Use Security Key</button>
</div>
</div>
<div id="mfa-error" class="error-message"></div>
<button id="mfa-back" class="btn btn-link">Back to Login</button>
</div>
</div>
<!-- Main App -->
<div id="main-page" class="page hidden">
<nav class="navbar">
<div class="navbar-brand">ComfyUI Video Generator</div>
<div class="navbar-menu">
<a href="#" class="nav-link active" data-page="generate">Generate</a>
<a href="#" class="nav-link" data-page="gallery">My Videos</a>
<a href="#" class="nav-link admin-only hidden" data-page="admin">Admin</a>
<div class="navbar-user">
<span id="current-user"></span>
<button id="logout-btn" class="btn btn-sm">Logout</button>
</div>
</div>
</nav>
<main class="main-content">
<!-- Generate Section -->
<section id="generate-section" class="content-section">
<div class="section-grid">
<div class="card">
<h2>Input Image</h2>
<div class="file-upload" id="image-upload-area">
<input type="file" id="image-input" accept="image/*">
<label for="image-input" class="file-upload-label">
<span class="upload-icon">+</span>
<span class="upload-text">Click or drag to upload</span>
</label>
<img id="preview-image" class="preview-image hidden" alt="Preview">
</div>
</div>
<div class="card">
<h2>Generation Settings</h2>
<form id="generate-form">
<div class="form-group">
<label for="prompt">Prompt</label>
<textarea id="prompt" name="prompt" rows="3" required placeholder="Describe the motion you want..."></textarea>
</div>
<div class="form-group">
<label for="negative-prompt">Negative Prompt</label>
<textarea id="negative-prompt" name="negativePrompt" rows="2" placeholder="What to avoid...">blurry, low quality, distorted</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="resolution">Resolution</label>
<select id="resolution" name="resolution">
<option value="480">480p</option>
<option value="720" selected>720p</option>
<option value="1080">1080p</option>
</select>
</div>
<div class="form-group">
<label for="steps">Steps</label>
<input type="number" id="steps" name="steps" value="8" min="1" max="50">
</div>
</div>
<button type="submit" class="btn btn-primary btn-block" id="generate-btn">Generate Video</button>
</form>
</div>
<div class="card full-width">
<h2>Output</h2>
<div id="output-container">
<div id="generation-status" class="status-message hidden"></div>
<video id="output-video" class="output-video hidden" controls loop></video>
</div>
</div>
</div>
</section>
<!-- Gallery Section -->
<section id="gallery-section" class="content-section hidden">
<div class="section-header">
<h2>My Videos</h2>
<div class="filter-group">
<select id="status-filter">
<option value="">All Status</option>
<option value="completed">Completed</option>
<option value="processing">Processing</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
<div id="gallery-grid" class="gallery-grid"></div>
<div id="gallery-pagination" class="pagination"></div>
</section>
<!-- Admin Section -->
<section id="admin-section" class="content-section hidden">
<div class="admin-tabs">
<button class="tab-btn active" data-tab="users">Users</button>
<button class="tab-btn" data-tab="all-content">All Content</button>
</div>
<div id="users-tab" class="admin-tab">
<div class="section-header">
<h2>User Management</h2>
<button id="add-user-btn" class="btn btn-primary">Add User</button>
</div>
<div id="users-list" class="users-list"></div>
</div>
<div id="all-content-tab" class="admin-tab hidden">
<div class="section-header">
<h2>All Content</h2>
</div>
<div id="admin-gallery-grid" class="gallery-grid"></div>
<div id="admin-gallery-pagination" class="pagination"></div>
</div>
</section>
</main>
</div>
<!-- Modals -->
<div id="modal-overlay" class="modal-overlay hidden">
<div id="modal-content" class="modal-content"></div>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>

629
frontend/public/js/app.js Normal file
View File

@@ -0,0 +1,629 @@
// State
let currentUser = null;
let mfaTypes = [];
let base64Image = '';
let currentPage = 1;
let adminCurrentPage = 1;
// DOM Elements
const pages = {
login: document.getElementById('login-page'),
mfa: document.getElementById('mfa-page'),
main: document.getElementById('main-page'),
};
const sections = {
generate: document.getElementById('generate-section'),
gallery: document.getElementById('gallery-section'),
admin: document.getElementById('admin-section'),
};
// API Helper
async function api(path, options = {}) {
const response = await fetch(`/api${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Request failed');
}
return data;
}
// Page Navigation
function showPage(pageName) {
Object.values(pages).forEach(p => p.classList.add('hidden'));
pages[pageName]?.classList.remove('hidden');
}
function showSection(sectionName) {
Object.values(sections).forEach(s => s.classList.add('hidden'));
sections[sectionName]?.classList.remove('hidden');
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.page === sectionName);
});
if (sectionName === 'gallery') loadGallery();
if (sectionName === 'admin') loadUsers();
}
// Auth
async function checkAuth() {
try {
const data = await api('/auth/me');
currentUser = data.user;
showMainApp();
} catch {
showPage('login');
}
}
function showMainApp() {
showPage('main');
document.getElementById('current-user').textContent = currentUser.username;
if (currentUser.isAdmin) {
document.querySelectorAll('.admin-only').forEach(el => el.classList.remove('hidden'));
}
showSection('generate');
}
// Login
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('login-error');
errorEl.textContent = '';
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const data = await api('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
if (data.requiresMfa) {
mfaTypes = data.mfaTypes;
showMfaPage();
} else {
currentUser = data.user;
showMainApp();
}
} catch (error) {
errorEl.textContent = error.message;
}
});
function showMfaPage() {
showPage('mfa');
const totpSection = document.getElementById('totp-section');
const webauthnSection = document.getElementById('webauthn-section');
totpSection.classList.toggle('hidden', !mfaTypes.includes('totp'));
webauthnSection.classList.toggle('hidden', !mfaTypes.includes('webauthn'));
}
// TOTP Verification
document.getElementById('totp-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('mfa-error');
errorEl.textContent = '';
const code = document.getElementById('totp-code').value;
try {
const data = await api('/auth/mfa/totp', {
method: 'POST',
body: JSON.stringify({ code }),
});
currentUser = data.user;
showMainApp();
} catch (error) {
errorEl.textContent = error.message;
}
});
// WebAuthn
document.getElementById('webauthn-btn').addEventListener('click', async () => {
const errorEl = document.getElementById('mfa-error');
errorEl.textContent = '';
try {
const options = await api('/auth/mfa/webauthn/challenge', { method: 'POST' });
const credential = await navigator.credentials.get({
publicKey: {
...options,
challenge: base64UrlToBuffer(options.challenge),
allowCredentials: options.allowCredentials?.map(c => ({
...c,
id: base64UrlToBuffer(c.id),
})),
},
});
const response = {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(credential.response.clientDataJSON),
authenticatorData: bufferToBase64Url(credential.response.authenticatorData),
signature: bufferToBase64Url(credential.response.signature),
},
};
const data = await api('/auth/mfa/webauthn/verify', {
method: 'POST',
body: JSON.stringify(response),
});
currentUser = data.user;
showMainApp();
} catch (error) {
errorEl.textContent = error.message || 'WebAuthn verification failed';
}
});
// Back to login from MFA
document.getElementById('mfa-back').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' });
showPage('login');
});
// Logout
document.getElementById('logout-btn').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' });
currentUser = null;
showPage('login');
});
// Navigation
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
showSection(link.dataset.page);
});
});
// Image Upload
const imageInput = document.getElementById('image-input');
const imageUploadArea = document.getElementById('image-upload-area');
const previewImage = document.getElementById('preview-image');
imageInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
previewImage.src = event.target.result;
previewImage.classList.remove('hidden');
imageUploadArea.classList.add('has-file');
base64Image = event.target.result.split(',')[1];
};
reader.readAsDataURL(file);
}
});
// Generation
document.getElementById('generate-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!base64Image) {
showStatus('Please upload an image first', 'error');
return;
}
const btn = document.getElementById('generate-btn');
btn.disabled = true;
btn.textContent = 'Generating...';
const statusEl = document.getElementById('generation-status');
const videoEl = document.getElementById('output-video');
statusEl.className = 'status-message info';
statusEl.textContent = 'Submitting job...';
statusEl.classList.remove('hidden');
videoEl.classList.add('hidden');
try {
const formData = {
image: base64Image,
prompt: document.getElementById('prompt').value,
negativePrompt: document.getElementById('negative-prompt').value,
resolution: parseInt(document.getElementById('resolution').value),
steps: parseInt(document.getElementById('steps').value),
};
const submitData = await api('/generate', {
method: 'POST',
body: JSON.stringify(formData),
});
const { jobId, contentId } = submitData;
statusEl.textContent = `Job submitted. ID: ${jobId}. Waiting for completion...`;
// Poll for completion
await pollJob(jobId, contentId, statusEl, videoEl);
} catch (error) {
showStatus(error.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Generate Video';
}
});
async function pollJob(jobId, contentId, statusEl, videoEl) {
const startTime = Date.now();
const maxTime = 10 * 60 * 1000; // 10 minutes
while (Date.now() - startTime < maxTime) {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
statusEl.textContent = `Generating... (${elapsed}s elapsed)`;
try {
const status = await api(`/generate/${jobId}/status`);
if (status.status === 'COMPLETED') {
statusEl.className = 'status-message success';
statusEl.textContent = 'Generation complete!';
// Load video
videoEl.src = `/api/content/${contentId}/stream`;
videoEl.classList.remove('hidden');
videoEl.play();
return;
}
if (status.status === 'FAILED') {
throw new Error(status.error || 'Generation failed');
}
await new Promise(r => setTimeout(r, 5000));
} catch (error) {
showStatus(error.message, 'error');
return;
}
}
showStatus('Generation timed out', 'error');
}
function showStatus(message, type) {
const statusEl = document.getElementById('generation-status');
statusEl.className = `status-message ${type}`;
statusEl.textContent = message;
statusEl.classList.remove('hidden');
}
// Gallery
async function loadGallery(page = 1) {
currentPage = page;
const grid = document.getElementById('gallery-grid');
const pagination = document.getElementById('gallery-pagination');
const status = document.getElementById('status-filter').value;
grid.innerHTML = '<div class="spinner"></div>';
try {
const params = new URLSearchParams({ page, limit: 12 });
if (status) params.append('status', status);
const data = await api(`/content?${params}`);
renderGallery(grid, data.content);
renderPagination(pagination, data.pagination, loadGallery);
} catch (error) {
grid.innerHTML = `<p class="error-message">${error.message}</p>`;
}
}
document.getElementById('status-filter').addEventListener('change', () => loadGallery(1));
function renderGallery(container, items) {
if (items.length === 0) {
container.innerHTML = '<p style="text-align:center;color:var(--gray-500);grid-column:1/-1;">No content found</p>';
return;
}
container.innerHTML = items.map(item => `
<div class="gallery-item">
<div class="gallery-item-media">
${item.status === 'completed'
? `<video src="/api/content/${item.id}/stream" muted loop onmouseenter="this.play()" onmouseleave="this.pause()"></video>`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--gray-500)">' + item.status + '</div>'
}
<span class="gallery-item-status ${item.status}">${item.status}</span>
</div>
<div class="gallery-item-info">
<p class="gallery-item-prompt">${escapeHtml(item.prompt || 'No prompt')}</p>
<div class="gallery-item-meta">
<span>${formatDate(item.createdAt)}</span>
<div class="gallery-item-actions">
${item.status === 'completed' ? `<a href="/api/content/${item.id}/download" class="btn btn-sm btn-secondary">Download</a>` : ''}
<button class="btn btn-sm btn-danger" onclick="deleteContent(${item.id})">Delete</button>
</div>
</div>
</div>
</div>
`).join('');
}
async function deleteContent(id) {
if (!confirm('Are you sure you want to delete this content?')) return;
try {
await api(`/content/${id}`, { method: 'DELETE' });
loadGallery(currentPage);
} catch (error) {
alert(error.message);
}
}
// Admin
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.admin-tab').forEach(t => t.classList.add('hidden'));
btn.classList.add('active');
document.getElementById(`${btn.dataset.tab}-tab`).classList.remove('hidden');
if (btn.dataset.tab === 'all-content') loadAdminGallery();
});
});
async function loadUsers() {
const container = document.getElementById('users-list');
container.innerHTML = '<div class="spinner"></div>';
try {
const data = await api('/users');
container.innerHTML = data.users.map(user => `
<div class="user-item">
<div class="user-info">
<h4>${escapeHtml(user.username)}</h4>
<p>${escapeHtml(user.email || 'No email')}</p>
<div class="user-badges">
${user.isAdmin ? '<span class="badge badge-admin">Admin</span>' : ''}
${!user.isActive ? '<span class="badge badge-inactive">Inactive</span>' : ''}
</div>
</div>
<div class="user-actions">
<button class="btn btn-sm btn-secondary" onclick="editUser(${user.id})">Edit</button>
${user.id !== currentUser.id ? `<button class="btn btn-sm btn-danger" onclick="deleteUser(${user.id})">Delete</button>` : ''}
</div>
</div>
`).join('');
} catch (error) {
container.innerHTML = `<p class="error-message">${error.message}</p>`;
}
}
document.getElementById('add-user-btn').addEventListener('click', () => {
showModal(`
<div class="modal-header">
<h3>Add User</h3>
<button class="modal-close" onclick="hideModal()">&times;</button>
</div>
<form id="add-user-form">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" required minlength="3">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required minlength="12">
</div>
<div class="form-group">
<label>Email (optional)</label>
<input type="email" name="email">
</div>
<div class="form-group">
<label><input type="checkbox" name="isAdmin"> Admin</label>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="hideModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
`);
document.getElementById('add-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
try {
await api('/users', {
method: 'POST',
body: JSON.stringify({
username: form.username.value,
password: form.password.value,
email: form.email.value || null,
isAdmin: form.isAdmin.checked,
}),
});
hideModal();
loadUsers();
} catch (error) {
alert(error.message);
}
});
});
async function editUser(id) {
try {
const data = await api(`/users/${id}`);
const user = data.user;
showModal(`
<div class="modal-header">
<h3>Edit User: ${escapeHtml(user.username)}</h3>
<button class="modal-close" onclick="hideModal()">&times;</button>
</div>
<form id="edit-user-form">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" value="${escapeHtml(user.username)}" required>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" value="${escapeHtml(user.email || '')}">
</div>
<div class="form-group">
<label><input type="checkbox" name="isAdmin" ${user.isAdmin ? 'checked' : ''}> Admin</label>
</div>
<div class="form-group">
<label><input type="checkbox" name="isActive" ${user.isActive ? 'checked' : ''}> Active</label>
</div>
<hr>
<div class="form-group">
<label>Reset Password (leave blank to keep)</label>
<input type="password" name="newPassword" minlength="12" placeholder="New password">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="hideModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
`);
document.getElementById('edit-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
try {
await api(`/users/${id}`, {
method: 'PUT',
body: JSON.stringify({
username: form.username.value,
email: form.email.value || null,
isAdmin: form.isAdmin.checked,
isActive: form.isActive.checked,
}),
});
if (form.newPassword.value) {
await api(`/users/${id}/reset-password`, {
method: 'POST',
body: JSON.stringify({ newPassword: form.newPassword.value }),
});
}
hideModal();
loadUsers();
} catch (error) {
alert(error.message);
}
});
} catch (error) {
alert(error.message);
}
}
async function deleteUser(id) {
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await api(`/users/${id}`, { method: 'DELETE' });
loadUsers();
} catch (error) {
alert(error.message);
}
}
async function loadAdminGallery(page = 1) {
adminCurrentPage = page;
const grid = document.getElementById('admin-gallery-grid');
const pagination = document.getElementById('admin-gallery-pagination');
grid.innerHTML = '<div class="spinner"></div>';
try {
const data = await api(`/content?page=${page}&limit=12`);
renderGallery(grid, data.content);
renderPagination(pagination, data.pagination, loadAdminGallery);
} catch (error) {
grid.innerHTML = `<p class="error-message">${error.message}</p>`;
}
}
// Modal
function showModal(content) {
document.getElementById('modal-content').innerHTML = content;
document.getElementById('modal-overlay').classList.remove('hidden');
}
function hideModal() {
document.getElementById('modal-overlay').classList.add('hidden');
}
document.getElementById('modal-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) hideModal();
});
// Pagination
function renderPagination(container, pagination, loadFn) {
const { page, totalPages } = pagination;
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
let html = '';
html += `<button ${page === 1 ? 'disabled' : ''} onclick="(${loadFn.name})(${page - 1})">Prev</button>`;
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) {
html += `<button class="${i === page ? 'active' : ''}" onclick="(${loadFn.name})(${i})">${i}</button>`;
} else if (i === page - 2 || i === page + 2) {
html += '<span>...</span>';
}
}
html += `<button ${page === totalPages ? 'disabled' : ''} onclick="(${loadFn.name})(${page + 1})">Next</button>`;
container.innerHTML = html;
}
// Utilities
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
function base64UrlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - base64.length % 4) % 4);
const binary = atob(base64 + padding);
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
}
function bufferToBase64Url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// Init
checkAuth();

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

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}