| import express from 'express'; |
| import { createServer } from 'http'; |
| import { WebSocketServer } from 'ws'; |
| import { createClient } from '@supabase/supabase-js'; |
| import crypto from 'crypto'; |
| import path from 'path'; |
| import { fileURLToPath } from 'url'; |
| import fetch from 'node-fetch'; |
| import rateLimit from 'express-rate-limit'; |
| import fs from 'fs'; |
| import { registerFeedbackRoutes } from './handleFeedback.js'; |
| import { abortActiveStream, handleWsMessage } from './wsHandler.js'; |
| import { serializeSessionForClient } from './chatSessionSerializer.js'; |
| import { sessionStore, initStoreConfig } from './sessionStore.js'; |
| import { SUPABASE_URL, SUPABASE_ANON_KEY } from './config.js'; |
| import { safeSend } from './helpers.js'; |
| import { verifySupabaseToken } from './auth.js'; |
| import { mediaStore } from './mediaStore.js'; |
| import { pendingTurnstileTokens } from './turnstileState.js'; |
|
|
| export { SUPABASE_URL, SUPABASE_ANON_KEY }; |
| export { LIGHTNING_BASE, PUBLIC_URL } from './config.js'; |
|
|
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
|
|
| initStoreConfig(SUPABASE_URL, SUPABASE_ANON_KEY); |
| export const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); |
|
|
| const PORT = process.env.PORT || 7860; |
| const app = express(); |
|
|
| const GITHUB_REPO = 'sharktide/inferenceport-webchat'; |
| const CDN_BASE = `https://cdn.jsdelivr.net/gh/${GITHUB_REPO}`; |
| let latestSHA = null; |
| const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret'; |
| const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || '0x4AAAAAAC1ZXKIhZ9Kdz8j9'; |
| const MAX_TEXT_UPLOAD_BYTES = 100 * 1024; |
|
|
| const LOCAL_UI_DIR = [ |
| process.env.UI_LOCAL_PATH, |
| path.resolve(__dirname, '..', '..', 'InferencePort-Pages'), |
| path.resolve(process.cwd(), '..', 'InferencePort-Pages'), |
| ].filter(Boolean).find((dir) => { |
| try { |
| return fs.existsSync(path.join(dir, 'index.html')); |
| } catch { |
| return false; |
| } |
| }) || null; |
|
|
| |
| const verifyLimiter = rateLimit({ |
| windowMs: 60 * 1000, |
| max: 5, |
| standardHeaders: true, |
| legacyHeaders: false, |
| handler: (req, res) => { |
| logAdminEvent(req, 'rate_limited'); |
| res.status(429).json({ error: 'rate_limited' }); |
| }, |
| }); |
|
|
| const DATA_DIR = "/data"; |
| const VERSION_FILE = path.join(DATA_DIR, 'version.json'); |
| const PUBLIC_URL = process.env.PUBLIC_URL || 'default'; |
|
|
| function getRequestIp(req) { |
| return (req.headers['x-forwarded-for'] || '').split(',')[0].trim() |
| || req.socket?.remoteAddress |
| || req.ip |
| || 'unknown'; |
| } |
|
|
| function truncateForLog(value, max = 180) { |
| const text = String(value ?? '').replace(/\s+/g, ' ').trim(); |
| if (!text) return ''; |
| return text.length > max ? `${text.slice(0, max - 1)}…` : text; |
| } |
|
|
| function extensionFromName(name = '') { |
| const ext = path.extname(String(name || '')).toLowerCase(); |
| return ext.startsWith('.') ? ext.slice(1) : ext; |
| } |
|
|
| function isTextLikeUpload(name = '', mimeType = '', kind = '') { |
| const normalizedKind = String(kind || '').toLowerCase(); |
| const mime = String(mimeType || '').toLowerCase(); |
| const ext = extensionFromName(name); |
| if (normalizedKind === 'text' || normalizedKind === 'rich_text') return true; |
| if (mime.startsWith('text/')) return true; |
| if (['application/json', 'application/javascript', 'application/xml'].includes(mime)) return true; |
| return ['txt', 'md', 'json', 'js', 'ts', 'css', 'py', 'html', 'htm', 'xml', 'csv', 'rtf'].includes(ext); |
| } |
|
|
| function getCookieMap(req) { |
| const cookies = (req.headers.cookie || '') |
| .split(';') |
| .map((value) => value.trim()) |
| .filter(Boolean) |
| .map((entry) => { |
| const idx = entry.indexOf('='); |
| if (idx === -1) return null; |
| return [entry.slice(0, idx), entry.slice(idx + 1)]; |
| }) |
| .filter(Boolean); |
| return Object.fromEntries(cookies); |
| } |
|
|
| function hasAdminTurnstile(req) { |
| return getCookieMap(req).admin_turnstile === '1'; |
| } |
|
|
| function requireAdminTurnstile(req, res, next) { |
| if (hasAdminTurnstile(req)) return next(); |
| logAdminEvent(req, 'blocked_missing_turnstile'); |
| return res.status(403).json({ error: 'turnstile:required' }); |
| } |
|
|
| function logAdminEvent(req, action, detail = null) { |
| const parts = [ |
| `[ADMIN ${new Date().toISOString()}]`, |
| `action=${action}`, |
| `ip=${getRequestIp(req)}`, |
| `method=${req.method}`, |
| `path=${truncateForLog(req.originalUrl || req.url, 220)}`, |
| ]; |
| const userAgent = truncateForLog(req.headers['user-agent'] || 'unknown', 140); |
| if (userAgent) parts.push(`ua="${userAgent}"`); |
| if (typeof detail === 'string' && detail.trim()) { |
| parts.push(detail.trim()); |
| } else if (detail && typeof detail === 'object') { |
| Object.entries(detail).forEach(([key, value]) => { |
| if (value === undefined || value === null || value === '') return; |
| parts.push(`${key}=${JSON.stringify(String(value))}`); |
| }); |
| } |
| console.log(parts.join(' | ')); |
| } |
|
|
| function respondTextUploadTooLarge(res) { |
| return res.status(413).json({ |
| error: 'media:text_too_large', |
| message: 'Text files must be 100 KB or smaller.', |
| }); |
| } |
|
|
| async function verifyTurnstileToken(token, remoteIp) { |
| const secret = process.env.TURNSTILE_SECRET_KEY; |
| if (!token || !secret) throw new Error('Missing token or server not configured'); |
| const params = new URLSearchParams(); |
| params.append('secret', secret); |
| params.append('response', token); |
| if (remoteIp) params.append('remoteip', remoteIp); |
| const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { |
| method: 'POST', |
| body: params, |
| }); |
| return response.json(); |
| } |
|
|
|
|
| function loadStoredSHA() { |
| try { |
| if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); |
| if (!fs.existsSync(VERSION_FILE)) return null; |
| const data = JSON.parse(fs.readFileSync(VERSION_FILE, 'utf-8')); |
|
|
| |
| const obj = data.find(item => item[PUBLIC_URL]); |
| return obj ? obj[PUBLIC_URL] : null; |
| } catch (e) { |
| console.error('Failed to load stored SHA:', e); |
| return null; |
| } |
| } |
|
|
|
|
| function saveStoredSHA(sha) { |
| try { |
| if (!fs.existsSync(DATA_DIR)) { |
| fs.mkdirSync(DATA_DIR, { recursive: true }); |
| } |
|
|
| let data = []; |
|
|
| if (fs.existsSync(VERSION_FILE)) { |
| try { |
| data = JSON.parse(fs.readFileSync(VERSION_FILE, 'utf-8')); |
| if (!Array.isArray(data)) data = []; |
| } catch { |
| data = []; |
| } |
| } |
|
|
| let found = false; |
|
|
| for (const entry of data) { |
| if (entry[PUBLIC_URL]) { |
| entry[PUBLIC_URL] = sha; |
| found = true; |
| break; |
| } |
| } |
|
|
| if (!found) { |
| data.push({ [PUBLIC_URL]: sha }); |
| } |
|
|
| fs.writeFileSync( |
| VERSION_FILE, |
| JSON.stringify(data, null, 2), |
| 'utf-8' |
| ); |
|
|
| } catch (e) { |
| console.error('Failed to save SHA:', e); |
| } |
| } |
|
|
| app.use(express.json({ limit: '10mb' })); |
|
|
| |
| app.use('/api', (req, res, next) => { |
| const exempt = ['/turnstile', '/health']; |
| if (req.path === '/db' || req.path.startsWith('/db/')) return next(); |
| if (exempt.includes(req.path)) return next(); |
| const cookieHeader = req.headers.cookie || ''; |
| if (cookieHeader.includes('turnstile=1')) return next(); |
| return res.status(403).json({ error: 'turnstile:required' }); |
| }); |
|
|
| registerFeedbackRoutes(app, { |
| requireAdminTurnstile, |
| verifyLimiter, |
| logAdminEvent, |
| ADMIN_TOKEN, |
| getRequestIp, |
| }); |
|
|
| async function getRequestOwner(req) { |
| const authHeader = req.headers.authorization || ''; |
| const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; |
| if (accessToken) { |
| const user = await verifySupabaseToken(accessToken); |
| if (user) return { owner: { type: 'user', id: user.id }, accessToken }; |
| } |
|
|
| const tempId = String(req.headers['x-temp-id'] || req.query.tempId || '').trim(); |
| if (tempId) { |
| sessionStore.initTemp(tempId); |
| return { owner: { type: 'guest', id: tempId }, accessToken: null }; |
| } |
| return null; |
| } |
|
|
| async function requireRequestOwner(req, res) { |
| const resolved = await getRequestOwner(req); |
| if (!resolved?.owner) { |
| res.status(401).json({ error: 'auth:required' }); |
| return null; |
| } |
| return resolved; |
| } |
|
|
| function getBearerToken(req) { |
| const authHeader = String(req.headers.authorization || '').trim(); |
| if (!authHeader.toLowerCase().startsWith('bearer ')) return ''; |
| return authHeader.slice(7).trim(); |
| } |
|
|
| async function requireJwtUser(req, res) { |
| const accessToken = getBearerToken(req); |
| if (!accessToken) { |
| res.status(401).json({ |
| error: 'auth:required', |
| message: 'Provide Authorization: Bearer <supabase_jwt>.', |
| }); |
| return null; |
| } |
| const user = await verifySupabaseToken(accessToken); |
| if (!user?.id) { |
| res.status(401).json({ |
| error: 'auth:invalid_token', |
| message: 'Supabase JWT is invalid or expired.', |
| }); |
| return null; |
| } |
| return { user, owner: { type: 'user', id: user.id }, accessToken }; |
| } |
|
|
| function queryBool(value, defaultValue = false) { |
| if (value === undefined || value === null || value === '') return defaultValue; |
| const normalized = String(value).trim().toLowerCase(); |
| return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; |
| } |
|
|
| function ensureStringArray(value) { |
| if (!Array.isArray(value)) return []; |
| return value.map((v) => String(v || '').trim()).filter(Boolean); |
| } |
|
|
| function buildDatabaseApiDocs() { |
| return { |
| name: 'InferencePort Local Database API', |
| version: '1.0', |
| auth: { |
| type: 'Bearer Supabase JWT', |
| header: 'Authorization: Bearer <supabase_access_token>', |
| authorizationRule: 'Every request is scoped to the JWT user id. Request payloads cannot override owner/user id.', |
| errors: { |
| unauthenticated: { status: 401, error: 'auth:required' }, |
| invalidToken: { status: 401, error: 'auth:invalid_token' }, |
| }, |
| }, |
| storage: { |
| chats: { |
| root: '/data/chat', |
| encryption: 'AES-256-GCM via DATA_ENCRYPTION_KEY', |
| layout: { |
| userIndex: '/data/chat/users/<userId>/index.json (encrypted metadata only)', |
| sessionBlob: '/data/chat/users/<userId>/sessions/<sessionId>.json (encrypted full chat)', |
| }, |
| startupBehavior: 'No global chat database decrypt at startup. User data decrypt is lazy and per-user/per-session.', |
| }, |
| media: { |
| root: '/data/media', |
| encryption: 'AES-256-GCM for blob and index data', |
| }, |
| }, |
| models: { |
| chatSession: { |
| id: 'string', |
| name: 'string', |
| created: 'epoch milliseconds', |
| model: 'string|null', |
| history: 'array', |
| }, |
| mediaEntry: { |
| id: 'string', |
| type: 'folder|file', |
| name: 'string', |
| parentId: 'string|null', |
| mimeType: 'string|null', |
| kind: 'image|video|audio|text|rich_text|file|null', |
| size: 'number', |
| sessionIds: 'string[]', |
| trashedAt: 'ISO string|null', |
| }, |
| }, |
| endpoints: [ |
| { method: 'GET', path: '/api/db/docs', description: 'This documentation payload.' }, |
| { method: 'GET', path: '/api/db/chats', description: 'List chats. Query: includeHistory=0|1 (default 0).' }, |
| { method: 'POST', path: '/api/db/chats', description: 'Create a chat. Body: {name?, model?, history?, created?}.' }, |
| { method: 'GET', path: '/api/db/chats/:sessionId', description: 'Get one full chat session.' }, |
| { method: 'PATCH', path: '/api/db/chats/:sessionId', description: 'Update chat fields: {name?, model?, history?}.' }, |
| { method: 'DELETE', path: '/api/db/chats/:sessionId', description: 'Delete a chat.' }, |
| { method: 'DELETE', path: '/api/db/chats', description: 'Delete all chats. Body: {confirm:true} required.' }, |
| { method: 'GET', path: '/api/db/media', description: 'List all media. Query: view=all|active|trash (default all).' }, |
| { method: 'GET', path: '/api/db/media/:id', description: 'Get media metadata by id.' }, |
| { method: 'GET', path: '/api/db/media/:id/content', description: 'Get file content. Query: format=base64|text (default base64 for binary).' }, |
| { method: 'POST', path: '/api/db/media/files', description: 'Create file from text/base64. Body supports {name,mimeType,parentId,sessionId,kind,text|base64}.' }, |
| { method: 'POST', path: '/api/db/media/folders', description: 'Create folder. Body: {name,parentId?}.' }, |
| { method: 'PATCH', path: '/api/db/media/:id', description: 'Rename/move media. Body: {name?, parentId?}.' }, |
| { method: 'PUT', path: '/api/db/media/:id/content', description: 'Replace file content. Body supports {text|base64,mimeType?,name?,kind?}.' }, |
| { method: 'POST', path: '/api/db/media/trash', description: 'Move media to trash. Body: {ids:string[]}.' }, |
| { method: 'POST', path: '/api/db/media/restore', description: 'Restore trashed media. Body: {ids:string[]}.' }, |
| { method: 'DELETE', path: '/api/db/media', description: 'Delete forever. Body: {ids:string[]}.' }, |
| { method: 'GET', path: '/api/db/export', description: 'Export chat + media database for current user. Query includeMediaContent=0|1.' }, |
| ], |
| }; |
| } |
|
|
| function requireSignedInMediaOwner(resolved, res, message = 'Sign in to upload files.') { |
| if (resolved?.owner?.type === 'user') return true; |
| res.status(403).json({ error: 'media:auth_required', message }); |
| return false; |
| } |
|
|
| app.get('/health', (_req,res) => res.json({ok:true})); |
|
|
| app.get('/api/share/:token', async (req,res) => { |
| try { |
| const shared = await sessionStore.resolveShareToken(req.params.token); |
| if (!shared) return res.status(404).json({error:'Not found'}); |
| const snap = shared.session_snapshot; |
| res.json({ |
| name: snap.name, |
| preview: (snap.history || []).slice(0,6).map(m=>({ |
| role: m.role, |
| content: (typeof m.content==='string'?m.content:JSON.stringify(m.content)).slice(0,400), |
| })), |
| }); |
| } catch { res.status(500).json({error:'Server error'}); } |
| }); |
|
|
| app.get('/api/db/docs', (_req, res) => { |
| res.json(buildDatabaseApiDocs()); |
| }); |
|
|
| app.get('/api/db/chats', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const includeHistory = queryBool(req.query.includeHistory, false); |
| const listed = await sessionStore.loadUserSessions(resolved.user.id, resolved.accessToken); |
| if (!includeHistory) { |
| return res.json({ |
| items: listed.map((session) => ({ |
| id: session.id, |
| name: session.name, |
| created: session.created, |
| model: session.model || null, |
| })), |
| }); |
| } |
| const items = []; |
| for (const listedSession of listed) { |
| const full = await sessionStore.getUserSessionResolved(resolved.user.id, listedSession.id); |
| if (full) items.push(serializeSessionForClient(full)); |
| } |
| res.json({ items }); |
| } catch (err) { |
| console.error('db chats list error', err); |
| res.status(500).json({ error: 'db:chats_list_failed' }); |
| } |
| }); |
|
|
| app.post('/api/db/chats', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| let created = await sessionStore.createUserSession(resolved.user.id, resolved.accessToken); |
| const patch = {}; |
| if (typeof req.body?.name === 'string' && req.body.name.trim()) patch.name = req.body.name.trim(); |
| if (typeof req.body?.model === 'string' && req.body.model.trim()) patch.model = req.body.model.trim(); |
| if (Array.isArray(req.body?.history)) patch.history = req.body.history; |
| if (Number.isFinite(req.body?.created)) patch.created = req.body.created; |
| if (Object.keys(patch).length) { |
| created = await sessionStore.updateUserSession(resolved.user.id, resolved.accessToken, created.id, patch); |
| } |
| res.status(201).json({ item: serializeSessionForClient(created) }); |
| } catch (err) { |
| console.error('db chat create error', err); |
| res.status(500).json({ error: 'db:chat_create_failed' }); |
| } |
| }); |
|
|
| app.get('/api/db/chats/:sessionId', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const session = await sessionStore.getUserSessionResolved(resolved.user.id, req.params.sessionId); |
| if (!session) return res.status(404).json({ error: 'db:chat_not_found' }); |
| res.json({ item: serializeSessionForClient(session) }); |
| } catch (err) { |
| console.error('db chat get error', err); |
| res.status(500).json({ error: 'db:chat_get_failed' }); |
| } |
| }); |
|
|
| app.patch('/api/db/chats/:sessionId', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const patch = {}; |
| if (typeof req.body?.name === 'string') patch.name = req.body.name.trim() || 'New Chat'; |
| if (typeof req.body?.model === 'string') patch.model = req.body.model.trim() || null; |
| if (req.body?.model === null) patch.model = null; |
| if (Array.isArray(req.body?.history)) patch.history = req.body.history; |
| if (Number.isFinite(req.body?.created)) patch.created = req.body.created; |
| const updated = await sessionStore.updateUserSession( |
| resolved.user.id, |
| resolved.accessToken, |
| req.params.sessionId, |
| patch |
| ); |
| if (!updated) return res.status(404).json({ error: 'db:chat_not_found' }); |
| res.json({ item: serializeSessionForClient(updated) }); |
| } catch (err) { |
| console.error('db chat patch error', err); |
| res.status(500).json({ error: 'db:chat_update_failed' }); |
| } |
| }); |
|
|
| app.delete('/api/db/chats/:sessionId', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const existing = await sessionStore.getUserSessionResolved(resolved.user.id, req.params.sessionId); |
| if (!existing) return res.status(404).json({ error: 'db:chat_not_found' }); |
| await sessionStore.deleteUserSession(resolved.user.id, resolved.accessToken, req.params.sessionId); |
| res.json({ ok: true, id: req.params.sessionId }); |
| } catch (err) { |
| console.error('db chat delete error', err); |
| res.status(500).json({ error: 'db:chat_delete_failed' }); |
| } |
| }); |
|
|
| app.delete('/api/db/chats', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| if (req.body?.confirm !== true) { |
| return res.status(400).json({ |
| error: 'db:confirm_required', |
| message: 'Send {\"confirm\":true} to delete all chats.', |
| }); |
| } |
| try { |
| await sessionStore.deleteAllUserSessions(resolved.user.id, resolved.accessToken); |
| res.json({ ok: true }); |
| } catch (err) { |
| console.error('db chats delete-all error', err); |
| res.status(500).json({ error: 'db:chats_delete_all_failed' }); |
| } |
| }); |
|
|
| app.get('/api/db/media', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const requestedView = String(req.query.view || 'all').toLowerCase(); |
| const view = ['all', 'active', 'trash'].includes(requestedView) ? requestedView : 'all'; |
| const result = await mediaStore.listAll(resolved.owner, { view }); |
| res.json(result); |
| } catch (err) { |
| console.error('db media list error', err); |
| res.status(500).json({ error: 'db:media_list_failed' }); |
| } |
| }); |
|
|
| app.get('/api/db/media/:id', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const item = await mediaStore.get(resolved.owner, req.params.id); |
| if (!item) return res.status(404).json({ error: 'db:media_not_found' }); |
| res.json({ item }); |
| } catch (err) { |
| console.error('db media get error', err); |
| res.status(500).json({ error: 'db:media_get_failed' }); |
| } |
| }); |
|
|
| app.get('/api/db/media/:id/content', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const loaded = await mediaStore.readBuffer(resolved.owner, req.params.id); |
| if (!loaded) return res.status(404).json({ error: 'db:media_not_found' }); |
| const format = String(req.query.format || '').toLowerCase(); |
| if (format === 'text' || (loaded.entry.mimeType || '').startsWith('text/')) { |
| return res.json({ |
| item: loaded.entry, |
| encoding: 'utf8', |
| content: loaded.buffer.toString('utf8'), |
| }); |
| } |
| res.json({ |
| item: loaded.entry, |
| encoding: 'base64', |
| content: loaded.buffer.toString('base64'), |
| }); |
| } catch (err) { |
| console.error('db media content error', err); |
| res.status(500).json({ error: 'db:media_content_failed' }); |
| } |
| }); |
|
|
| app.post('/api/db/media/files', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const text = typeof req.body?.text === 'string' ? req.body.text : null; |
| const base64 = typeof req.body?.base64 === 'string' ? req.body.base64 : null; |
| if (text === null && base64 === null) { |
| return res.status(400).json({ |
| error: 'db:content_required', |
| message: 'Provide either text or base64 in request body.', |
| }); |
| } |
| const buffer = text !== null ? Buffer.from(text, 'utf8') : Buffer.from(base64, 'base64'); |
| const item = await mediaStore.storeBuffer(resolved.owner, { |
| name: req.body?.name || 'upload.bin', |
| mimeType: req.body?.mimeType || 'application/octet-stream', |
| buffer, |
| parentId: req.body?.parentId || null, |
| sessionId: req.body?.sessionId || null, |
| source: req.body?.source || 'api_db', |
| kind: req.body?.kind || null, |
| }); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.status(201).json({ item, usage }); |
| } catch (err) { |
| console.error('db media create file error', err); |
| res.status(err.status || 500).json({ |
| error: err.code || 'db:media_create_file_failed', |
| message: err.message || 'Failed to create file.', |
| usage: err.usage || null, |
| }); |
| } |
| }); |
|
|
| app.post('/api/db/media/folders', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const item = await mediaStore.createFolder(resolved.owner, { |
| name: req.body?.name || 'New Folder', |
| parentId: req.body?.parentId || null, |
| }); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.status(201).json({ item, usage }); |
| } catch (err) { |
| console.error('db media create folder error', err); |
| res.status(500).json({ error: 'db:media_create_folder_failed' }); |
| } |
| }); |
|
|
| app.patch('/api/db/media/:id', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const updates = []; |
| if (typeof req.body?.parentId !== 'undefined') { |
| const moved = await mediaStore.move( |
| resolved.owner, |
| [req.params.id], |
| req.body?.parentId || null |
| ); |
| if (!moved.length) return res.status(404).json({ error: 'db:media_not_found_or_move_failed' }); |
| updates.push(...moved); |
| } |
| if (typeof req.body?.name === 'string') { |
| const renamed = await mediaStore.rename(resolved.owner, req.params.id, req.body.name); |
| if (!renamed) return res.status(404).json({ error: 'db:media_not_found' }); |
| updates.push(renamed); |
| } |
| const item = await mediaStore.get(resolved.owner, req.params.id); |
| if (!item) return res.status(404).json({ error: 'db:media_not_found' }); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ item, updates, usage }); |
| } catch (err) { |
| console.error('db media patch error', err); |
| res.status(500).json({ error: 'db:media_update_failed', message: err.message || 'Update failed' }); |
| } |
| }); |
|
|
| app.put('/api/db/media/:id/content', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const text = typeof req.body?.text === 'string' ? req.body.text : null; |
| const base64 = typeof req.body?.base64 === 'string' ? req.body.base64 : null; |
| if (text === null && base64 === null) { |
| return res.status(400).json({ |
| error: 'db:content_required', |
| message: 'Provide either text or base64 in request body.', |
| }); |
| } |
| const buffer = text !== null ? Buffer.from(text, 'utf8') : Buffer.from(base64, 'base64'); |
| const item = await mediaStore.updateContent(resolved.owner, req.params.id, { |
| buffer, |
| name: typeof req.body?.name === 'string' ? req.body.name : null, |
| mimeType: typeof req.body?.mimeType === 'string' ? req.body.mimeType : null, |
| kind: typeof req.body?.kind === 'string' ? req.body.kind : null, |
| }); |
| if (!item) return res.status(404).json({ error: 'db:media_not_found' }); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ item, usage }); |
| } catch (err) { |
| console.error('db media content update error', err); |
| res.status(err.status || 500).json({ |
| error: err.code || 'db:media_content_update_failed', |
| message: err.message || 'Update failed', |
| usage: err.usage || null, |
| }); |
| } |
| }); |
|
|
| app.post('/api/db/media/trash', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const ids = ensureStringArray(req.body?.ids); |
| const items = await mediaStore.moveToTrash(resolved.owner, ids); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ items, usage }); |
| } catch (err) { |
| console.error('db media trash error', err); |
| res.status(500).json({ error: 'db:media_trash_failed' }); |
| } |
| }); |
|
|
| app.post('/api/db/media/restore', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const ids = ensureStringArray(req.body?.ids); |
| const items = await mediaStore.restore(resolved.owner, ids); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ items, usage }); |
| } catch (err) { |
| console.error('db media restore error', err); |
| res.status(500).json({ error: 'db:media_restore_failed' }); |
| } |
| }); |
|
|
| app.delete('/api/db/media', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const ids = ensureStringArray(req.body?.ids); |
| const removedIds = await mediaStore.deleteForever(resolved.owner, ids); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ ids: removedIds, usage }); |
| } catch (err) { |
| console.error('db media delete error', err); |
| res.status(500).json({ error: 'db:media_delete_failed' }); |
| } |
| }); |
|
|
| app.get('/api/db/export', async (req, res) => { |
| const resolved = await requireJwtUser(req, res); |
| if (!resolved) return; |
| try { |
| const includeMediaContent = queryBool(req.query.includeMediaContent, false); |
| const listed = await sessionStore.loadUserSessions(resolved.user.id, resolved.accessToken); |
| const chats = []; |
| for (const listedSession of listed) { |
| const full = await sessionStore.getUserSessionResolved(resolved.user.id, listedSession.id); |
| if (full) chats.push(serializeSessionForClient(full)); |
| } |
|
|
| const mediaResult = await mediaStore.listAll(resolved.owner, { view: 'all' }); |
| let media = mediaResult.items; |
| if (includeMediaContent) { |
| const withContent = []; |
| for (const item of mediaResult.items) { |
| if (item.type !== 'file') { |
| withContent.push(item); |
| continue; |
| } |
| const loaded = await mediaStore.readBuffer(resolved.owner, item.id); |
| withContent.push({ |
| ...item, |
| contentEncoding: 'base64', |
| content: loaded?.buffer ? loaded.buffer.toString('base64') : null, |
| }); |
| } |
| media = withContent; |
| } |
|
|
| res.json({ |
| userId: resolved.user.id, |
| exportedAt: new Date().toISOString(), |
| chats, |
| media, |
| usage: mediaResult.usage, |
| }); |
| } catch (err) { |
| console.error('db export error', err); |
| res.status(500).json({ error: 'db:export_failed' }); |
| } |
| }); |
|
|
| app.get('/api/media', async (req, res) => { |
| const resolved = await requireRequestOwner(req, res); |
| if (!resolved) return; |
| try { |
| const result = await mediaStore.list(resolved.owner, { |
| view: req.query.view === 'trash' ? 'trash' : 'active', |
| parentId: req.query.parentId ? String(req.query.parentId) : null, |
| }); |
| res.json(result); |
| } catch (err) { |
| console.error('media list error', err); |
| res.status(500).json({ error: 'media:list_failed' }); |
| } |
| }); |
|
|
| app.post('/api/media/upload', express.raw({ type: () => true, limit: '100mb' }), async (req, res) => { |
| const resolved = await requireRequestOwner(req, res); |
| if (!resolved) return; |
| if (!requireSignedInMediaOwner(resolved, res, 'Sign in to upload files.')) return; |
| try { |
| const name = decodeURIComponent(String(req.headers['x-file-name'] || 'upload.bin')); |
| const mimeType = String(req.headers['x-mime-type'] || 'application/octet-stream'); |
| const parentId = req.headers['x-parent-id'] ? String(req.headers['x-parent-id']) : null; |
| const sessionId = req.headers['x-session-id'] ? String(req.headers['x-session-id']) : null; |
| const kindHeader = req.headers['x-file-kind'] ? String(req.headers['x-file-kind']) : null; |
| const buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || []); |
| if (isTextLikeUpload(name, mimeType, kindHeader) && buffer.byteLength > MAX_TEXT_UPLOAD_BYTES) { |
| return respondTextUploadTooLarge(res); |
| } |
| const item = await mediaStore.storeBuffer(resolved.owner, { |
| name, |
| mimeType, |
| buffer, |
| parentId, |
| sessionId, |
| source: 'user_upload', |
| kind: kindHeader || null, |
| }); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ item, usage }); |
| } catch (err) { |
| console.error('media upload error', err); |
| res.status(err.status || 500).json({ |
| error: err.code || 'media:upload_failed', |
| message: err.message || 'Upload failed', |
| usage: err.usage || null, |
| }); |
| } |
| }); |
|
|
| app.post('/api/media/folders', async (req, res) => { |
| const resolved = await requireRequestOwner(req, res); |
| if (!resolved) return; |
| if (!requireSignedInMediaOwner(resolved, res, 'Sign in to create folders.')) return; |
| try { |
| const item = await mediaStore.createFolder(resolved.owner, { |
| name: req.body?.name || 'New Folder', |
| parentId: req.body?.parentId || null, |
| }); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ item, usage }); |
| } catch (err) { |
| console.error('media create folder error', err); |
| res.status(500).json({ error: 'media:create_folder_failed' }); |
| } |
| }); |
|
|
| app.post('/api/media/documents', async (req, res) => { |
| const resolved = await requireRequestOwner(req, res); |
| if (!resolved) return; |
| if (!requireSignedInMediaOwner(resolved, res, 'Sign in to create documents.')) return; |
| try { |
| const richText = !!req.body?.richText; |
| const content = String(req.body?.content || ''); |
| if (Buffer.byteLength(content, 'utf8') > MAX_TEXT_UPLOAD_BYTES) { |
| return respondTextUploadTooLarge(res); |
| } |
| const item = await mediaStore.createDocument(resolved.owner, { |
| name: req.body?.name, |
| parentId: req.body?.parentId || null, |
| richText, |
| content, |
| source: 'user_upload', |
| sessionId: req.body?.sessionId || null, |
| }); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ item, usage }); |
| } catch (err) { |
| console.error('media create document error', err); |
| res.status(err.status || 500).json({ |
| error: err.code || 'media:create_document_failed', |
| message: err.message || 'Document creation failed', |
| usage: err.usage || null, |
| }); |
| } |
| }); |
|
|
| app.post('/api/media/move', async (req, res) => { |
| const resolved = await requireRequestOwner(req, res); |
| if (!resolved) return; |
| if (!requireSignedInMediaOwner(resolved, res, 'Sign in to move files.')) return; |
| try { |
| const items = await mediaStore.move(resolved.owner, req.body?.ids || [], req.body?.parentId || null); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ items, usage }); |
| } catch (err) { |
| console.error('media move error', err); |
| res.status(500).json({ error: 'media:move_failed' }); |
| } |
| }); |
|
|
| app.post('/api/media/trash', async (req, res) => { |
| const resolved = await requireRequestOwner(req, res); |
| if (!resolved) return; |
| try { |
| const items = await mediaStore.moveToTrash(resolved.owner, req.body?.ids || []); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ items, usage }); |
| } catch (err) { |
| console.error('media trash error', err); |
| res.status(500).json({ error: 'media:trash_failed' }); |
| } |
| }); |
|
|
| app.post('/api/media/restore', async (req, res) => { |
| const resolved = await requireRequestOwner(req, res); |
| if (!resolved) return; |
| if (!requireSignedInMediaOwner(resolved, res, 'Sign in to restore files.')) return; |
| try { |
| const items = await mediaStore.restore(resolved.owner, req.body?.ids || []); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ items, usage }); |
| } catch (err) { |
| console.error('media restore error', err); |
| res.status(500).json({ error: 'media:restore_failed' }); |
| } |
| }); |
|
|
| app.post('/api/media/deleteForever', async (req, res) => { |
| const resolved = await requireRequestOwner(req, res); |
| if (!resolved) return; |
| try { |
| const ids = await mediaStore.deleteForever(resolved.owner, req.body?.ids || []); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ ids, usage }); |
| } catch (err) { |
| console.error('media delete forever error', err); |
| res.status(500).json({ error: 'media:delete_failed' }); |
| } |
| }); |
|
|
| app.get('/api/media/:id/text', async (req, res) => { |
| const resolved = await requireRequestOwner(req, res); |
| if (!resolved) return; |
| try { |
| const loaded = await mediaStore.readText(resolved.owner, req.params.id); |
| if (!loaded) return res.status(404).json({ error: 'media:not_found' }); |
| res.json({ item: loaded.entry, content: loaded.text }); |
| } catch (err) { |
| console.error('media read text error', err); |
| res.status(500).json({ error: 'media:read_failed' }); |
| } |
| }); |
|
|
| app.put('/api/media/:id/text', async (req, res) => { |
| const resolved = await requireRequestOwner(req, res); |
| if (!resolved) return; |
| if (!requireSignedInMediaOwner(resolved, res, 'Sign in to edit files.')) return; |
| try { |
| const buffer = Buffer.from(String(req.body?.content || ''), 'utf8'); |
| if (buffer.byteLength > MAX_TEXT_UPLOAD_BYTES) { |
| return respondTextUploadTooLarge(res); |
| } |
| const item = await mediaStore.updateContent(resolved.owner, req.params.id, { |
| buffer, |
| mimeType: req.body?.mimeType || null, |
| kind: req.body?.richText ? 'rich_text' : null, |
| name: req.body?.name || null, |
| }); |
| if (!item) return res.status(404).json({ error: 'media:not_found' }); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ item, usage }); |
| } catch (err) { |
| console.error('media update text error', err); |
| res.status(err.status || 500).json({ |
| error: err.code || 'media:update_failed', |
| message: err.message || 'Update failed', |
| usage: err.usage || null, |
| }); |
| } |
| }); |
|
|
| app.post('/api/media/:id/rename', async (req, res) => { |
| const resolved = await requireRequestOwner(req, res); |
| if (!resolved) return; |
| if (!requireSignedInMediaOwner(resolved, res, 'Sign in to rename files.')) return; |
| try { |
| const name = String(req.body?.name || '').trim(); |
| if (!name) return res.status(400).json({ error: 'media:name_required', message: 'A file name is required.' }); |
| const item = await mediaStore.rename(resolved.owner, req.params.id, name); |
| if (!item) return res.status(404).json({ error: 'media:not_found' }); |
| const usage = await mediaStore.getUsage(resolved.owner); |
| res.json({ item, usage }); |
| } catch (err) { |
| console.error('media rename error', err); |
| res.status(500).json({ error: 'media:rename_failed', message: err.message || 'Rename failed' }); |
| } |
| }); |
|
|
| app.get('/api/media/:id/content', async (req, res) => { |
| const resolved = await requireRequestOwner(req, res); |
| if (!resolved) return; |
| try { |
| const loaded = await mediaStore.readBuffer(resolved.owner, req.params.id); |
| if (!loaded) return res.status(404).json({ error: 'media:not_found' }); |
| res.setHeader('Content-Type', loaded.entry.mimeType || 'application/octet-stream'); |
| res.setHeader('Cache-Control', 'private, max-age=60'); |
| if (req.query.download === '1') { |
| res.setHeader('Content-Disposition', `attachment; filename="${loaded.entry.name}"`); |
| } |
| res.send(loaded.buffer); |
| } catch (err) { |
| console.error('media read binary error', err); |
| res.status(500).json({ error: 'media:read_failed' }); |
| } |
| }); |
|
|
| app.post('/api/turnstile', async (req, res) => { |
| try { |
| const token = req.body?.token; |
| const j = await verifyTurnstileToken(token, getRequestIp(req)); |
| if (j?.success) { |
| res.cookie('turnstile', '1', { maxAge: 24 * 3600 * 1000, path: '/', sameSite: 'lax' }); |
| const confirmToken = crypto.randomUUID(); |
| pendingTurnstileTokens.set(confirmToken, Date.now() + 60_000); |
| return res.json({ success: true, confirmToken }); |
| } |
| return res.status(403).json({ error: 'Verification failed' }); |
| } catch (e) { |
| console.error('turnstile verify', e); |
| return res.status(500).json({ error: 'Server error' }); |
| } |
| }); |
|
|
| export { pendingTurnstileTokens }; |
|
|
| async function fetchLatestSHA() { |
| try { |
| const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/commits/main`); |
| const data = await res.json(); |
| latestSHA = data.sha; |
| console.log(`[${PUBLIC_URL}] Updated latest SHA: ${latestSHA}`); |
| saveStoredSHA(latestSHA); |
| } catch (e) { |
| console.error('Failed to fetch latest commit SHA', e); |
| } |
| } |
|
|
| latestSHA = loadStoredSHA(); |
| if (!latestSHA) { |
| console.log(`[${PUBLIC_URL}] No stored SHA found.`); |
| latestSHA = "latest"; |
| saveStoredSHA(latestSHA); |
| } else { |
| console.log(`[${PUBLIC_URL}] Using stored SHA: ${latestSHA}`); |
| } |
|
|
| |
| app.get('/admin.html', async (req, res) => { |
| logAdminEvent(req, 'page_view'); |
| if (serveLocalUiFile('/admin.html', res)) return; |
| if (!latestSHA) return res.status(500).send('Server not ready'); |
|
|
| const url = `${CDN_BASE}@${latestSHA}/admin.html`; |
|
|
| try { |
| const response = await fetch(url); |
| if (!response.ok) return res.status(404).send('File not found'); |
|
|
| const text = await response.text(); |
| res.setHeader('Content-Type', 'text/html'); |
| res.send(text); |
| } catch (e) { |
| console.error('Error fetching admin.html from CDN:', e); |
| res.status(500).send('Server error'); |
| } |
| }); |
|
|
| app.get('/admin/config', (req, res) => { |
| logAdminEvent(req, 'config_view', { verified: hasAdminTurnstile(req) }); |
| res.json({ |
| siteKey: TURNSTILE_SITE_KEY || null, |
| verified: hasAdminTurnstile(req), |
| }); |
| }); |
|
|
| app.post('/admin/turnstile', async (req, res) => { |
| try { |
| const token = req.body?.token; |
| const result = await verifyTurnstileToken(token, getRequestIp(req)); |
| if (!result?.success) { |
| logAdminEvent(req, 'turnstile_failed', { |
| errorCodes: Array.isArray(result?.['error-codes']) ? result['error-codes'].join(',') : '', |
| }); |
| return res.status(403).json({ error: 'Verification failed' }); |
| } |
| res.cookie('admin_turnstile', '1', { maxAge: 2 * 3600 * 1000, path: '/', sameSite: 'lax' }); |
| logAdminEvent(req, 'turnstile_verified'); |
| return res.json({ success: true }); |
| } catch (err) { |
| console.error('admin turnstile verify', err); |
| logAdminEvent(req, 'turnstile_error', { message: err.message || 'unknown' }); |
| return res.status(500).json({ error: err.message || 'Server error' }); |
| } |
| }); |
|
|
| app.get('/admin/verify', requireAdminTurnstile, verifyLimiter, (req,res)=>{ |
| const token = req.query.token; |
| const success = token===ADMIN_TOKEN; |
| logAdminEvent(req, success ? 'login_success' : 'login_failed', { |
| turnstile: hasAdminTurnstile(req), |
| tokenProvided: !!token, |
| }); |
| res.json({success}); |
| }); |
|
|
| app.get('/admin/refresh', requireAdminTurnstile, verifyLimiter, async (req, res) => { |
| const token = req.query.token; |
| if (token !== ADMIN_TOKEN) { |
| logAdminEvent(req, 'refresh_denied', { reason: 'bad_token' }); |
| return res.status(403).send('Forbidden'); |
| } |
|
|
| const sha = req.query.sha?.trim(); |
| if (sha) { |
| if (!/^[0-9a-f]{7,40}$/.test(sha)) { |
| logAdminEvent(req, 'set_sha_invalid', { requestedSha: sha }); |
| return res.status(400).send('Invalid SHA'); |
| } |
| latestSHA = sha; |
| saveStoredSHA(latestSHA); |
| logAdminEvent(req, 'set_sha', { requestedSha: latestSHA }); |
| console.log(`[${PUBLIC_URL}] Manual SHA set by admin: ${latestSHA}`); |
| return res.send(`Version set to commit ${latestSHA}`); |
| } |
|
|
| await fetchLatestSHA(); |
| logAdminEvent(req, 'refresh_latest', { resolvedSha: latestSHA }); |
| res.send(`Latest version refreshed: ${latestSHA}`); |
| }); |
|
|
| app.get('/admin/status', requireAdminTurnstile, verifyLimiter, async (req, res) => { |
| const token = req.query.token; |
| if (token !== ADMIN_TOKEN) { |
| logAdminEvent(req, 'status_denied', { reason: 'bad_token' }); |
| return res.status(403).json({ error: 'Forbidden' }); |
| } |
| logAdminEvent(req, 'status_view', { |
| currentSha: latestSHA, |
| servingMode: LOCAL_UI_DIR ? 'local-ui' : 'cdn', |
| }); |
| res.json({ |
| publicUrl: PUBLIC_URL, |
| currentSha: latestSHA, |
| servingMode: LOCAL_UI_DIR ? 'local-ui' : 'cdn', |
| localUiDir: LOCAL_UI_DIR, |
| repo: GITHUB_REPO, |
| uiSource: LOCAL_UI_DIR ? 'Local InferencePort-Pages checkout is active' : 'Serving from CDN SHA', |
| }); |
| }); |
|
|
| |
| function getMimeType(filePath){ |
| const ext = path.extname(filePath).toLowerCase(); |
| switch(ext){ |
| case '.html': return 'text/html'; |
| case '.js': return 'application/javascript'; |
| case '.css': return 'text/css'; |
| case '.json': return 'application/json'; |
| case '.png': return 'image/png'; |
| case '.jpg': |
| case '.jpeg': return 'image/jpeg'; |
| case '.gif': return 'image/gif'; |
| case '.svg': return 'image/svg+xml'; |
| case '.ico': return 'image/x-icon'; |
| default: return 'application/octet-stream'; |
| } |
| } |
|
|
| function resolveLocalUiFile(filePath) { |
| if (!LOCAL_UI_DIR) return null; |
| const resolvedRoot = path.resolve(LOCAL_UI_DIR); |
| const relativePath = String(filePath || '/index.html').replace(/^[/\\]+/, ''); |
| const resolvedFile = path.resolve(resolvedRoot, relativePath); |
| if (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(`${resolvedRoot}${path.sep}`)) return null; |
| if (!fs.existsSync(resolvedFile) || fs.statSync(resolvedFile).isDirectory()) return null; |
| return resolvedFile; |
| } |
|
|
| function serveLocalUiFile(filePath, res) { |
| const resolvedFile = resolveLocalUiFile(filePath); |
| if (!resolvedFile) return false; |
| res.setHeader('Content-Type', getMimeType(resolvedFile)); |
| res.send(fs.readFileSync(resolvedFile)); |
| return true; |
| } |
|
|
| |
| app.get('*', async (req,res)=>{ |
| if(req.path.startsWith('/api/')) return res.status(404).send('Not found'); |
|
|
| |
| const filePath = req.path === '/' ? '/index.html' : req.path; |
| if (serveLocalUiFile(filePath, res)) return; |
| const url = `${CDN_BASE}@${latestSHA}${filePath}`; |
|
|
| try{ |
| const response = await fetch(url); |
| if(!response.ok) return res.status(404).send('File not found'); |
|
|
| const mimeType = getMimeType(filePath); |
| res.setHeader('Content-Type', mimeType); |
|
|
| |
| if(mimeType.startsWith('text') || mimeType==='application/javascript' || mimeType==='application/json'){ |
| const text = await response.text(); |
| res.send(text); |
| } else { |
| const buffer = await response.arrayBuffer(); |
| res.send(Buffer.from(buffer)); |
| } |
| }catch(e){ |
| console.error('Error fetching from CDN:',e); |
| res.status(500).send('Server error'); |
| } |
| }); |
|
|
| |
| const httpServer = createServer(app); |
| const wss = new WebSocketServer({server:httpServer,path:'/ws'}); |
|
|
| export const wsClients = new Map(); |
|
|
| wss.on('connection',(ws,req)=>{ |
| const ip = (req.headers['x-forwarded-for']||'').split(',')[0].trim()||req.socket.remoteAddress||'unknown'; |
| const userAgent = req.headers['user-agent']||'unknown'; |
| const cookies = (req.headers.cookie||'').split(';').map(s=>s.trim()).filter(Boolean); |
| const cookieMap = Object.fromEntries(cookies.map(c=>{ const i=c.indexOf('='); return [c.slice(0,i),c.slice(i+1)];})); |
| const verified = cookieMap.turnstile==='1'; |
|
|
| wsClients.set(ws,{ tempId:crypto.randomUUID(), ip, userAgent, userId:null, authenticated:false, verified }); |
|
|
| ws.on('message',async raw=>{ |
| try{ await handleWsMessage(ws,JSON.parse(raw.toString()),wsClients);} |
| catch(ex){ console.error("Invalid message error:",ex.message,"\nStack:",ex.stack); safeSend(ws,{type:'error',message:'Invalid message: '+ex.message}); } |
| }); |
|
|
| ws.on('close',()=>{ |
| abortActiveStream(ws); |
| const c=wsClients.get(ws); |
| if(c?.userId) sessionStore.markOffline(c.userId,ws); |
| wsClients.delete(ws); |
| }); |
| ws.on('error',()=>{ |
| abortActiveStream(ws); |
| wsClients.delete(ws); |
| }); |
| safeSend(ws,{type:'connected', tempId:wsClients.get(ws)?.tempId}); |
| }); |
|
|
| httpServer.listen(PORT,'0.0.0.0',()=>console.log(`Running on port ${PORT}`)); |
|
|