Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
48 KiB
JSON

{"repo": "billsargent/ai-roleplay", "files": {"server.js": {"4839191d219fa7a89f408243f386809203f889ae": {"t": 1786068488.3388588, "n": 45110, "h": "b03b82414151a385", "c": "/**\n * Express.js Backend Server\n * \n * Provides REST API for the RP Chat application.\n * Handles authentication (JWT), CRUD for scenarios/chats/users,\n * system settings, and serves the built frontend from /dist.\n * Uses ESM module system (type: \"module\" in package.json).\n */\n\nimport express from 'express';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\nimport bcrypt from 'bcryptjs';\nimport jwt from 'jsonwebtoken';\nimport cors from 'cors';\nimport helmet from 'helmet';\nimport rateLimit from 'express-rate-limit';\nimport crypto from 'crypto';\nimport { v4 as uuidv4 } from 'uuid';\nimport {\n getDb,\n createUser,\n getUserByUsername,\n getUserById,\n getUserWithPersonas,\n getAllUsers,\n updateUser,\n deleteUserById,\n getPersonasByUserId,\n upsertPersona,\n deletePersonaById,\n getAllScenarios,\n getAccessibleScenarios,\n\n getScenarioById,\n saveScenarioTransaction,\n deleteScenarioById,\n getTrashedScenariosByUserId,\n restoreScenarioById,\n permanentlyDeleteScenarioById,\n emptyScenarioTrashByUserId,\n getChatsByUserId,\n getChatById,\n saveChatTransaction,\n deleteChatById,\n getTrashedChatsByUserId,\n restoreChatById,\n permanentlyDeleteChatById,\n emptyChatTrashByUserId,\n deleteAllChatsByUserId,\n\n getSystemSetting,\n setSystemSetting,\n getUserSetting,\n setUserSetting,\n getAllUserSettings,\n seedDefaultScenariosIfEmpty,\n} from './database/index.js';\n\nimport { ensureUploadsDirs, saveImage, deleteImage, readImage } from './database/images.js';\n\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\nconst app = express();\nconst PORT = process.env.PORT || 3000;\nconst MAX_JSON_BODY_SIZE = process.env.MAX_JSON_BODY_SIZE || '250mb';\n\n// Generate a random JWT secret if one isn't provided via environment variable\nconst JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(64).toString('hex');\n\n// ─── Image Serving Endpoint (BEFORE rate limiter for performance) ─────\n\n/**\n * GET /api/images/*\n * Serves uploaded image files from the filesystem.\n * Path format: /api/images/{entityType}/{prefix}/{id}.{ext}\n * \n * Images are public by design — access control is enforced at the\n * entity level (scenarios, characters, etc.) through the API.\n * \n * Sets a long cache duration (1 year, immutable) since uploaded images\n * never change (they get new paths on update via the delete+recreate pattern).\n * \n * NOTE: Using app.use() with mount path to avoid path-to-regexp v8 issues.\n * The bundled version in Express 5's router (v8.4.2) dropped support for\n * both bare * wildcards and the + modifier syntax. With app.use(), Express\n * strips the mount prefix and the remaining path is in req.path.\n * \n * This is placed BEFORE the rate limiter to skip 95+ unnecessary JWT\n * verifications + DB lookups on every page load.\n */\napp.use('/api/images/', (req, res) => {\n // Only handle GET requests\n if (req.method !== 'GET') {\n return res.status(405).json({ error: 'Method not allowed' });\n }\n\n // Extract the relative path after /api/images/ (strip leading /)\n const relPath = req.path.replace(/^\\//, '');\n if (!relPath) {\n return res.status(400).json({ error: 'Invalid image path' });\n }\n\n const image = readImage(relPath);\n if (!image) {\n return res.status(404).json({ error: 'Image not found' });\n }\n\n res.setHeader('Content-Type', image.contentType);\n res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');\n res.send(image.buffer);\n});\n\n// ─── Security Middleware ───────────────────────────────────────────────\n\napp.use(helmet({\n contentSecurityPolicy: {\n directives: {\n defaultSrc: [\"'self'\"],\n scriptSrc: [\"'self'\", \"'unsafe-inline'\", \"'unsafe-eval'\"],\n styleSrc: [\"'self'\", \"'unsafe-inline'\", \"https://fonts.googleapis.com\"],\n imgSrc: [\"'self'\", \"data:\", \"block:\", \"https://images.unsplash.com\", \"https://*.unsplash.com\", \"/api/images/\"],\n\n fontSrc: [\"'self'\", \"data:\", \"https://fonts.gstatic.com\"],\n connectSrc: [\"'self'\", \"https://api.deepseek.com\"],\n frameSrc: [\"'none'\"],\n objectSrc: [\"'none'\"],\n baseUri: [\"'self'\"],\n formAction: [\"'self'\"],\n },\n },\n crossOriginEmbedderPolicy: false,\n}));\n\n// CORS — restrict in production\napp.use(cors({\n origin: process.env.CORS_ORIGIN || true,\n credentials: true,\n}));\n\napp.use(express.json({ limit: MAX_JSON_BODY_SIZE }));\napp.use(express.urlencoded({ extended: true, limit: MAX_JSON_BODY_SIZE }));\n\napp.use((err, req, res, next) => {\n if (err && (err.type === 'entity.too.large' || err.code === 'ENTITY_TOO_LARGE' || err.status === 413)) {\n console.warn(`[PAYLOAD] ${req.method} ${req.originalUrl} exceeded ${MAX_JSON_BODY_SIZE}`);\n return res.status(413).json({\n error: 'Request payload too large. Please reduce the number of images or split the import into smaller scenario payloads.'\n });\n }\n\n if (err instanceof SyntaxError && 'body' in err) {\n return res.status(400).json({ error: 'Invalid JSON payload.' });\n }\n\n next(err);\n});\n\napp.use(express.static(path.join(__dirname, 'dist')));\n\n// General rate limiter for all API routes to prevent abuse\n// Important: when mounted at '/api', req.path is relative (e.g. '/login' not '/api/login').\n// Auth endpoints are skipped since they have their own dedicated authLimiter.\n// Admin users are also skipped (bypass rate limiting entirely).\n// Non-skipped users get a generous 1000 requests per 15 minute window.\nconst generalLimiter = rateLimit({\n windowMs: 15 * 60 * 1000, // 15 minutes\n // Skip auth endpoints (they have their own authLimiter) AND admin users\n skip: (req) => {\n if (req.path === '/login' || req.path === '/register') return true;\n // Admin users bypass rate limiting entirely\n try {\n const auth = req.headers.authorization;\n if (auth) {\n const token = auth.split(' ')[1];\n const decoded = jwt.verify(token, JWT_SECRET);\n const user = getUserById(decoded.userId);\n if (user?.role === 'admin') return true;\n }\n } catch {\n // Token invalid/expired — don't skip\n }\n return false;\n },\n // Static limit — no async function to avoid any potential issues\n max: 1000,\n message: { error: 'Too many requests. Please try again later.' },\n standardHeaders: true,\n legacyHeaders: false,\n});\n\n\n\n// Apply general rate limiting to all API routes\napp.use('/api', generalLimiter);\n\n// Rate limiting for auth endpoints to prevent brute force attacks\nconst authLimiter = rateLimit({\n windowMs: 15 * 60 * 1000, // 15 minutes\n max: 20, // 20 attempts per window\n message: { error: 'Too many login/registration attempts. Please try again later.' },\n standardHeaders: true,\n legacyHeaders: false,\n});\n\n// Initialize database, seed default scenarios, and create upload directories\ngetDb();\nseedDefaultScenariosIfEmpty();\nensureUploadsDirs();\n\n// ─── Authentication Middleware ────────────────────────────────────────\n\n/**\n * Verifies JWT token from Authorization header.\n * Attaches userId to request on success.\n */\nconst auth = async (req, res, next) => {\n try {\n const authHeader = req.headers.authorization;\n if (!authHeader || !authHeader.startsWith('Bearer ')) {\n return res.status(401).json({ error: 'Unauthorized: No token provided' });\n }\n const token = authHeader.split(' ')[1];\n const decoded = jwt.verify(token, JWT_SECRET);\n req.userId = decoded.userId;\n next();\n } catch (err) {\n res.status(401).json({ error: 'Unauthorized: Invalid token' });\n }\n};\n\n/**\n * Verifies JWT AND checks that the user has admin role.\n * Returns 403 if the user is not an admin.\n */\nconst adminAuth = async (req, res, next) => {\n try {\n const authHeader = req.headers.authorization;\n if (!authHeader || !authHeader.startsWith('Bearer ')) {\n return res.status(401).json({ error: 'Unauthorized: No token provided' });\n }\n const token = authHeader.split(' ')[1];\n const decoded = jwt.verify(token, JWT_SECRET);\n const user = getUserById(decoded.userId);\n if (user?.role !== 'admin') {\n return res.status(403).json({ error: 'Forbidden: Admins only' });\n }\n req.userId = decoded.userId;\n next();\n } catch (err) {\n res.status(401).json({ error: 'Unauthorized: Invalid token' });\n }\n};\n\n// ─── Auth Routes ──────────────────────────────────────────────────────\n\nconst TOKEN_EXPIRY = '7d';\n\n/** Validates password meets minimum length requirement */\nfunction validatePassword(password) {\n if (!password || password.length < 6) {\n return 'Password must be at least 6 characters long';\n }\n return null;\n}\n\n/**\n * POST /api/register\n * Creates a new user account. First real user (non-system) gets admin role.\n * Rate limited to 20 attempts per 15 minutes.\n */\napp.post('/api/register', authLimiter, async (req, res) => {\n try {\n const { username, password } = req.body;\n if (!username || !password) {\n return res.status(400).json({ error: 'Username and password are required' });\n }\n\n const passwordError = validatePassword(password);\n if (passwordError) {\n return res.status(400).json({ error: passwordError });\n }\n\n const existing = getUserByUsername(username);\n if (existing) {\n return res.status(400).json({ error: 'Username already exists' });\n }\n\n const hashedPassword = await bcrypt.hash(password, 10);\n // First real user gets admin role (exclude internal system user)\n const realUsers = getDb().prepare(`SELECT COUNT(*) as cnt FROM users WHERE id != 'system'`).get();\n const role = realUsers.cnt === 0 ? 'admin' : 'user';\n\n const user = {\n id: uuidv4(),\n username,\n password: hashedPassword,\n role,\n createdAt: Date.now(),\n };\n\n createUser(user);\n const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });\n const { password: _, ...userWithoutPassword } = user;\n console.log(`Registered user: ${username} with role: ${role}`);\n res.json({ user: userWithoutPassword, token });\n } catch (err) {\n console.error('Registration error:', err);\n res.status(500).json({ error: 'Internal server error during registration' });\n }\n});\n\n/**\n * POST /api/login\n * Authenticates a user and returns a JWT token.\n * Rate limited to 20 attempts per 15 minutes.\n */\napp.post('/api/login', authLimiter, async (req, res) => {\n try {\n const { username, password } = req.body;\n if (!username || !password) {\n return res.status(400).json({ error: 'Username and password are required' });\n }\n\n const user = getUserByUsername(username);\n if (!user || !(await bcrypt.compare(password, user.password))) {\n return res.status(401).json({ error: 'Invalid username or password' });\n }\n\n const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });\n const { password: _, ...userWithoutPassword } = user;\n console.log(`Logged in user: ${username}`);\n res.json({ user: userWithoutPassword, token });\n } catch (err) {\n console.error('Login error:', err);\n res.status(500).json({ error: 'Internal server error during login' });\n }\n});\n\n// ─── User Routes ──────────────────────────────────────────────────────\n\n/** GET /api/users/me — Returns current user profile with personas */\napp.get('/api/users/me', auth, (req, res) => {\n const user = getUserWithPersonas(req.userId);\n if (!user) return res.status(404).json({ error: 'User not found' });\n res.json(user);\n});\n\n// ─── Persona Routes ──────────────────────────────────────────────────\n\n/** GET /api/personas — Returns all personas for the authenticated user */\napp.get('/api/personas', auth, (req, res) => {\n const personas = getPersonasByUserId(req.userId);\n res.json(personas);\n});\n\n/** POST /api/personas — Creates or updates a persona */\napp.post('/api/personas', auth, (req, res) => {\n const persona = { ...req.body, userId: req.userId };\n const saved = upsertPersona(persona);\n res.json(saved);\n});\n\n/** DELETE /api/personas/:id — Deletes a persona (owner-only) */\napp.delete('/api/personas/:id', auth, (req, res) => {\n const deleted = deletePersonaById(req.params.id, req.userId);\n if (!deleted) return res.status(404).json({ error: 'Persona not found or unauthorized' });\n res.status(204).send();\n});\n\n// ─── Scenario Routes ──────────────────────────────────────────────────\n\n/**\n * GET /api/scenarios\n * Returns all accessible scenarios for the user.\n * Admins see all scenarios; regular users see public + own.\n * Supports pagination via ?page and ?limit query params.\n */\napp.get('/api/scenarios', auth, (req, res) => {\n const user = getUserById(req.userId);\n const isAdmin = user?.role === 'admin';\n\n let scenarios;\n if (isAdmin) {\n scenarios = getAllScenarios();\n } else {\n scenarios = getAccessibleScenarios(req.userId);\n }\n\n // — Pagination support —\n const page = Math.max(1, parseInt(req.query.page) || 1);\n const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 50));\n const total = scenarios.length;\n const totalPages = Math.ceil(total / limit);\n const start = (page - 1) * limit;\n const paginated = scenarios.slice(start, start + limit);\n\n console.log(`[TIMING] GET /api/scenarios: ${total} total, returned ${paginated.length} (page ${page}/${totalPages})`);\n res.json({\n scenarios: paginated,\n pagination: {\n page,\n limit,\n total,\n totalPages,\n }\n });\n});\n\n/**\n * GET /api/scenarios/:id\n * Returns a single scenario with characters and lore.\n * Enforces access control: only owner and admin can view private scenarios.\n */\napp.get('/api/scenarios/:id', auth, (req, res) => {\n const scenario = getScenarioById(req.params.id);\n if (!scenario) return res.status(404).json({ error: 'Scenario not found' });\n\n const user = getUserById(req.userId);\n const isAdmin = user?.role === 'admin';\n const isOwner = scenario.userId === req.userId;\n\n // Private scenario — only owner and admin can view\n if (!scenario.settings?.isPublic && !isOwner && !isAdmin) {\n return res.status(403).json({ error: 'Unauthorized to view this scenario' });\n }\n\n res.json(scenario);\n});\n\n/**\n * POST /api/scenarios\n * Creates a new scenario or saves edits to an existing one.\n * Handles ownership verification, copy flow, and creator name preservation.\n */\napp.post('/api/scenarios', auth, (req, res) => {\n try {\n const scenario = req.body;\n const existing = getDb().prepare(`SELECT id, user_id FROM scenarios WHERE id = ?`).get(scenario.id);\n\n if (existing) {\n // Editing existing scenario — only owner or admin can edit in-place\n const isOwner = existing.user_id === req.userId;\n const user = getUserById(req.userId);\n const isAdmin = user?.role === 'admin';\n\n if (!isOwner && !isAdmin) {\n return res.status(403).json({ error: 'Unauthorized to edit this scenario' });\n }\n\n // Preserve original creator\n scenario.userId = existing.user_id;\n const original = getScenarioById(scenario.id);\n scenario.creatorName = original?.creatorName || '';\n } else {\n // New scenario (also used for copy flow — frontend generates a fresh UUID)\n scenario.userId = req.userId;\n const user = getUserById(req.userId);\n scenario.creatorName = user?.username || '';\n scenario.createdAt = scenario.createdAt || Date.now();\n }\n\n saveScenarioTransaction(scenario);\n res.json(scenario);\n } catch (err) {\n console.error('Save scenario error:', err);\n res.status(500).json({ error: 'Server error saving scenario' });\n }\n});\n\n/**\n * DELETE /api/scenarios/:id\n * Deletes (soft-delete, moves to trash) a scenario. Owner or admin only.\n */\napp.delete('/api/scenarios/:id', auth, (req, res) => {\n try {\n const scenario = getDb().prepare(`SELECT id, user_id FROM scenarios WHERE id = ?`).get(req.params.id);\n if (!scenario) return res.status(404).json({ error: 'Scenario not found' });\n\n const user = getUserById(req.userId);\n const isAdmin = user?.role === 'admin';\n const isOwner = scenario.user_id === req.userId;\n\n if (!isOwner && !isAdmin) {\n return res.status(403).json({ error: 'Unauthorized to delete this scenario' });\n }\n\n deleteScenarioById(req.params.id);\n res.status(204).send();\n } catch (err) {\n console.error('Delete scenario error:', err);\n res.status(500).json({ error: 'Server error deleting scenario' });\n }\n});\n\n// ─── Chat Routes ──────────────────────────────────────────────────────\n\n/**\n * GET /api/chats\n * Returns the user's chats. Admins see all users' chats with owner info.\n */\napp.get('/api/chats', auth, (req, res) => {\n const user = getUserById(req.userId);\n const isAdmin = user?.role === 'admin';\n\n let chats;\n if (isAdmin) {\n // Admin sees all chats for management\n const rawChats = getDb().prepare(`SELECT * FROM chats WHERE deleted_at IS NULL ORDER BY created_at DESC`).all();\n chats = rawChats.map(chat => {\n const c = getChatById(chat.id);\n if (!c) return null;\n // Attach the owner's username\n const owner = getUserById(chat.user_id);\n c.ownerUsername = owner?.username || 'Unknown';\n return c;\n });\n chats = chats.filter(Boolean);\n } else {\n // Regular user sees only their own chats\n chats = getChatsByUserId(req.userId);\n }\n\n res.json(chats);\n\n});\n\n/**\n * POST /api/chats\n * Creates a new chat or saves updates to an existing one.\n * Handles ownership verification.\n */\napp.post('/api/chats', auth, (req, res) => {\n try {\n const chat = req.body;\n const existing = getDb().prepare(`SELECT id, user_id FROM chats WHERE id = ?`).get(chat.id);\n\n if (existing) {\n // Editing — check ownership\n const isOwner = existing.user_id === req.userId;\n const user = getUserById(req.userId);\n const isAdmin = user?.role === 'admin';\n\n if (!isOwner && !isAdmin) {\n return res.status(403).json({ error: 'Unauthorized to edit this chat' });\n }\n chat.userId = existing.user_id;\n } else {\n // New chat\n chat.userId = req.userId;\n chat.createdAt = chat.createdAt || Date.now();\n }\n\n saveChatTransaction(chat);\n res.json(chat);\n } catch (err) {\n console.error('Save chat error:', err);\n res.status(500).json({ error: 'Server error saving chat' });\n }\n});\n\n/** GET /api/chats/:id — Returns a single chat with messages and memories */\napp.get('/api/chats/:id', auth, (req, res) => {\n const chat = getChatById(req.params.id);\n if (!chat) return res.status(404).json({ error: 'Chat not found' });\n\n const user = getUserById(req.userId);\n const isAdmin = user?.role === 'admin';\n const isOwner = chat.userId === req.userId;\n\n if (!isOwner && !isAdmin) {\n return res.status(403).json({ error: 'Unauthorized to view this chat' });\n }\n\n res.json(chat);\n});\n\n/** DELETE /api/chats/all — Deletes all chats for the authenticated user */\napp.delete('/api/chats/all', auth, (req, res) => {\n try {\n deleteAllChatsByUserId(req.userId);\n res.json({ success: true });\n } catch (err) {\n console.error('Delete all chats error:', err);\n res.status(500).json({ error: 'Server error deleting chats' });\n }\n});\n\n/** DELETE /api/chats/:id — Deletes (soft-delete, moves to trash) a single chat (owner or admin) */\napp.delete('/api/chats/:id', auth, (req, res) => {\n try {\n const chat = getDb().prepare(`SELECT id, user_id FROM chats WHERE id = ?`).get(req.params.id);\n if (!chat) return res.status(404).json({ error: 'Chat not found' });\n\n const user = getUserById(req.userId);\n const isAdmin = user?.role === 'admin';\n const isOwner = chat.user_id === req.userId;\n\n if (!isOwner && !isAdmin) {\n return res.status(403).json({ error: 'Unauthorized to delete this chat' });\n }\n\n deleteChatById(req.params.id);\n res.status(204).send();\n } catch (err) {\n console.error('Delete chat error:', err);\n res.status(500).json({ error: 'Server error deleting chat' });\n }\n});\n\n// ─── Trash / Recycle Bin Routes ────────────────────────────────────────\n\n/**\n * GET /api/trash\n * Returns all trashed items (chats and scenarios) for the authenticated user.\n */\napp.get('/api/trash', auth, (req, res) => {\n const trashedChats = getTrashedChatsByUserId(req.userId);\n const trashedScenarios = getTrashedScenariosByUserId(req.userId);\n res.json({ chats: trashedChats, scenarios: trashedScenarios });\n});\n\n/**\n * POST /api/trash/restore/chat/:id\n * Restores a soft-deleted chat from the trash.\n */\napp.post('/api/trash/restore/chat/:id', auth, (req, res) => {\n try {\n const chat = getDb().prepare(`SELECT id, user_id FROM chats WHERE id = ?`).get(req.params.id);\n if (!chat) return res.status(404).json({ error: 'Chat not found' });\n\n const user = getUserById(req.userId);\n const isAdmin = user?.role === 'admin';\n const isOwner = chat.user_id === req.userId;\n\n if (!isOwner && !isAdmin) {\n return res.status(403).json({ error: 'Unauthorized to restore this chat' });\n }\n\n restoreChatById(req.params.id);\n res.json({ success: true });\n } catch (err) {\n console.error('Restore chat error:', err);\n res.status(500).json({ error: 'Server error restoring chat' });\n }\n});\n\n/**\n * POST /api/trash/restore/scenario/:id\n * Restores a soft-deleted scenario from the trash.\n */\napp.post('/api/trash/restore/scenario/:id', auth, (req, res) => {\n try {\n const scenario = getDb().prepare(`SELECT id, user_id FROM scenarios WHERE id = ?`).get(req.params.id);\n if (!scenario) return res.status(404).json({ error: 'Scenario not found' });\n\n const user = getUserById(req.userId);\n const isAdmin = user?.role === 'admin';\n const isOwner = scenario.user_id === req.userId;\n\n if (!isOwner && !isAdmin) {\n return res.status(403).json({ error: 'Unauthorized to restore this scenario' });\n }\n\n restoreScenarioById(req.params.id);\n res.json({ success: true });\n } catch (err) {\n console.error('Restore scenario error:', err);\n res.status(500).json({ error: 'Server error restoring scenario' });\n }\n});\n\n/**\n * DELETE /api/trash/chat/:id\n * Permanently deletes a chat from the trash (final deletion).\n */\napp.delete('/api/trash/chat/:id', auth, (req, res) => {\n try {\n const chat = getDb().prepare(`SELECT id, user_id FROM chats WHERE id = ?`).get(req.params.id);\n if (!chat) return res.status(404).json({ error: 'Chat not found' });\n\n const user = getUserById(req.userId);\n const isAdmin = user?.role === 'admin';\n const isOwner = chat.user_id === req.userId;\n\n if (!isOwner && !isAdmin) {\n return res.status(403).json({ error: 'Unauthorized to permanently delete this chat' });\n }\n\n permanentlyDeleteChatById(req.params.id);\n res.status(204).send();\n } catch (err) {\n console.error('Permanent delete chat error:', err);\n res.status(500).json({ error: 'Server error permanently deleting chat' });\n }\n});\n\n/**\n * DELETE /api/trash/scenario/:id\n * Permanently deletes a scenario from the trash (final deletion).\n */\napp.delete('/api/trash/scenario/:id', auth, (req, res) => {\n try {\n const scenario = getDb().prepare(`SELECT id, user_id FROM scenarios WHERE id = ?`).get(req.params.id);\n if (!scenario) return res.status(404).json({ error: 'Scenario not found' });\n\n const user = getUserById(req.userId);\n const isAdmin = user?.role === 'admin';\n const isOwner = scenario.user_id === req.userId;\n\n if (!isOwner && !isAdmin) {\n return res.status(403).json({ error: 'Unauthorized to permanently delete this scenario' });\n }\n\n permanentlyDeleteScenarioById(req.params.id);\n res.status(204).send();\n } catch (err) {\n console.error('Permanent delete scenario error:', err);\n res.status(500).json({ error: 'Server error permanently deleting scenario' });\n }\n});\n\n/**\n * DELETE /api/trash/empty\n * Permanently deletes all trashed items (chats and scenarios) for the authenticated user.\n */\napp.delete('/api/trash/empty', auth, (req, res) => {\n try {\n emptyChatTrashByUserId(req.userId);\n emptyScenarioTrashByUserId(req.userId);\n res.json({ success: true });\n } catch (err) {\n console.error('Empty trash error:', err);\n res.status(500).json({ error: 'Server error emptying trash' });\n }\n});\n\n// ─── System Settings (Admin Only) ─────────────────────────────────────\n\n/** GET /api/system/settings — Returns all system settings (admin only) */\napp.get('/api/system/settings', adminAuth, (req, res) => {\n const deepseekKey = getSystemSetting('deepseekKey');\n const globalInstructions = getSystemSetting('globalInstructions');\n const temperature = getSystemSetting('temperature');\n const maxTokens = getSystemSetting('maxTokens');\n const tokenShort = getSystemSetting('tokenShort');\n const tokenMedium = getSystemSetting('tokenMedium');\n const tokenLong = getSystemSetting('tokenLong');\n const chatPaddingLeft = getSystemSetting('chatPaddingLeft');\n const chatPaddingRight = getSystemSetting('chatPaddingRight');\n const frequencyPenalty = getSystemSetting('frequencyPenalty');\n const presencePenalty = getSystemSetting('presencePenalty');\n const siteName = getSystemSetting('siteName');\n const memorySendInterval = getSystemSetting('memorySendInterval');\n const memoryGenerateInterval = getSystemSetting('memoryGenerateInterval');\n const memoryWordCount = getSystemSetting('memoryWordCount');\n const memoryMaxCount = getSystemSetting('memoryMaxCount');\n const deepseekModel = getSystemSetting('deepseekModel');\n const debugLlmPrompt = getSystemSetting('debugLlmPrompt');\n res.json({ deepseekKey, globalInstructions, temperature, maxTokens, tokenShort, tokenMedium, tokenLong, chatPaddingLeft, chatPaddingRight, frequencyPenalty, presencePenalty, siteName, memorySendInterval, memoryGenerateInterval, memoryWordCount, memoryMaxCount, deepseekModel, debugLlmPrompt });\n\n\n\n});\n\n/** POST /api/system/settings — Updates system settings (admin only) */\napp.post('/api/system/settings', adminAuth, (req, res) => {\n const { deepseekKey, globalInstructions, temperature, maxTokens, tokenShort, tokenMedium, tokenLong, chatPaddingLeft, chatPaddingRight, frequencyPenalty, presencePenalty, siteName, memorySendInterval, memoryGenerateInterval, memoryWordCount, memoryMaxCount, deepseekModel, debugLlmPrompt } = req.body;\n if (deepseekKey !== undefined) setSystemSetting('deepseekKey', deepseekKey);\n if (globalInstructions !== undefined) setSystemSetting('globalInstructions', globalInstructions);\n if (temperature !== undefined) setSystemSetting('temperature', String(temperature));\n if (maxTokens !== undefined) setSystemSetting('maxTokens', String(maxTokens));\n if (tokenShort !== undefined) setSystemSetting('tokenShort', String(tokenShort));\n if (tokenMedium !== undefined) setSystemSetting('tokenMedium', String(tokenMedium));\n if (tokenLong !== undefined) setSystemSetting('tokenLong', String(tokenLong));\n if (chatPaddingLeft !== undefined) setSystemSetting('chatPaddingLeft', String(chatPaddingLeft));\n if (chatPaddingRight !== undefined) setSystemSetting('chatPaddingRight', String(chatPaddingRight));\n if (frequencyPenalty !== undefined) setSystemSetting('frequencyPenalty', String(frequencyPenalty));\n if (presencePenalty !== undefined) setSystemSetting('presencePenalty', String(presencePenalty));\n if (siteName !== undefined) setSystemSetting('siteName', siteName);\n if (memorySendInterval !== undefined) setSystemSetting('memorySendInterval', String(memorySendInterval));\n if (memoryGenerateInterval !== undefined) setSystemSetting('memoryGenerateInterval', String(memoryGenerateInterval));\n if (memoryWordCount !== undefined) setSystemSetting('memoryWordCount', String(memoryWordCount));\n if (memoryMaxCount !== undefined) setSystemSetting('memoryMaxCount', String(memoryMaxCount));\n if (deepseekModel !== undefined) setSystemSetting('deepseekModel', deepseekModel);\n if (debugLlmPrompt !== undefined) setSystemSetting('debugLlmPrompt', debugLlmPrompt ? 'true' : 'false');\n\n res.json({ success: true });\n\n\n});\n\n/** GET /api/system/deepseek-key — Returns the DeepSeek API key (admin only) */\napp.get('/api/system/deepseek-key', adminAuth, (req, res) => {\n const deepseekKey = getSystemSetting('deepseekKey');\n res.json({ deepseekKey });\n});\n\n// ─── LLM Debug Logging Helper ────────────────────────────────────────\n\n/**\n * Checks if LLM debug logging is enabled AND the requesting user is an admin.\n * If both conditions are met, logs the full prompt payload to the console.\n * \n * @param {string} userId - The user ID from the authenticated request\n * @param {object} payload - The request body being sent to DeepSeek\n * @param {string} endpoint - Label describing which endpoint is being called\n */\nfunction debugLogLlmPrompt(userId, payload, endpoint) {\n try {\n const debugEnabled = getSystemSetting('debugLlmPrompt');\n if (debugEnabled !== 'true') return;\n\n const user = getUserById(userId);\n if (user?.role !== 'admin') return;\n\n const { messages, model, temperature, max_tokens, frequency_penalty, presence_penalty } = payload;\n\n console.log('');\n console.log('┌─────────────────────────────────────────────────────────────┐');\n console.log(`│ 🔍 LLM DEBUG — ${endpoint.padEnd(40)}│`);\n console.log('├─────────────────────────────────────────────────────────────┤');\n console.log(`│ Model: ${String(model || 'deepseek-chat').padEnd(37)}│`);\n console.log(`│ Temperature: ${String(temperature ?? 'N/A').padEnd(37)}│`);\n console.log(`│ Max Tokens: ${String(max_tokens ?? 'N/A').padEnd(37)}│`);\n console.log(`│ Freq Penalty: ${String(frequency_penalty ?? 'N/A').padEnd(37)}│`);\n console.log(`│ Pres Penalty: ${String(presence_penalty ?? 'N/A').padEnd(37)}│`);\n console.log(`│ Messages Count: ${String(messages?.length ?? 0).padEnd(37)}│`);\n console.log('├─────────────────────────────────────────────────────────────┤');\n console.log('│ FULL MESSAGES PAYLOAD: │');\n console.log('└─────────────────────────────────────────────────────────────┘');\n\n if (messages && Array.isArray(messages)) {\n messages.forEach((msg, i) => {\n const role = msg.role || 'unknown';\n const contentPreview = msg.content?.substring(0, 200) || '';\n console.log(` [${i}] role: ${role}`);\n console.log(` content: ${contentPreview}${msg.content?.length > 200 ? '...' : ''}`);\n console.log('');\n });\n }\n\n console.log('└─────────────────────────────────────────────────────────────┘');\n console.log('');\n } catch (err) {\n // Debug logging should never break the actual request\n console.error('Debug log error:', err);\n }\n}\n\n// ─── DeepSeek Proxy Routes ───────────────────────────────────────────\n\nconst DEEPSEEK_BASE_URL = 'https://api.deepseek.com';\n\n/**\n * GET /api/deepseek/models — Fetches available model IDs from DeepSeek API (admin only)\n * This endpoint proxies DeepSeek's list-models endpoint:\n * https://api-docs.deepseek.com/api/list-models\n */\napp.get('/api/deepseek/models', adminAuth, async (req, res) => {\n try {\n const deepseekKey = getSystemSetting('deepseekKey');\n if (!deepseekKey) {\n return res.status(400).json({ error: 'DeepSeek API key is not configured.' });\n }\n\n const response = await fetch(`${DEEPSEEK_BASE_URL}/models`, {\n headers: {\n 'Authorization': `Bearer ${deepseekKey}`,\n 'Accept': 'application/json',\n },\n });\n\n const data = await response.json();\n if (!response.ok) {\n return res.status(response.status).json({ error: data.error?.message || 'DeepSeek API error' });\n }\n res.json(data);\n } catch (err) {\n console.error('DeepSeek models fetch error:', err);\n res.status(500).json({ error: 'Failed to fetch models from DeepSeek API' });\n }\n});\n\n\n/**\n * POST /api/deepseek/chat — Proxies non-streaming chat to DeepSeek API\n * The API key stays server-side — never exposed to the client.\n */\napp.post('/api/deepseek/chat', auth, async (req, res) => {\n try {\n const deepseekKey = getSystemSetting('deepseekKey');\n if (!deepseekKey) {\n return res.status(400).json({ error: 'DeepSeek API key is not configured. Please ask your administrator to set it in settings.' });\n }\n const { messages, model, temperature, max_tokens, frequency_penalty, presence_penalty } = req.body;\n\n // Debug logging for admin users\n debugLogLlmPrompt(req.userId, req.body, 'Chat');\n\n const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${deepseekKey}`,\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages,\n temperature,\n max_tokens,\n frequency_penalty,\n presence_penalty,\n }),\n });\n\n const data = await response.json();\n if (!response.ok) {\n return res.status(response.status).json({ error: data.error?.message || 'DeepSeek API error' });\n }\n res.json(data);\n } catch (err) {\n console.error('DeepSeek proxy error:', err);\n res.status(500).json({ error: 'Failed to communicate with DeepSeek API' });\n }\n});\n\n/**\n * POST /api/deepseek/chat/stream — Proxies SSE streaming chat to DeepSeek API\n * Streams data chunks back to the client as they arrive from DeepSeek.\n */\napp.post('/api/deepseek/chat/stream', auth, async (req, res) => {\n try {\n const deepseekKey = getSystemSetting('deepseekKey');\n if (!deepseekKey) {\n return res.status(400).json({ error: 'DeepSeek API key is not configured.' });\n }\n const { messages, model, temperature, max_tokens, frequency_penalty, presence_penalty } = req.body;\n\n // Debug logging for admin users\n debugLogLlmPrompt(req.userId, req.body, 'Chat Stream');\n\n const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${deepseekKey}`,\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages,\n temperature,\n max_tokens,\n frequency_penalty,\n presence_penalty,\n stream: true,\n }),\n });\n\n if (!response.ok) {\n const errorData = await response.json().catch(() => ({}));\n return res.status(response.status).json({ error: errorData.error?.message || 'DeepSeek API error' });\n }\n\n // Set SSE headers\n res.setHeader('Content-Type', 'text/event-stream');\n res.setHeader('Cache-Control', 'no-cache');\n res.setHeader('Connection', 'keep-alive');\n\n // Pipe the DeepSeek stream to the client\n const reader = response.body?.getReader();\n if (!reader) {\n return res.status(500).json({ error: 'No response body' });\n }\n\n const decoder = new TextDecoder();\n let buffer = '';\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) {\n res.write('data: [DONE]\\n\\n');\n res.end();\n break;\n }\n\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n\n for (const line of lines) {\n if (line.trim()) {\n res.write(line + '\\n');\n }\n }\n }\n } catch (err) {\n console.error('DeepSeek streaming proxy error:', err);\n if (!res.headersSent) {\n res.status(500).json({ error: 'Failed to communicate with DeepSeek API' });\n }\n }\n});\n\n/**\n * POST /api/deepseek/generate — Proxies scenario generation to DeepSeek API\n */\napp.post('/api/deepseek/generate', auth, async (req, res) => {\n try {\n const deepseekKey = getSystemSetting('deepseekKey');\n if (!deepseekKey) {\n return res.status(400).json({ error: 'DeepSeek API key is not configured.' });\n }\n const { messages, model, temperature, max_tokens } = req.body;\n\n // Debug logging for admin users\n debugLogLlmPrompt(req.userId, req.body, 'Generate');\n\n const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${deepseekKey}`,\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages,\n temperature: temperature || 0.8,\n max_tokens: max_tokens || 4000,\n }),\n });\n\n const data = await response.json();\n if (!response.ok) {\n return res.status(response.status).json({ error: data.error?.message || 'DeepSeek API error' });\n }\n res.json(data);\n } catch (err) {\n console.error('DeepSeek generate proxy error:', err);\n res.status(500).json({ error: 'Failed to communicate with DeepSeek API' });\n }\n});\n\n/** GET /api/system/llm-settings — Returns LLM configuration (any authenticated user) */\napp.get('/api/system/llm-settings', auth, (req, res) => {\n const globalInstructions = getSystemSetting('globalInstructions');\n const temperature = getSystemSetting('temperature');\n const maxTokens = getSystemSetting('maxTokens');\n const tokenShort = getSystemSetting('tokenShort');\n const tokenMedium = getSystemSetting('tokenMedium');\n const tokenLong = getSystemSetting('tokenLong');\n const chatPaddingLeft = getSystemSetting('chatPaddingLeft');\n const chatPaddingRight = getSystemSetting('chatPaddingRight');\n const frequencyPenalty = getSystemSetting('frequencyPenalty');\n const presencePenalty = getSystemSetting('presencePenalty');\n const siteName = getSystemSetting('siteName');\n const memorySendInterval = getSystemSetting('memorySendInterval');\n const memoryGenerateInterval = getSystemSetting('memoryGenerateInterval');\n const memoryWordCount = getSystemSetting('memoryWordCount');\n const memoryMaxCount = getSystemSetting('memoryMaxCount');\n const deepseekModel = getSystemSetting('deepseekModel');\n res.json({ globalInstructions, temperature, maxTokens, tokenShort, tokenMedium, tokenLong, chatPaddingLeft, chatPaddingRight, frequencyPenalty, presencePenalty, siteName, memorySendInterval, memoryGenerateInterval, memoryWordCount, memoryMaxCount, deepseekModel });\n\n\n\n});\n\n// ─── Change Password ──────────────────────────────────────────────────\n\n/**\n * POST /api/users/change-password\n * Allows authenticated users to change their password.\n * Requires current password verification before updating.\n */\napp.post('/api/users/change-password', auth, async (req, res) => {\n try {\n const { currentPassword, newPassword } = req.body;\n if (!currentPassword || !newPassword) {\n return res.status(400).json({ error: 'Current password and new password are required' });\n }\n\n const passwordError = validatePassword(newPassword);\n if (passwordError) {\n return res.status(400).json({ error: passwordError });\n }\n\n const user = getUserById(req.userId);\n if (!user) return res.status(404).json({ error: 'User not found' });\n\n const isMatch = await bcrypt.compare(currentPassword, user.password);\n if (!isMatch) {\n return res.status(401).json({ error: 'Current password is incorrect' });\n }\n\n const hashedPassword = await bcrypt.hash(newPassword, 10);\n updateUser(req.userId, { password: hashedPassword });\n res.json({ success: true });\n } catch (err) {\n console.error('Change password error:', err);\n res.status(500).json({ error: 'Server error changing password' });\n }\n});\n\n// ─── User Settings ────────────────────────────────────────────────────\n\n/** GET /api/users/settings — Returns all settings for the authenticated user */\napp.get('/api/users/settings', auth, (req, res) => {\n const settings = getAllUserSettings(req.userId);\n res.json(settings);\n});\n\n/** List of allowed user settings keys to prevent arbitrary data injection */\nconst ALLOWED_USER_SETTINGS_KEYS = new Set([\n 'dialogColor',\n 'narrationColor',\n 'chatBubbleColor',\n]);\n\n/** POST /api/users/settings — Saves user settings (single key-value or bulk object) */\napp.post('/api/users/settings', auth, (req, res) => {\n const { key, value } = req.body;\n if (key && value !== undefined) {\n if (!ALLOWED_USER_SETTINGS_KEYS.has(key)) {\n return res.status(400).json({ error: `Invalid setting key: ${key}` });\n }\n setUserSetting(req.userId, key, value);\n res.json({ success: true });\n } else if (typeof req.body === 'object') {\n // Bulk save: { key1: value1, key2: value2 }\n for (const [k, v] of Object.entries(req.body)) {\n if (!ALLOWED_USER_SETTINGS_KEYS.has(k)) {\n return res.status(400).json({ error: `Invalid setting key: ${k}` });\n }\n setUserSetting(req.userId, k, String(v));\n }\n res.json({ success: true });\n } else {\n res.status(400).json({ error: 'Invalid settings data' });\n }\n});\n\n// ─── Admin: User Management ───────────────────────────────────────────\n\n/** GET /api/admin/users — Returns all users with chat counts (admin only) */\napp.get('/api/admin/users', adminAuth, (req, res) => {\n const users = getAllUsers();\n res.json(users);\n});\n\n/** POST /api/admin/users — Creates a new user (admin only) */\napp.post('/api/admin/users', adminAuth, async (req, res) => {\n try {\n const { username, password, role } = req.body;\n if (!username || !password) {\n return res.status(400).json({ error: 'Username and password are required' });\n }\n const existing = getUserByUsername(username);\n if (existing) {\n return res.status(400).json({ error: 'Username already exists' });\n }\n const hashedPassword = await bcrypt.hash(password, 10);\n const user = {\n id: uuidv4(),\n username,\n password: hashedPassword,\n role: role || 'user',\n createdAt: Date.now(),\n };\n createUser(user);\n const { password: _, ...userWithoutPassword } = user;\n res.json(userWithoutPassword);\n } catch (err) {\n console.error('Admin create user error:', err);\n res.status(500).json({ error: 'Server error creating user' });\n }\n});\n\n/** PUT /api/admin/users/:id — Updates a user (admin only). Cannot modify system user. */\napp.put('/api/admin/users/:id', adminAuth, async (req, res) => {\n try {\n const { username, role, password } = req.body;\n const targetUser = getUserById(req.params.id);\n if (!targetUser) return res.status(404).json({ error: 'User not found' });\n if (targetUser.id === 'system') return res.status(400).json({ error: 'Cannot modify system user' });\n\n const updates = {};\n if (username !== undefined) updates.username = username;\n if (role !== undefined) updates.role = role;\n if (password !== undefined && password.trim()) {\n updates.password = await bcrypt.hash(password, 10);\n }\n\n const updated = updateUser(req.params.id, updates);\n const { password: _, ...userWithoutPassword } = updated;\n res.json(userWithoutPassword);\n } catch (err) {\n console.error('Admin update user error:', err);\n res.status(500).json({ error: 'Server error updating user' });\n }\n});\n\n/** DELETE /api/admin/users/:id — Deletes a user (admin only). Cannot delete self or system. */\napp.delete('/api/admin/users/:id', adminAuth, (req, res) => {\n try {\n const targetUser = getUserById(req.params.id);\n if (!targetUser) return res.status(404).json({ error: 'User not found' });\n if (targetUser.id === 'system') return res.status(400).json({ error: 'Cannot delete system user' });\n if (targetUser.id === req.userId) return res.status(400).json({ error: 'Cannot delete yourself' });\n\n deleteUserById(req.params.id);\n res.status(204).send();\n } catch (err) {\n console.error('Admin delete user error:', err);\n res.status(500).json({ error: 'Server error deleting user' });\n }\n});\n\n// ─── Image Proxy ──────────────────────────────────────────────────────\n\n/**\n * GET /api/proxy-image\n * Fetches a remote image server-side and returns it as a base64 data URL.\n * Used by the FictionLab import flow to bypass CORS restrictions when\n * fetching images from CDNs that don't set Access-Control-Allow-Origin.\n *\n * Query params:\n * url - The full URL of the image to fetch (required)\n *\n * Returns: { dataUrl: \"data:image/webp;base64,...\" }\n */\napp.get('/api/proxy-image', async (req, res) => {\n try {\n const { url } = req.query;\n if (!url || typeof url !== 'string') {\n return res.status(400).json({ error: 'Missing or invalid \"url\" query parameter' });\n }\n\n const response = await fetch(url);\n if (!response.ok) {\n return res.status(response.status).json({ error: `Upstream HTTP ${response.status}` });\n }\n\n const contentType = response.headers.get('content-type') || 'image/webp';\n const buffer = await response.arrayBuffer();\n const base64 = Buffer.from(buffer).toString('base64');\n const dataUrl = `data:${contentType};base64,${base64}`;\n\n res.json({ dataUrl });\n } catch (err) {\n console.error('Proxy-image error:', err);\n res.status(500).json({ error: 'Failed to proxy image' });\n }\n});\n\n// ─── Serve Frontend ───────────────────────────────────────────────────\n\n// Catch-all: serve index.html for all non-API routes (SPA support)\napp.use((req, res) => {\n res.sendFile(path.join(__dirname, 'dist', 'index.html'));\n});\n\n\napp.listen(PORT, () => {\n console.log(`Server running at http://localhost:${PORT}`);\n});\n"}}}}