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