Add frontend service with auth, MFA, and content management
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- Node.js/Express backend with TypeScript
- SQLite database for users, sessions, and content metadata
- Authentication with TOTP and WebAuthn MFA support
- Admin user auto-created on first startup
- User content gallery with view/delete functionality
- RunPod API proxy (keeps API keys server-side)
- Docker setup with CI/CD for Gitea registry

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-01-07 04:57:08 +00:00
parent 8a5610a1e4
commit 890543fb77
33 changed files with 6851 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
import { randomBytes, createCipheriv, createDecipheriv, scrypt } from 'crypto';
import { promisify } from 'util';
import argon2 from 'argon2';
import { config } from '../config.js';
const scryptAsync = promisify(scrypt);
// Password hashing with Argon2id
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
// Generate cryptographically secure random strings
export function generateSecureToken(length: number = 32): string {
return randomBytes(length).toString('hex');
}
export function generateSessionId(): string {
return randomBytes(32).toString('base64url');
}
// AES-256-GCM encryption for TOTP secrets
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
async function deriveKey(): Promise<Buffer> {
const keyHex = config.encryptionKey;
if (keyHex.length !== 64) {
throw new Error('ENCRYPTION_KEY must be 64 hex characters (32 bytes)');
}
return Buffer.from(keyHex, 'hex');
}
export async function encrypt(plaintext: string): Promise<string> {
const key = await deriveKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Format: iv:authTag:ciphertext (all hex)
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
export async function decrypt(ciphertext: string): Promise<string> {
const key = await deriveKey();
const parts = ciphertext.split(':');
if (parts.length !== 3) {
throw new Error('Invalid ciphertext format');
}
const iv = Buffer.from(parts[0], 'hex');
const authTag = Buffer.from(parts[1], 'hex');
const encrypted = parts[2];
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}

View File

@@ -0,0 +1,18 @@
import pino from 'pino';
import { config } from '../config.js';
export const logger = pino({
level: config.isProduction ? 'info' : 'debug',
transport: config.isProduction
? undefined
: {
target: 'pino-pretty',
options: {
colorize: true,
},
},
redact: {
paths: ['password', 'passwordHash', 'totpSecret', 'apiKey', 'sessionSecret'],
censor: '[REDACTED]',
},
});

View File

@@ -0,0 +1,83 @@
import { z } from 'zod';
// User validation
export const usernameSchema = z
.string()
.min(3, 'Username must be at least 3 characters')
.max(32, 'Username must be at most 32 characters')
.regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and hyphens');
export const passwordSchema = z
.string()
.min(12, 'Password must be at least 12 characters')
.max(128, 'Password must be at most 128 characters');
export const emailSchema = z
.string()
.email('Invalid email address')
.optional()
.nullable();
// Auth schemas
export const loginSchema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
});
export const totpVerifySchema = z.object({
code: z.string().length(6, 'TOTP code must be 6 digits').regex(/^\d+$/, 'TOTP code must be numeric'),
});
// User management schemas
export const createUserSchema = z.object({
username: usernameSchema,
password: passwordSchema,
email: emailSchema,
isAdmin: z.boolean().optional().default(false),
});
export const updateUserSchema = z.object({
username: usernameSchema.optional(),
email: emailSchema,
isAdmin: z.boolean().optional(),
isActive: z.boolean().optional(),
});
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: passwordSchema,
});
// Content schemas
export const contentListSchema = z.object({
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
status: z.enum(['pending', 'processing', 'completed', 'failed']).optional(),
userId: z.coerce.number().int().positive().optional(),
});
// Generation schemas
export const generationRequestSchema = z.object({
image: z.string().min(1, 'Image is required'),
prompt: z.string().min(1, 'Prompt is required').max(2000, 'Prompt is too long'),
negativePrompt: z.string().max(2000).optional().default(''),
resolution: z.number().int().min(480).max(1080).optional().default(720),
steps: z.number().int().min(1).max(50).optional().default(8),
splitStep: z.number().int().min(1).max(20).optional().default(4),
timeout: z.number().int().min(60).max(600).optional().default(600),
});
// MFA schemas
export const mfaNameSchema = z.object({
name: z.string().min(1).max(64).optional().default('Default'),
});
// Validation helper
export function validateRequest<T>(schema: z.ZodSchema<T>, data: unknown): { success: true; data: T } | { success: false; error: string } {
const result = schema.safeParse(data);
if (result.success) {
return { success: true, data: result.data };
}
const errorMessage = result.error.errors.map(e => e.message).join(', ');
return { success: false, error: errorMessage };
}