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