Spaces:
Runtime error
Runtime error
| 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 { SUPABASE_URL, SUPABASE_ANON_KEY } from './config.js'; | |
| import { safeSend } from './helpers.js'; | |
| import { getPostgresBackendType, initializePostgresStorage } from './postgres.js'; | |
| import { POSTGRES_STORAGE_DB_DIR, POSTGRES_STORAGE_DIR, refreshStorageMode } from './dataPaths.js'; | |
| import { materializeEmbeddedPostgresStorage, migrateLegacyDataToPostgres } from './postgresMigration.js'; | |
| import { loadStoredSHA, saveStoredSHA } from './versionStore.js'; | |
| export { SUPABASE_URL, SUPABASE_ANON_KEY }; | |
| export { LIGHTNING_BASE, PUBLIC_URL } from './config.js'; | |
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| export const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); | |
| const PORT = process.env.PORT || 7860; | |
| const app = express(); | |
| const GITHUB_REPO = 'incognitolm/InferencePort-Pages'; | |
| 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; | |
| // Rate limiter for admin endpoints (5 attempts per IP per minute) | |
| 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 PUBLIC_URL = process.env.PUBLIC_URL || 'default'; | |
| async function ensureStorageBackendReady() { | |
| let mode = refreshStorageMode(); | |
| const autoBootstrap = process.env.POSTGRES_AUTO_BOOTSTRAP !== '0'; | |
| if (mode !== 'postgres' && autoBootstrap) { | |
| console.log(`[storage] PostgreSQL storage folder missing at ${POSTGRES_STORAGE_DIR}; starting automatic bootstrap.`); | |
| const report = await migrateLegacyDataToPostgres({ skipIfFolderExists: true }); | |
| mode = refreshStorageMode(); | |
| if (report?.skipped) { | |
| console.log(`[storage] PostgreSQL storage folder became available during bootstrap check.`); | |
| } else { | |
| console.log(`[storage] automatic PostgreSQL bootstrap ${report.status} at ${POSTGRES_STORAGE_DIR}`); | |
| } | |
| } | |
| if (mode === 'postgres') { | |
| const materialized = await materializeEmbeddedPostgresStorage(); | |
| if (materialized?.upgraded && materialized?.report) { | |
| console.log(`[storage] materialized embedded PostgreSQL database at ${POSTGRES_STORAGE_DB_DIR}`); | |
| } | |
| await initializePostgresStorage(); | |
| } | |
| const backend = mode === 'postgres' ? getPostgresBackendType() : 'legacy'; | |
| const target = mode === 'postgres' | |
| ? (backend === 'embedded' ? POSTGRES_STORAGE_DB_DIR : POSTGRES_STORAGE_DIR) | |
| : '/data'; | |
| console.log(`[storage] mode=${mode}${mode === 'postgres' ? ` backend=${backend}` : ''} target=${target}`); | |
| return mode; | |
| } | |
| await ensureStorageBackendReady(); | |
| const { sessionStore, initStoreConfig } = await import('./sessionStore.js'); | |
| const { abortActiveStream, handleWsMessage } = await import('./wsHandler.js'); | |
| const { verifySupabaseToken } = await import('./auth.js'); | |
| const { mediaStore } = await import('./mediaStore.js'); | |
| initStoreConfig(SUPABASE_URL, SUPABASE_ANON_KEY); | |
| 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(); | |
| } | |
| app.use(express.json({ limit: '10mb' })); | |
| // --- API Turnstile Protection --- | |
| app.use('/api', (req, res, next) => { | |
| const exempt = ['/turnstile', '/health']; | |
| 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 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/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'}); | |
| return res.json({success:true}); | |
| } | |
| return res.status(403).json({error:'Verification failed'}); | |
| }catch(e){ console.error('turnstile verify',e); return res.status(500).json({error:'Server error'});} | |
| }); | |
| 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}`); | |
| await saveStoredSHA(PUBLIC_URL, latestSHA); | |
| } catch (e) { | |
| console.error('Failed to fetch latest commit SHA', e); | |
| } | |
| } | |
| latestSHA = await loadStoredSHA(PUBLIC_URL); | |
| if (!latestSHA) { | |
| console.log(`[${PUBLIC_URL}] No stored SHA found.`); | |
| latestSHA = "latest"; | |
| await saveStoredSHA(PUBLIC_URL, latestSHA); | |
| } else { | |
| console.log(`[${PUBLIC_URL}] Using stored SHA: ${latestSHA}`); | |
| } | |
| // --- Admin endpoints --- | |
| 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; | |
| await saveStoredSHA(PUBLIC_URL, 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', | |
| }); | |
| }); | |
| // --- MIME type helper --- | |
| 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; | |
| } | |
| // Hybrid serving: everything via latest SHA (auto-refresh / manual refresh) | |
| app.get('*', async (req,res)=>{ | |
| if(req.path.startsWith('/api/')) return res.status(404).send('Not found'); | |
| // All client files are fetched from latest SHA | |
| 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); | |
| // Stream text files; redirect others (images/fonts) optional | |
| 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'); | |
| } | |
| }); | |
| // --- WebSocket --- | |
| 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}`)); | |