chat-dev / server /index.js
sharktide's picture
Update server/index.js
c04c926 verified
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;
// 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 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'));
// Find SHA for this PUBLIC_URL
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' }));
// --- API Turnstile Protection ---
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}`);
}
// --- 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;
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',
});
});
// --- 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}`));