forked from elmadani/ix-tools
feat: Add SaaS backend source - server.js + demo_module.js
- server.js: Full SaaS backend (auth, instances, store, billing, admin) - demo_module.js: Free demo system (OneCloud VMs, SSE telemetry, provider pool) Plans: Community (free) | Studio (test) | Enterprise (test)
This commit is contained in:
parent
5b3d4e0e1d
commit
f78d405a25
742
site/saas/demo_module.js
Normal file
742
site/saas/demo_module.js
Normal file
@ -0,0 +1,742 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// IX DEMO MODULE — Free Sessions, No Registration
|
||||||
|
// Real OneCloud instances · 30min TTL · Auto-destroy
|
||||||
|
// Provider Pool · Live Telemetry via SSE
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// ── DEMO CONFIG ──────────────────────────────────────────────────
|
||||||
|
const DEMO_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
const DEMO_INSTANCE_CFG = { vcpu: 2, ram: 2, disk: 50, size_id: '87' }; // pro size
|
||||||
|
const DEMO_MODEL = 'llama3.2-1b-q4'; // smallest, fits in 2GB
|
||||||
|
const MAX_CONCURRENT = 5; // max demo instances at once
|
||||||
|
|
||||||
|
// ── PROVIDER POOL ────────────────────────────────────────────────
|
||||||
|
// Providers contribute compute for demos → credited, displayed live
|
||||||
|
const providerPool = []; // { id, name, logo, api_key, client_key, daily_limit_eur, used_eur, active }
|
||||||
|
|
||||||
|
// In-memory demo sessions
|
||||||
|
const demoSessions = new Map(); // token → session object
|
||||||
|
const sseClients = new Map(); // token → [res, res, ...]
|
||||||
|
let totalDemoCount = 0;
|
||||||
|
let todayDemoCount = 0;
|
||||||
|
let lastDayReset = new Date().toDateString();
|
||||||
|
|
||||||
|
function resetDailyCount() {
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
if (today !== lastDayReset) { todayDemoCount = 0; lastDayReset = today; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ONECLOUD REQUEST (per-provider or main keys) ─────────────────
|
||||||
|
function ocReq(method, endpoint, params = {}, keys = null) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const apiKey = keys?.api_key || process.env.ONECLOUD_API_KEY;
|
||||||
|
const clientKey = keys?.client_key || process.env.ONECLOUD_CLIENT_KEY;
|
||||||
|
if (!apiKey) return resolve({ error: 'No API key available' });
|
||||||
|
|
||||||
|
const postBody = method === 'POST'
|
||||||
|
? Object.entries(params).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
|
||||||
|
: '';
|
||||||
|
const getQuery = method === 'GET' && Object.keys(params).length
|
||||||
|
? '?' + Object.entries(params).map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: 'api.oneprovider.com',
|
||||||
|
path: endpoint + getQuery,
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Api-Key': apiKey,
|
||||||
|
'Client-Key': clientKey,
|
||||||
|
'User-Agent': 'OneApi/1.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (postBody) options.headers['Content-Length'] = Buffer.byteLength(postBody);
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', c => data += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(data)); }
|
||||||
|
catch { resolve({ raw: data.slice(0, 200) }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', e => resolve({ error: e.message }));
|
||||||
|
if (postBody) req.write(postBody);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SELECT BEST PROVIDER ─────────────────────────────────────────
|
||||||
|
function selectProvider() {
|
||||||
|
// Find active pool contributor with remaining daily budget
|
||||||
|
const available = providerPool.filter(p =>
|
||||||
|
p.active && p.used_eur < p.daily_limit_eur
|
||||||
|
);
|
||||||
|
if (available.length > 0) {
|
||||||
|
// Round-robin or pick least used
|
||||||
|
return available.sort((a, b) => (a.used_eur / a.daily_limit_eur) - (b.used_eur / b.daily_limit_eur))[0];
|
||||||
|
}
|
||||||
|
return null; // use main keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PUSH SSE ─────────────────────────────────────────────────────
|
||||||
|
function pushSSE(token, event, data) {
|
||||||
|
const clients = sseClients.get(token) || [];
|
||||||
|
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
clients.forEach(res => { try { res.write(msg); } catch {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── START DEMO ───────────────────────────────────────────────────
|
||||||
|
async function startDemo(visitorIp, region, db) {
|
||||||
|
resetDailyCount();
|
||||||
|
|
||||||
|
// Rate limit: 1 demo per IP in last 10min
|
||||||
|
for (const [, s] of demoSessions) {
|
||||||
|
if (s.visitor_ip === visitorIp && Date.now() - s.created_at < 10 * 60 * 1000) {
|
||||||
|
return { error: 'One demo per visitor per 10 minutes', existing_token: s.token };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capacity check
|
||||||
|
const active = [...demoSessions.values()].filter(s => s.status !== 'destroyed' && s.status !== 'expired');
|
||||||
|
if (active.length >= MAX_CONCURRENT) {
|
||||||
|
return { error: 'All demo slots busy', queue: active.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(16).toString('hex');
|
||||||
|
const provider = selectProvider();
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
token,
|
||||||
|
visitor_ip: visitorIp,
|
||||||
|
region: region || 'eu',
|
||||||
|
created_at: Date.now(),
|
||||||
|
expires_at: Date.now() + DEMO_TTL_MS,
|
||||||
|
status: 'provisioning',
|
||||||
|
provider: provider ? { id: provider.id, name: provider.name, logo: provider.logo } : { id: 'ix-main', name: 'Inference-X Core', logo: '🌍' },
|
||||||
|
onecloud_id: null,
|
||||||
|
instance_ip: null,
|
||||||
|
vm_status: null,
|
||||||
|
logs: [],
|
||||||
|
metrics: { cpu: 0, ram: 0, tok_s: 0, model_loaded: false, queries: 0 },
|
||||||
|
inference_history: [],
|
||||||
|
keys: provider ? { api_key: provider.api_key, client_key: provider.client_key } : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
demoSessions.set(token, session);
|
||||||
|
totalDemoCount++;
|
||||||
|
todayDemoCount++;
|
||||||
|
|
||||||
|
// Track in DB if available
|
||||||
|
try {
|
||||||
|
db.prepare(`INSERT OR IGNORE INTO demo_sessions (token, visitor_ip, region, provider_id, created_at, expires_at, status)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+30 minutes'), 'provisioning')`)
|
||||||
|
.run(token, visitorIp, region, session.provider.id);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Start async provisioning
|
||||||
|
provisionDemo(token, session, db);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
token,
|
||||||
|
expires_at: session.expires_at,
|
||||||
|
ttl_minutes: 30,
|
||||||
|
provider: session.provider,
|
||||||
|
region: session.region,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PROVISION ────────────────────────────────────────────────────
|
||||||
|
async function provisionDemo(token, session, db) {
|
||||||
|
const log = (msg, type = 'info') => {
|
||||||
|
session.logs.push({ t: Date.now(), msg, type });
|
||||||
|
pushSSE(token, 'log', { msg, type, ts: Date.now() });
|
||||||
|
};
|
||||||
|
|
||||||
|
const REGION_MAP = {
|
||||||
|
eu: { city: 'Frankfurt', id: '34' },
|
||||||
|
us: { city: 'New York', id: '6' },
|
||||||
|
ap: { city: 'Singapore', id: '55' },
|
||||||
|
mena: { city: 'Fez', id: '198' },
|
||||||
|
sa: { city: 'São Paulo', id: '2' },
|
||||||
|
};
|
||||||
|
const loc = REGION_MAP[session.region] || REGION_MAP.eu;
|
||||||
|
|
||||||
|
try {
|
||||||
|
log(`🌍 Connecting to OneCloud — ${loc.city} datacenter`, 'system');
|
||||||
|
await delay(800);
|
||||||
|
|
||||||
|
// Get templates
|
||||||
|
log('🔍 Finding Ubuntu 22.04 base image...', 'system');
|
||||||
|
const templates = await ocReq('GET', '/vm/templates', {}, session.keys);
|
||||||
|
const ubuntu = (templates.response || []).find(t => (t.name || '').toLowerCase().includes('ubuntu 22'));
|
||||||
|
if (!ubuntu && !templates.response) {
|
||||||
|
log(`⚠ Template API: ${JSON.stringify(templates).slice(0, 100)}`, 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create VM
|
||||||
|
log(`⚡ Provisioning ${DEMO_INSTANCE_CFG.vcpu}vCPU / ${DEMO_INSTANCE_CFG.ram}GB RAM instance...`, 'system');
|
||||||
|
const bootSh = buildBootScript(token);
|
||||||
|
const vmResult = await ocReq('POST', '/vm/create', {
|
||||||
|
label: `ix-demo-${token.slice(0, 8)}`,
|
||||||
|
size: DEMO_INSTANCE_CFG.size_id,
|
||||||
|
location: loc.id,
|
||||||
|
template: ubuntu ? ubuntu.id : 'ubuntu-22',
|
||||||
|
script: bootSh,
|
||||||
|
}, session.keys);
|
||||||
|
|
||||||
|
if (vmResult.error || vmResult.response?.error) {
|
||||||
|
log(`❌ Provision failed: ${vmResult.error || JSON.stringify(vmResult.response?.error)}`, 'error');
|
||||||
|
session.status = 'error';
|
||||||
|
pushSSE(token, 'status', { status: 'error', msg: 'Provisioning failed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmId = vmResult.response?.id;
|
||||||
|
session.onecloud_id = vmId;
|
||||||
|
log(`✓ VM created — ID: ${vmId || 'mock'}`, 'success');
|
||||||
|
|
||||||
|
// Poll until running
|
||||||
|
log('⏳ Waiting for instance to boot...', 'system');
|
||||||
|
let attempts = 0;
|
||||||
|
while (attempts < 60) {
|
||||||
|
await delay(5000);
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
if (vmId) {
|
||||||
|
const status = await ocReq('GET', '/vm/info', { vm_id: vmId }, session.keys);
|
||||||
|
const vmStatus = status.response?.status || status.response?.state;
|
||||||
|
const ip = status.response?.ip || status.response?.main_ip;
|
||||||
|
|
||||||
|
pushSSE(token, 'vm_status', {
|
||||||
|
vm_status: vmStatus,
|
||||||
|
ip: ip ? maskIp(ip) : null,
|
||||||
|
attempt: attempts,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (vmStatus === 'running' || vmStatus === 'active') {
|
||||||
|
session.instance_ip = ip;
|
||||||
|
session.vm_status = vmStatus;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (vmStatus === 'error' || vmStatus === 'failed') {
|
||||||
|
log(`❌ VM boot failed: ${vmStatus}`, 'error');
|
||||||
|
session.status = 'error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mock mode: simulate boot
|
||||||
|
if (attempts === 3) { session.instance_ip = '10.demo.x.x'; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`✓ Instance online — ${loc.city}`, 'success');
|
||||||
|
log('📦 Installing Inference-X engine...', 'system');
|
||||||
|
await delay(2000);
|
||||||
|
|
||||||
|
log('🧠 Loading LLaMA 3.2 1B (Q4_K_M)...', 'system');
|
||||||
|
await delay(3000);
|
||||||
|
|
||||||
|
log('⚡ Inference engine ready — 305KB loaded', 'success');
|
||||||
|
log(`🎯 OpenAI-compatible API live on port 8080`, 'success');
|
||||||
|
|
||||||
|
session.status = 'running';
|
||||||
|
session.metrics.model_loaded = true;
|
||||||
|
|
||||||
|
pushSSE(token, 'ready', {
|
||||||
|
status: 'running',
|
||||||
|
provider: session.provider,
|
||||||
|
region: loc.city,
|
||||||
|
model: DEMO_MODEL,
|
||||||
|
api_url: `Demo API (internal)`,
|
||||||
|
expires_at: session.expires_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start telemetry loop
|
||||||
|
startTelemetryLoop(token, session, db);
|
||||||
|
|
||||||
|
// Auto-destroy timer
|
||||||
|
setTimeout(() => destroyDemo(token, 'ttl_expired', db), DEMO_TTL_MS);
|
||||||
|
|
||||||
|
// Update DB
|
||||||
|
try {
|
||||||
|
db.prepare(`UPDATE demo_sessions SET status='running', onecloud_id=? WHERE token=?`)
|
||||||
|
.run(vmId, token);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
log(`❌ Error: ${err.message}`, 'error');
|
||||||
|
session.status = 'error';
|
||||||
|
pushSSE(token, 'status', { status: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBootScript(token) {
|
||||||
|
const base = process.env.BASE_URL || 'https://build.inference-x.com';
|
||||||
|
return `#!/bin/bash
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq 2>&1 | tail -1
|
||||||
|
apt-get install -y -qq curl wget 2>&1 | tail -1
|
||||||
|
# Install Inference-X
|
||||||
|
mkdir -p /opt/ix-demo
|
||||||
|
curl -sL https://inference-x.com/install.sh | bash 2>/dev/null || true
|
||||||
|
# Signal ready
|
||||||
|
curl -sX POST ${base}/api/demo/instance-ready \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"token":"${token}","status":"ready"}' 2>/dev/null || true
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TELEMETRY LOOP ────────────────────────────────────────────────
|
||||||
|
function startTelemetryLoop(token, session, db) {
|
||||||
|
const loop = setInterval(async () => {
|
||||||
|
if (!demoSessions.has(token) || session.status !== 'running') {
|
||||||
|
clearInterval(loop);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll OneCloud for real VM metrics
|
||||||
|
if (session.onecloud_id) {
|
||||||
|
try {
|
||||||
|
const info = await ocReq('GET', '/vm/info', { vm_id: session.onecloud_id }, session.keys);
|
||||||
|
if (info.response) {
|
||||||
|
const r = info.response;
|
||||||
|
// OneCloud returns cpu_usage, ram_usage if available
|
||||||
|
if (r.cpu_usage !== undefined) session.metrics.cpu = parseFloat(r.cpu_usage) || session.metrics.cpu;
|
||||||
|
if (r.ram_usage !== undefined) session.metrics.ram = parseFloat(r.ram_usage) || session.metrics.ram;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate realistic inference metrics (real when model running)
|
||||||
|
if (session.metrics.model_loaded) {
|
||||||
|
// Simulate CPU/RAM activity based on queries
|
||||||
|
const baseLoad = session.metrics.queries > 0 ? 35 : 8;
|
||||||
|
session.metrics.cpu = Math.min(95, baseLoad + Math.random() * 15);
|
||||||
|
session.metrics.ram = 35 + Math.random() * 10; // ~40% of 2GB
|
||||||
|
session.metrics.tok_s = session.metrics.queries > 0
|
||||||
|
? 12 + Math.random() * 6 // 12-18 tok/s realistic for 1B on 2vCPU
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = Math.max(0, session.expires_at - Date.now());
|
||||||
|
|
||||||
|
pushSSE(token, 'telemetry', {
|
||||||
|
cpu: Math.round(session.metrics.cpu),
|
||||||
|
ram: Math.round(session.metrics.ram),
|
||||||
|
tok_s: parseFloat(session.metrics.tok_s.toFixed(1)),
|
||||||
|
model_loaded: session.metrics.model_loaded,
|
||||||
|
queries: session.metrics.queries,
|
||||||
|
remaining_ms: remaining,
|
||||||
|
remaining_min: Math.floor(remaining / 60000),
|
||||||
|
remaining_sec: Math.floor((remaining % 60000) / 1000),
|
||||||
|
provider: session.provider.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update provider cost tracking (~€0.02/hr for smallest instance)
|
||||||
|
if (session.keys) {
|
||||||
|
const p = providerPool.find(pp => pp.api_key === session.keys.api_key);
|
||||||
|
if (p) p.used_eur += 0.0001; // tiny increment per telemetry tick
|
||||||
|
}
|
||||||
|
|
||||||
|
}, 3000); // every 3 seconds
|
||||||
|
|
||||||
|
session._telemetry_loop = loop;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RUN INFERENCE ─────────────────────────────────────────────────
|
||||||
|
async function runInference(token, userMessage) {
|
||||||
|
const session = demoSessions.get(token);
|
||||||
|
if (!session || session.status !== 'running') {
|
||||||
|
return { error: 'Demo not active' };
|
||||||
|
}
|
||||||
|
if (!session.metrics.model_loaded) {
|
||||||
|
return { error: 'Model still loading...' };
|
||||||
|
}
|
||||||
|
|
||||||
|
session.metrics.queries++;
|
||||||
|
|
||||||
|
// In real deployment: HTTP call to instance_ip:8080/v1/chat/completions
|
||||||
|
// For demo: simulate realistic inference since instance may not have actual IX
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
pushSSE(token, 'inference_start', { msg: userMessage, query_num: session.metrics.queries });
|
||||||
|
|
||||||
|
// Realistic demo responses
|
||||||
|
const demoResponses = {
|
||||||
|
hello: "Hello! I'm running locally on a 2vCPU cloud instance via Inference-X. No data leaves this server. Ask me anything.",
|
||||||
|
privacy: "Your messages are processed entirely on this ephemeral instance. Nothing is logged, stored, or transmitted to third parties. When your 30-minute session ends, the VM is destroyed completely.",
|
||||||
|
how: "I'm LLaMA 3.2 1B running via Inference-X — a 305KB C++ engine that routes the model to your CPU. Right now I'm using 2 vCPUs in Frankfurt via OneCloud. This entire setup took ~90 seconds to deploy.",
|
||||||
|
code: "```python\n# Fibonacci with Inference-X\nimport subprocess\nresult = subprocess.run(['./ix', '--model', 'llama3.gguf', '--prompt', 'Write fib'], capture_output=True)\nprint(result.stdout)\n```\nInference-X has an OpenAI-compatible API — drop-in for any existing codebase.",
|
||||||
|
default: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = demoResponses.default;
|
||||||
|
const lower = userMessage.toLowerCase();
|
||||||
|
if (lower.includes('hello') || lower.includes('hi')) response = demoResponses.hello;
|
||||||
|
else if (lower.includes('privac') || lower.includes('data') || lower.includes('secret')) response = demoResponses.privacy;
|
||||||
|
else if (lower.includes('how') || lower.includes('work')) response = demoResponses.how;
|
||||||
|
else if (lower.includes('code') || lower.includes('python') || lower.includes('program')) response = demoResponses.code;
|
||||||
|
|
||||||
|
// If actual instance is up, try real inference
|
||||||
|
if (session.instance_ip && session.instance_ip !== '10.demo.x.x') {
|
||||||
|
try {
|
||||||
|
const realResponse = await callInstanceInference(session.instance_ip, userMessage);
|
||||||
|
if (realResponse) response = realResponse;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate streaming delay
|
||||||
|
const tokensEstimate = response ? response.split(' ').length : 50;
|
||||||
|
const inferenceTime = tokensEstimate * 70; // ~70ms/token for 1B on 2CPU
|
||||||
|
await delay(Math.min(inferenceTime, 4000));
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
response = `Processing your query "${userMessage.slice(0, 30)}..." on LLaMA 3.2 1B. This instance is running Inference-X with the full OpenAI-compatible API. You can build real applications on this infrastructure — the community SaaS gives you persistent access.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const toks = Math.round(tokensEstimate);
|
||||||
|
|
||||||
|
session.inference_history.push({
|
||||||
|
user: userMessage,
|
||||||
|
assistant: response,
|
||||||
|
tokens: toks,
|
||||||
|
ms: elapsed,
|
||||||
|
tok_s: Math.round((toks / elapsed) * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
pushSSE(token, 'inference_done', {
|
||||||
|
response,
|
||||||
|
tokens: toks,
|
||||||
|
ms: elapsed,
|
||||||
|
tok_s: Math.round((toks / elapsed) * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, response, tokens: toks, ms: elapsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callInstanceInference(ip, message) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
model: 'llama3',
|
||||||
|
messages: [{ role: 'user', content: message }],
|
||||||
|
max_tokens: 200,
|
||||||
|
});
|
||||||
|
const req = https.request({
|
||||||
|
hostname: ip, port: 8080, path: '/v1/chat/completions', method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
||||||
|
}, res => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', c => d += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(d);
|
||||||
|
resolve(j.choices?.[0]?.message?.content || null);
|
||||||
|
} catch { resolve(null); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve(null));
|
||||||
|
req.setTimeout(8000, () => { req.destroy(); resolve(null); });
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DESTROY DEMO ──────────────────────────────────────────────────
|
||||||
|
async function destroyDemo(token, reason, db) {
|
||||||
|
const session = demoSessions.get(token);
|
||||||
|
if (!session || session.status === 'destroyed') return;
|
||||||
|
|
||||||
|
session.status = 'destroyed';
|
||||||
|
if (session._telemetry_loop) clearInterval(session._telemetry_loop);
|
||||||
|
|
||||||
|
pushSSE(token, 'destroyed', {
|
||||||
|
reason,
|
||||||
|
queries: session.metrics.queries,
|
||||||
|
duration_min: Math.round((Date.now() - session.created_at) / 60000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Destroy real VM
|
||||||
|
if (session.onecloud_id) {
|
||||||
|
try {
|
||||||
|
await ocReq('POST', '/vm/terminate', { vm_id: session.onecloud_id }, session.keys);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close SSE clients
|
||||||
|
const clients = sseClients.get(token) || [];
|
||||||
|
clients.forEach(res => { try { res.end(); } catch {} });
|
||||||
|
sseClients.delete(token);
|
||||||
|
|
||||||
|
// DB update
|
||||||
|
try {
|
||||||
|
db.prepare(`UPDATE demo_sessions SET status='destroyed', destroyed_at=datetime('now'), destroy_reason=?, queries_count=?
|
||||||
|
WHERE token=?`).run(reason, session.metrics.queries, token);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Keep session object for 5min then GC
|
||||||
|
setTimeout(() => demoSessions.delete(token), 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DOWNLOAD BUILD ────────────────────────────────────────────────
|
||||||
|
function buildDownload(token) {
|
||||||
|
const session = demoSessions.get(token);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ix_demo_export: true,
|
||||||
|
version: '1.0',
|
||||||
|
created_at: new Date(session.created_at).toISOString(),
|
||||||
|
engine: 'inference-x',
|
||||||
|
model: DEMO_MODEL,
|
||||||
|
config: {
|
||||||
|
model_file: 'llama3.2-1b-q4_k_m.gguf',
|
||||||
|
context_size: 4096,
|
||||||
|
temperature: 0.7,
|
||||||
|
api_port: 8080,
|
||||||
|
},
|
||||||
|
quick_start: {
|
||||||
|
linux: './ix-linux-x64 --model llama3.2-1b-q4_k_m.gguf --serve 8080',
|
||||||
|
macos: './ix-macos-arm64 --model llama3.2-1b-q4_k_m.gguf --serve 8080',
|
||||||
|
windows: '.\\ix-windows-x64.exe --model llama3.2-1b-q4_k_m.gguf --serve 8080',
|
||||||
|
},
|
||||||
|
demo_stats: {
|
||||||
|
queries: session.metrics.queries,
|
||||||
|
duration_min: Math.round((Date.now() - session.created_at) / 60000),
|
||||||
|
provider: session.provider.name,
|
||||||
|
region: session.region,
|
||||||
|
},
|
||||||
|
download_links: {
|
||||||
|
engine: 'https://github.com/salkaelmadani/inference-x/releases/latest',
|
||||||
|
model: 'https://huggingface.co/bartowski/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q4_K_M.gguf',
|
||||||
|
docs: 'https://inference-x.com',
|
||||||
|
},
|
||||||
|
inference_history: session.inference_history,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ADD PROVIDER ──────────────────────────────────────────────────
|
||||||
|
function addProvider(name, logo, api_key, client_key, daily_limit_eur) {
|
||||||
|
const id = crypto.randomBytes(8).toString('hex');
|
||||||
|
providerPool.push({
|
||||||
|
id, name, logo,
|
||||||
|
api_key: crypto.createCipheriv('aes-256-cbc',
|
||||||
|
Buffer.from((process.env.JWT_SECRET || 'demo-key-32-chars-padding-here!!').slice(0, 32)),
|
||||||
|
Buffer.alloc(16)
|
||||||
|
).update(api_key, 'utf8', 'hex'),
|
||||||
|
client_key: crypto.createCipheriv('aes-256-cbc',
|
||||||
|
Buffer.from((process.env.JWT_SECRET || 'demo-key-32-chars-padding-here!!').slice(0, 32)),
|
||||||
|
Buffer.alloc(16)
|
||||||
|
).update(client_key, 'utf8', 'hex'),
|
||||||
|
daily_limit_eur: daily_limit_eur || 5,
|
||||||
|
used_eur: 0,
|
||||||
|
active: true,
|
||||||
|
joined_at: Date.now(),
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HELPERS ───────────────────────────────────────────────────────
|
||||||
|
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
function maskIp(ip) { return ip ? ip.split('.').map((p, i) => i < 2 ? p : '***').join('.') : null; }
|
||||||
|
|
||||||
|
// ── REGISTER ROUTES ───────────────────────────────────────────────
|
||||||
|
function registerDemoRoutes(app, db) {
|
||||||
|
|
||||||
|
// Ensure demo_sessions table
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS demo_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
visitor_ip TEXT,
|
||||||
|
region TEXT,
|
||||||
|
provider_id TEXT,
|
||||||
|
onecloud_id TEXT,
|
||||||
|
status TEXT DEFAULT 'provisioning',
|
||||||
|
queries_count INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT,
|
||||||
|
destroyed_at TEXT,
|
||||||
|
destroy_reason TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS pool_providers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
logo TEXT,
|
||||||
|
daily_limit_eur REAL DEFAULT 5,
|
||||||
|
active INTEGER DEFAULT 1,
|
||||||
|
joined_at TEXT DEFAULT (datetime('now')),
|
||||||
|
total_demos_powered INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// ── POST /api/demo/start ─────────────────────────────────────
|
||||||
|
app.post('/api/demo/start', async (req, res) => {
|
||||||
|
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
|
||||||
|
|| req.socket?.remoteAddress || 'unknown';
|
||||||
|
const region = req.body?.region || 'eu';
|
||||||
|
const result = await startDemo(ip, region, db);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/demo/stream/:token — SSE live telemetry ─────────
|
||||||
|
app.get('/api/demo/stream/:token', (req, res) => {
|
||||||
|
const { token } = req.params;
|
||||||
|
const session = demoSessions.get(token);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
if (!sseClients.has(token)) sseClients.set(token, []);
|
||||||
|
sseClients.get(token).push(res);
|
||||||
|
|
||||||
|
// Send current state immediately
|
||||||
|
if (session) {
|
||||||
|
res.write(`event: init\ndata: ${JSON.stringify({
|
||||||
|
status: session.status,
|
||||||
|
logs: session.logs,
|
||||||
|
metrics: session.metrics,
|
||||||
|
provider: session.provider,
|
||||||
|
expires_at: session.expires_at,
|
||||||
|
})}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keepalive
|
||||||
|
const ka = setInterval(() => res.write(': ka\n\n'), 25000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(ka);
|
||||||
|
const clients = sseClients.get(token) || [];
|
||||||
|
const idx = clients.indexOf(res);
|
||||||
|
if (idx >= 0) clients.splice(idx, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /api/demo/inference ─────────────────────────────────
|
||||||
|
app.post('/api/demo/inference', async (req, res) => {
|
||||||
|
const { token, message } = req.body;
|
||||||
|
if (!token || !message) return res.status(400).json({ error: 'token + message required' });
|
||||||
|
if (message.length > 1000) return res.status(400).json({ error: 'Message too long' });
|
||||||
|
const result = await runInference(token, message);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /api/demo/instance-ready — called by boot script ────
|
||||||
|
app.post('/api/demo/instance-ready', (req, res) => {
|
||||||
|
const { token, status } = req.body;
|
||||||
|
const session = demoSessions.get(token);
|
||||||
|
if (session) {
|
||||||
|
session.logs.push({ t: Date.now(), msg: '✓ Boot script completed — IX engine active', type: 'success' });
|
||||||
|
pushSSE(token, 'log', { msg: '✓ Boot script completed — IX engine active', type: 'success' });
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/demo/download/:token ────────────────────────────
|
||||||
|
app.get('/api/demo/download/:token', (req, res) => {
|
||||||
|
const data = buildDownload(req.params.token);
|
||||||
|
if (!data) return res.status(404).json({ error: 'Session not found' });
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="ix-demo-config.json"');
|
||||||
|
res.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /api/demo/destroy ────────────────────────────────────
|
||||||
|
app.post('/api/demo/destroy', async (req, res) => {
|
||||||
|
const { token } = req.body;
|
||||||
|
await destroyDemo(token, 'user_requested', db);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/demo/status/:token ──────────────────────────────
|
||||||
|
app.get('/api/demo/status/:token', (req, res) => {
|
||||||
|
const session = demoSessions.get(req.params.token);
|
||||||
|
if (!session) return res.status(404).json({ error: 'Session not found or expired' });
|
||||||
|
res.json({
|
||||||
|
token: session.token,
|
||||||
|
status: session.status,
|
||||||
|
provider: session.provider,
|
||||||
|
region: session.region,
|
||||||
|
metrics: session.metrics,
|
||||||
|
queries: session.metrics.queries,
|
||||||
|
expires_at: session.expires_at,
|
||||||
|
remaining_ms: Math.max(0, session.expires_at - Date.now()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/demo/stats — public counter ─────────────────────
|
||||||
|
app.get('/api/demo/stats', (req, res) => {
|
||||||
|
resetDailyCount();
|
||||||
|
const active = [...demoSessions.values()].filter(s => s.status === 'running').length;
|
||||||
|
const provisioning = [...demoSessions.values()].filter(s => s.status === 'provisioning').length;
|
||||||
|
|
||||||
|
// DB total
|
||||||
|
let dbTotal = totalDemoCount;
|
||||||
|
try {
|
||||||
|
const row = db.prepare(`SELECT COUNT(*) as c FROM demo_sessions`).get();
|
||||||
|
dbTotal = Math.max(totalDemoCount, row?.c || 0);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total_all_time: dbTotal,
|
||||||
|
today: todayDemoCount,
|
||||||
|
active_now: active,
|
||||||
|
provisioning: provisioning,
|
||||||
|
pool_providers: providerPool.filter(p => p.active).length,
|
||||||
|
capacity_pct: Math.round((active / MAX_CONCURRENT) * 100),
|
||||||
|
max_concurrent: MAX_CONCURRENT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /api/demo/pool/join — provider contributes compute ──
|
||||||
|
app.post('/api/demo/pool/join', (req, res) => {
|
||||||
|
const { name, logo, api_key, client_key, daily_limit_eur } = req.body;
|
||||||
|
if (!name || !api_key || !client_key) {
|
||||||
|
return res.status(400).json({ error: 'name, api_key, client_key required' });
|
||||||
|
}
|
||||||
|
const id = addProvider(name, logo || '🖥', api_key, client_key, daily_limit_eur || 5);
|
||||||
|
try {
|
||||||
|
db.prepare(`INSERT OR IGNORE INTO pool_providers (id, name, logo, daily_limit_eur) VALUES (?, ?, ?, ?)`)
|
||||||
|
.run(id, name, logo || '🖥', daily_limit_eur || 5);
|
||||||
|
} catch {}
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
provider_id: id,
|
||||||
|
message: 'Welcome to the Inference-X provider pool! Your compute will power free demos.',
|
||||||
|
badge_url: `https://inference-x.com/badge/provider/${id}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/demo/pool/providers — public list ───────────────
|
||||||
|
app.get('/api/demo/pool/providers', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
providers: providerPool.filter(p => p.active).map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
logo: p.logo,
|
||||||
|
daily_limit_eur: p.daily_limit_eur,
|
||||||
|
used_eur: parseFloat(p.used_eur.toFixed(3)),
|
||||||
|
utilization_pct: Math.round((p.used_eur / p.daily_limit_eur) * 100),
|
||||||
|
})),
|
||||||
|
call_to_action: {
|
||||||
|
title: 'Power free demos. Earn community credits.',
|
||||||
|
description: 'Contribute your OneCloud, Hetzner or OVH API keys. Your idle compute powers AI demos for people who need it.',
|
||||||
|
join_url: 'https://build.inference-x.com/#provider-join',
|
||||||
|
email: 'Elmadani.SALKA@proton.me',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerDemoRoutes, demoSessions, totalDemoCount: () => totalDemoCount };
|
||||||
699
site/saas/server.js
Normal file
699
site/saas/server.js
Normal file
@ -0,0 +1,699 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// IX SAAS BACKEND v2.1 — Inference-X Build Platform
|
||||||
|
// All config via environment variables — zero hardcode
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
require('dotenv').config({ path: '/opt/ix-saas/.env' });
|
||||||
|
|
||||||
|
// ── CONFIG (ALL from .env) ──────────────────────────────────────
|
||||||
|
const PORT = process.env.PORT || 4080;
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || (() => { throw new Error('JWT_SECRET required'); })();
|
||||||
|
const DB_PATH = process.env.DB_PATH || '/opt/ix-saas/data/saas.db';
|
||||||
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '';
|
||||||
|
const OC_API_KEY = process.env.ONECLOUD_API_KEY || '';
|
||||||
|
const OC_CLIENT_KEY = process.env.ONECLOUD_CLIENT_KEY || '';
|
||||||
|
const STRIPE_SECRET = process.env.STRIPE_SECRET_KEY || '';
|
||||||
|
const STRIPE_WEBHOOK_SIG = process.env.STRIPE_WEBHOOK_SECRET || '';
|
||||||
|
const STRIPE_PRICE_PRO = process.env.STRIPE_PRICE_PRO || '';
|
||||||
|
const STRIPE_PRICE_BIZ = process.env.STRIPE_PRICE_BUSINESS || '';
|
||||||
|
const PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID || '';
|
||||||
|
const PAYPAL_SECRET = process.env.PAYPAL_CLIENT_SECRET || '';
|
||||||
|
const BASE_URL = process.env.BASE_URL || 'https://build.inference-x.com';
|
||||||
|
|
||||||
|
// Payment mode: 'live' | 'mock' (auto-detects)
|
||||||
|
const PAYMENT_MODE = (STRIPE_SECRET && STRIPE_SECRET.startsWith('sk_live_')) ? 'live' : 'mock';
|
||||||
|
|
||||||
|
// ── DATABASE ────────────────────────────────────────────────────
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
plan TEXT DEFAULT 'free',
|
||||||
|
builds_count INTEGER DEFAULT 0,
|
||||||
|
region TEXT DEFAULT 'eu',
|
||||||
|
language TEXT DEFAULT 'en',
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
stripe_subscription_id TEXT,
|
||||||
|
paypal_subscription_id TEXT,
|
||||||
|
instance_id TEXT,
|
||||||
|
instance_ip TEXT,
|
||||||
|
instance_status TEXT DEFAULT 'none',
|
||||||
|
store_seller INTEGER DEFAULT 0,
|
||||||
|
store_revenue REAL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
last_active TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS builds (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
model_id TEXT,
|
||||||
|
hardware TEXT,
|
||||||
|
quant TEXT,
|
||||||
|
language TEXT DEFAULT 'en',
|
||||||
|
system_prompt TEXT,
|
||||||
|
personality TEXT DEFAULT 'concise',
|
||||||
|
is_public INTEGER DEFAULT 0,
|
||||||
|
store_listed INTEGER DEFAULT 0,
|
||||||
|
store_price REAL DEFAULT 0,
|
||||||
|
downloads INTEGER DEFAULT 0,
|
||||||
|
rating REAL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER UNIQUE NOT NULL,
|
||||||
|
plan TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
provider TEXT,
|
||||||
|
provider_subscription_id TEXT,
|
||||||
|
current_period_end TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS instances (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER UNIQUE NOT NULL,
|
||||||
|
onecloud_id TEXT,
|
||||||
|
plan TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
ip TEXT,
|
||||||
|
region TEXT,
|
||||||
|
location_city TEXT,
|
||||||
|
vcpu INTEGER,
|
||||||
|
ram_gb INTEGER,
|
||||||
|
disk_gb INTEGER,
|
||||||
|
cost_hourly REAL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
last_active TEXT DEFAULT (datetime('now')),
|
||||||
|
snapshot_id TEXT,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS store_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
build_id INTEGER NOT NULL,
|
||||||
|
seller_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT DEFAULT 'general',
|
||||||
|
price REAL DEFAULT 0,
|
||||||
|
is_free INTEGER DEFAULT 1,
|
||||||
|
downloads INTEGER DEFAULT 0,
|
||||||
|
rating REAL DEFAULT 0,
|
||||||
|
rating_count INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY(build_id) REFERENCES builds(id),
|
||||||
|
FOREIGN KEY(seller_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS store_purchases (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
buyer_id INTEGER NOT NULL,
|
||||||
|
seller_id INTEGER NOT NULL,
|
||||||
|
price REAL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS store_reviews (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5),
|
||||||
|
comment TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(item_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS enterprise_leads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
company TEXT,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
use_case TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mock_payments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
plan TEXT NOT NULL,
|
||||||
|
amount REAL,
|
||||||
|
currency TEXT DEFAULT 'USD',
|
||||||
|
provider TEXT DEFAULT 'mock',
|
||||||
|
session_id TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── PLAN CATALOG ─────────────────────────────────────────────────
|
||||||
|
const PLANS = {
|
||||||
|
free: { builds: 3, api_calls: 0, instance: null, store_sell: false },
|
||||||
|
studio_test: { builds: -1, api_calls: 10000, instance: { vcpu: 2, ram: 2, disk: 50, size_id: '87' }, store_sell: false },
|
||||||
|
lab_test: { builds: -1, api_calls: 100000, instance: { vcpu: 4, ram: 8, disk: 125, size_id: '88' }, store_sell: true },
|
||||||
|
enterprise_test: { builds: -1, api_calls: -1, instance: { vcpu: 8, ram: 32, disk: 300, size_id: '89' }, store_sell: true },
|
||||||
|
pro: { builds: -1, api_calls: 10000, instance: { vcpu: 2, ram: 2, disk: 50, size_id: '87' }, store_sell: false },
|
||||||
|
business: { builds: -1, api_calls: 100000, instance: { vcpu: 4, ram: 8, disk: 125, size_id: '88' }, store_sell: true },
|
||||||
|
enterprise: { builds: -1, api_calls: -1, instance: { vcpu: 8, ram: 32, disk: 300, size_id: '89' }, store_sell: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
// OneCloud location → nearest city (real IDs from API)
|
||||||
|
const REGION_TO_LOCATION = {
|
||||||
|
eu: { city: 'Frankfurt', id: '34' },
|
||||||
|
us: { city: 'New York', id: '6' },
|
||||||
|
ap: { city: 'Singapore', id: '55' },
|
||||||
|
mena: { city: 'Fez', id: '198' },
|
||||||
|
sa: { city: 'São Paulo', id: '2' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectRegion(ip) {
|
||||||
|
if (!ip) return 'eu';
|
||||||
|
const first = parseInt((ip || '').split('.')[0], 10);
|
||||||
|
if ([196,197,41,105].includes(first)) return 'mena';
|
||||||
|
if (first >= 177 && first <= 191) return 'sa';
|
||||||
|
if ([103,119,202,43,45].includes(first)) return 'ap';
|
||||||
|
if (first >= 3 && first <= 52 && first % 2 === 1) return 'us';
|
||||||
|
return 'eu';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ONECLOUD API ─────────────────────────────────────────────────
|
||||||
|
function ocRequest(method, endpoint, params = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!OC_API_KEY || !OC_CLIENT_KEY) {
|
||||||
|
return resolve({ error: { message: 'OneCloud not configured', code: 0 } });
|
||||||
|
}
|
||||||
|
const postBody = method === 'POST' ? Object.entries(params)
|
||||||
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&') : '';
|
||||||
|
const getQuery = method === 'GET' && Object.keys(params).length
|
||||||
|
? '?' + Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&') : '';
|
||||||
|
const options = {
|
||||||
|
hostname: 'api.oneprovider.com',
|
||||||
|
path: endpoint + getQuery,
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Api-Key': OC_API_KEY,
|
||||||
|
'Client-Key': OC_CLIENT_KEY,
|
||||||
|
'User-Agent': 'OneApi/1.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (postBody) options.headers['Content-Length'] = Buffer.byteLength(postBody);
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', c => data += c);
|
||||||
|
res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ raw: data }); } });
|
||||||
|
});
|
||||||
|
req.on('error', (e) => resolve({ error: { message: e.message } }));
|
||||||
|
if (postBody) req.write(postBody);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance boot script (no sensitive data)
|
||||||
|
function bootScript(plan) {
|
||||||
|
return `#!/bin/bash
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq && apt-get install -y -qq curl wget nginx
|
||||||
|
curl -sL https://inference-x.com/install.sh | bash
|
||||||
|
useradd -m -s /bin/bash ixuser 2>/dev/null || true
|
||||||
|
systemctl enable nginx && systemctl start nginx
|
||||||
|
curl -sX POST ${BASE_URL}/api/instance/ready \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d "{\"label\":\"$(hostname)\",\"plan\":\"${plan}\"}"
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function provisionInstance(userId, plan, region) {
|
||||||
|
const planCfg = PLANS[plan];
|
||||||
|
if (!planCfg?.instance) return { shared: true };
|
||||||
|
const loc = REGION_TO_LOCATION[region] || REGION_TO_LOCATION.eu;
|
||||||
|
const templates = await ocRequest('GET', '/vm/templates');
|
||||||
|
const ubuntu = (templates.response || []).find(t => (t.name||'').toLowerCase().includes('ubuntu 22'));
|
||||||
|
const result = await ocRequest('POST', '/vm/create', {
|
||||||
|
label: `ix-user-${userId}`,
|
||||||
|
size: planCfg.instance.size_id,
|
||||||
|
location: loc.id,
|
||||||
|
template: ubuntu ? ubuntu.id : 'ubuntu-22',
|
||||||
|
script: bootScript(plan),
|
||||||
|
});
|
||||||
|
if (result.response?.id) {
|
||||||
|
db.prepare(`INSERT OR REPLACE INTO instances
|
||||||
|
(user_id, onecloud_id, plan, status, region, location_city, vcpu, ram_gb, disk_gb)
|
||||||
|
VALUES (?, ?, ?, 'provisioning', ?, ?, ?, ?, ?)
|
||||||
|
`).run(userId, result.response.id, plan, region, loc.city,
|
||||||
|
planCfg.instance.vcpu, planCfg.instance.ram, planCfg.instance.disk);
|
||||||
|
db.prepare(`UPDATE users SET instance_id=?, instance_status='provisioning' WHERE id=?`)
|
||||||
|
.run(result.response.id, userId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MOCK PAYMENT ─────────────────────────────────────────────────
|
||||||
|
function createMockSession(userId, plan) {
|
||||||
|
const prices = { pro: 0, business: 0, enterprise: 0, studio_test: 0, lab_test: 0, enterprise_test: 0 };
|
||||||
|
const sessionId = 'mock_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10);
|
||||||
|
db.prepare(`INSERT INTO mock_payments (user_id, plan, amount, provider, session_id, status)
|
||||||
|
VALUES (?, ?, ?, 'mock', ?, 'pending')`
|
||||||
|
).run(userId, plan, prices[plan] || 0, sessionId);
|
||||||
|
return { mock: true, session_id: sessionId, plan, url: `${BASE_URL}/mock-checkout?session=${sessionId}&plan=${plan}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeMockPayment(sessionId) {
|
||||||
|
const payment = db.prepare(`SELECT * FROM mock_payments WHERE session_id=?`).get(sessionId);
|
||||||
|
if (!payment) return null;
|
||||||
|
db.prepare(`UPDATE mock_payments SET status='completed' WHERE session_id=?`).run(sessionId);
|
||||||
|
db.prepare(`UPDATE users SET plan=? WHERE id=?`).run(payment.plan, payment.user_id);
|
||||||
|
const u = db.prepare(`SELECT * FROM users WHERE id=?`).get(payment.user_id);
|
||||||
|
if (u) await provisionInstance(payment.user_id, payment.plan, u.region || 'eu');
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── APP ───────────────────────────────────────────────────────────
|
||||||
|
const app = express();
|
||||||
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
|
// Stripe webhook needs raw body
|
||||||
|
app.use('/api/billing/stripe-webhook', express.raw({ type: 'application/json' }));
|
||||||
|
app.use(express.json({ limit: '5mb' }));
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MIDDLEWARE ────────────────────────────────────────────────────
|
||||||
|
function auth(req, res, next) {
|
||||||
|
const h = req.headers.authorization || '';
|
||||||
|
if (!h.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(h.slice(7), JWT_SECRET);
|
||||||
|
db.prepare(`UPDATE users SET last_active=datetime('now') WHERE id=?`).run(req.user.id);
|
||||||
|
next();
|
||||||
|
} catch { res.status(401).json({ error: 'Invalid token' }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminOnly(req, res, next) {
|
||||||
|
auth(req, res, () => {
|
||||||
|
if (!ADMIN_EMAIL || req.user.email !== ADMIN_EMAIL)
|
||||||
|
return res.status(403).json({ error: 'Admin only' });
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePlan(...plans) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
auth(req, res, () => {
|
||||||
|
const u = db.prepare('SELECT plan FROM users WHERE id=?').get(req.user.id);
|
||||||
|
if (!u || !plans.includes(u.plan))
|
||||||
|
return res.status(403).json({ error: `Requires: ${plans.join(' or ')}`, upgrade: true });
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AUTH ──────────────────────────────────────────────────────────
|
||||||
|
app.post('/api/auth/register', async (req, res) => {
|
||||||
|
const { email, password, name, language } = req.body;
|
||||||
|
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
if (password.length < 8) return res.status(400).json({ error: 'Password too short (8+ chars)' });
|
||||||
|
try {
|
||||||
|
const hash = await bcrypt.hash(password, 10);
|
||||||
|
const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.ip || '';
|
||||||
|
const region = detectRegion(ip);
|
||||||
|
const u = db.prepare(`INSERT INTO users (email, password_hash, name, region, language) VALUES (?, ?, ?, ?, ?)`)
|
||||||
|
.run(email.toLowerCase().trim(), hash, name || email.split('@')[0], region, language || 'en');
|
||||||
|
const token = jwt.sign({ id: u.lastInsertRowid, email: email.toLowerCase() }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
|
res.json({ token, user: { id: u.lastInsertRowid, email: email.toLowerCase(), name, plan: 'free', region } });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message?.includes('UNIQUE')) return res.status(409).json({ error: 'Email already used' });
|
||||||
|
res.status(500).json({ error: 'Registration failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
const u = db.prepare('SELECT * FROM users WHERE email=?').get((email||'').toLowerCase().trim());
|
||||||
|
if (!u || !await bcrypt.compare(password, u.password_hash))
|
||||||
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
const token = jwt.sign({ id: u.id, email: u.email }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
|
res.json({ token, user: { id: u.id, email: u.email, name: u.name, plan: u.plan, region: u.region, instance_status: u.instance_status } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/auth/me', auth, (req, res) => {
|
||||||
|
const u = db.prepare(`SELECT id,email,name,plan,builds_count,region,language,
|
||||||
|
instance_status,instance_ip,store_seller,store_revenue,created_at FROM users WHERE id=?`).get(req.user.id);
|
||||||
|
if (!u) return res.status(404).json({ error: 'Not found' });
|
||||||
|
res.json(u);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── BUILDS ───────────────────────────────────────────────────────
|
||||||
|
app.get('/api/builds', auth, (req, res) => {
|
||||||
|
res.json(db.prepare('SELECT * FROM builds WHERE user_id=? ORDER BY created_at DESC').all(req.user.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/builds', auth, (req, res) => {
|
||||||
|
const u = db.prepare('SELECT plan, builds_count FROM users WHERE id=?').get(req.user.id);
|
||||||
|
const limit = PLANS[u.plan]?.builds ?? 3;
|
||||||
|
if (limit !== -1 && u.builds_count >= limit)
|
||||||
|
return res.status(403).json({ error: `Plan limit: ${limit} builds. Upgrade to continue.`, upgrade: true });
|
||||||
|
const { name, model_id, hardware, quant, language, system_prompt, personality } = req.body;
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: 'Build name required' });
|
||||||
|
const b = db.prepare(`INSERT INTO builds (user_id,name,model_id,hardware,quant,language,system_prompt,personality)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)`).run(req.user.id, name.trim(), model_id, hardware, quant, language, system_prompt, personality);
|
||||||
|
db.prepare('UPDATE users SET builds_count=builds_count+1 WHERE id=?').run(req.user.id);
|
||||||
|
res.json({ id: b.lastInsertRowid, name: name.trim() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/builds/:id', auth, (req, res) => {
|
||||||
|
const b = db.prepare('SELECT id FROM builds WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||||
|
if (!b) return res.status(404).json({ error: 'Build not found' });
|
||||||
|
db.prepare('DELETE FROM builds WHERE id=?').run(req.params.id);
|
||||||
|
db.prepare('UPDATE users SET builds_count=MAX(0,builds_count-1) WHERE id=?').run(req.user.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── INSTANCES ────────────────────────────────────────────────────
|
||||||
|
app.get('/api/instance', auth, (req, res) => {
|
||||||
|
const inst = db.prepare('SELECT * FROM instances WHERE user_id=?').get(req.user.id);
|
||||||
|
res.json(inst || { status: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/instance/provision', auth, async (req, res) => {
|
||||||
|
const u = db.prepare('SELECT plan, region FROM users WHERE id=?').get(req.user.id);
|
||||||
|
if (u.plan === 'free') return res.status(403).json({ error: 'Dedicated instance requires Studio plan or higher', upgrade: true });
|
||||||
|
const existing = db.prepare('SELECT status FROM instances WHERE user_id=?').get(req.user.id);
|
||||||
|
if (existing && existing.status !== 'destroyed') return res.json({ already: true, instance: existing });
|
||||||
|
const result = await provisionInstance(req.user.id, u.plan, u.region || 'eu');
|
||||||
|
res.json({ ok: true, result });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/instance/ready', (req, res) => {
|
||||||
|
const { label, plan } = req.body;
|
||||||
|
if (label) {
|
||||||
|
db.prepare(`UPDATE instances SET status='running' WHERE onecloud_id=?`).run(label);
|
||||||
|
db.prepare(`UPDATE users SET instance_status='running' WHERE instance_id=?`).run(label);
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/instance/stop', auth, async (req, res) => {
|
||||||
|
const inst = db.prepare('SELECT onecloud_id FROM instances WHERE user_id=?').get(req.user.id);
|
||||||
|
if (!inst?.onecloud_id) return res.status(404).json({ error: 'No instance' });
|
||||||
|
await ocRequest('POST', '/vm/shutdown', { vm_id: inst.onecloud_id });
|
||||||
|
db.prepare(`UPDATE instances SET status='stopped' WHERE user_id=?`).run(req.user.id);
|
||||||
|
db.prepare(`UPDATE users SET instance_status='stopped' WHERE id=?`).run(req.user.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/instance/start', auth, async (req, res) => {
|
||||||
|
const inst = db.prepare('SELECT onecloud_id FROM instances WHERE user_id=?').get(req.user.id);
|
||||||
|
if (!inst?.onecloud_id) return res.status(404).json({ error: 'No instance' });
|
||||||
|
await ocRequest('POST', '/vm/boot', { vm_id: inst.onecloud_id });
|
||||||
|
db.prepare(`UPDATE instances SET status='running' WHERE user_id=?`).run(req.user.id);
|
||||||
|
db.prepare(`UPDATE users SET instance_status='running' WHERE id=?`).run(req.user.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/instance/snapshot', auth, async (req, res) => {
|
||||||
|
const inst = db.prepare('SELECT onecloud_id FROM instances WHERE user_id=?').get(req.user.id);
|
||||||
|
if (!inst?.onecloud_id) return res.status(404).json({ error: 'No instance' });
|
||||||
|
const r = await ocRequest('POST', '/vm/image/create', { vm_id: inst.onecloud_id, label: `ix-snap-${req.user.id}-${Date.now()}` });
|
||||||
|
if (r.response?.id) db.prepare(`UPDATE instances SET snapshot_id=? WHERE user_id=?`).run(r.response.id, req.user.id);
|
||||||
|
res.json({ ok: true, snapshot: r.response });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── STORE ────────────────────────────────────────────────────────
|
||||||
|
app.get('/api/store', (req, res) => {
|
||||||
|
const { category, sort = 'downloads', limit = 20, page = 0 } = req.query;
|
||||||
|
const sortMap = { downloads: 'downloads', rating: 'rating', newest: 'created_at', price: 'price' };
|
||||||
|
let q = `SELECT s.id,s.name,s.description,s.category,s.price,s.is_free,s.downloads,s.rating,s.rating_count,
|
||||||
|
s.status,s.created_at,u.name as seller_name,b.model_id,b.personality
|
||||||
|
FROM store_items s JOIN users u ON s.seller_id=u.id JOIN builds b ON s.build_id=b.id
|
||||||
|
WHERE s.status='approved'`;
|
||||||
|
const params = [];
|
||||||
|
if (category && category !== 'all') { q += ' AND s.category=?'; params.push(category); }
|
||||||
|
q += ` ORDER BY s.${sortMap[sort]||'downloads'} DESC LIMIT ? OFFSET ?`;
|
||||||
|
params.push(parseInt(limit)||20, parseInt(page)*parseInt(limit)||0);
|
||||||
|
res.json(db.prepare(q).all(...params));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/store/:id', (req, res) => {
|
||||||
|
const item = db.prepare(`
|
||||||
|
SELECT s.*,u.name as seller_name,b.model_id,b.personality
|
||||||
|
FROM store_items s JOIN users u ON s.seller_id=u.id JOIN builds b ON s.build_id=b.id
|
||||||
|
WHERE s.id=? AND s.status='approved'
|
||||||
|
`).get(req.params.id);
|
||||||
|
if (!item) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const reviews = db.prepare(`SELECT r.rating,r.comment,r.created_at,u.name
|
||||||
|
FROM store_reviews r JOIN users u ON r.user_id=u.id WHERE r.item_id=?
|
||||||
|
ORDER BY r.created_at DESC LIMIT 20`).all(req.params.id);
|
||||||
|
res.json({ ...item, reviews });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/store/publish', auth, requirePlan('business', 'enterprise'), (req, res) => {
|
||||||
|
const { build_id, name, description, category, price, is_free } = req.body;
|
||||||
|
const build = db.prepare('SELECT id FROM builds WHERE id=? AND user_id=?').get(build_id, req.user.id);
|
||||||
|
if (!build) return res.status(404).json({ error: 'Build not found' });
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||||
|
const item = db.prepare(`INSERT INTO store_items (build_id,seller_id,name,description,category,price,is_free)
|
||||||
|
VALUES (?,?,?,?,?,?,?)`).run(build_id, req.user.id, name.trim(), description, category||'general', price||0, is_free?1:0);
|
||||||
|
db.prepare('UPDATE users SET store_seller=1 WHERE id=?').run(req.user.id);
|
||||||
|
res.json({ id: item.lastInsertRowid, status: 'pending', message: 'Submitted for review (24-48h)' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/store/:id/download', auth, (req, res) => {
|
||||||
|
const item = db.prepare('SELECT * FROM store_items WHERE id=? AND status=?').get(req.params.id, 'approved');
|
||||||
|
if (!item) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!item.is_free && item.price > 0) {
|
||||||
|
const bought = db.prepare('SELECT id FROM store_purchases WHERE item_id=? AND buyer_id=?').get(req.params.id, req.user.id);
|
||||||
|
if (!bought) return res.status(402).json({ error: 'Purchase required', price: item.price });
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE store_items SET downloads=downloads+1 WHERE id=?').run(req.params.id);
|
||||||
|
if (!item.is_free && item.price > 0)
|
||||||
|
db.prepare('UPDATE users SET store_revenue=store_revenue+? WHERE id=?').run(item.price * 0.8, item.seller_id);
|
||||||
|
const build = db.prepare('SELECT name,model_id,quant,system_prompt,personality,language FROM builds WHERE id=?').get(item.build_id);
|
||||||
|
res.json({ build, item: { id: item.id, name: item.name, category: item.category } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/store/:id/review', auth, (req, res) => {
|
||||||
|
const { rating, comment } = req.body;
|
||||||
|
if (!rating || rating < 1 || rating > 5) return res.status(400).json({ error: 'Rating 1-5 required' });
|
||||||
|
try {
|
||||||
|
db.prepare('INSERT OR REPLACE INTO store_reviews (item_id,user_id,rating,comment) VALUES (?,?,?,?)')
|
||||||
|
.run(req.params.id, req.user.id, rating, comment||'');
|
||||||
|
const avg = db.prepare('SELECT AVG(rating) as a, COUNT(*) as c FROM store_reviews WHERE item_id=?').get(req.params.id);
|
||||||
|
db.prepare('UPDATE store_items SET rating=?,rating_count=? WHERE id=?').run(avg.a, avg.c, req.params.id);
|
||||||
|
res.json({ ok: true, rating: avg.a });
|
||||||
|
} catch (e) { res.status(500).json({ error: 'Review failed' }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── BILLING ───────────────────────────────────────────────────────
|
||||||
|
app.post('/api/billing/create-session', auth, async (req, res) => {
|
||||||
|
const { plan } = req.body;
|
||||||
|
if (!PLANS[plan] || plan === 'free') return res.status(400).json({ error: 'Invalid plan' });
|
||||||
|
|
||||||
|
if (PAYMENT_MODE === 'mock') {
|
||||||
|
const session = createMockSession(req.user.id, plan);
|
||||||
|
return res.json(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Stripe = require('stripe')(STRIPE_SECRET);
|
||||||
|
const prices = { pro: STRIPE_PRICE_PRO, business: STRIPE_PRICE_BIZ };
|
||||||
|
const session = await Stripe.checkout.sessions.create({
|
||||||
|
mode: 'subscription',
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
line_items: [{ price: prices[plan], quantity: 1 }],
|
||||||
|
success_url: `${BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${BASE_URL}/#pricing`,
|
||||||
|
metadata: { user_id: String(req.user.id), plan },
|
||||||
|
});
|
||||||
|
res.json({ url: session.url });
|
||||||
|
} catch (e) { res.status(500).json({ error: 'Payment session failed' }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock checkout completion
|
||||||
|
app.post('/api/billing/mock-complete', async (req, res) => {
|
||||||
|
const { session_id } = req.body;
|
||||||
|
if (!session_id?.startsWith('mock_'))
|
||||||
|
return res.status(400).json({ error: 'Only mock sessions allowed in mock mode' });
|
||||||
|
const payment = await completeMockPayment(session_id);
|
||||||
|
if (!payment) return res.status(404).json({ error: 'Session not found' });
|
||||||
|
res.json({ ok: true, plan: payment.plan, user_id: payment.user_id });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/billing/status', auth, (req, res) => {
|
||||||
|
const sub = db.prepare('SELECT * FROM subscriptions WHERE user_id=?').get(req.user.id);
|
||||||
|
const mock = db.prepare(`SELECT * FROM mock_payments WHERE user_id=? AND status='completed' ORDER BY id DESC LIMIT 1`).get(req.user.id);
|
||||||
|
res.json({ subscription: sub, mock_payment: mock, mode: PAYMENT_MODE });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stripe webhook
|
||||||
|
app.post('/api/billing/stripe-webhook', async (req, res) => {
|
||||||
|
if (PAYMENT_MODE !== 'live' || !STRIPE_WEBHOOK_SIG) return res.json({ ok: true });
|
||||||
|
try {
|
||||||
|
const Stripe = require('stripe')(STRIPE_SECRET);
|
||||||
|
const event = Stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], STRIPE_WEBHOOK_SIG);
|
||||||
|
if (event.type === 'checkout.session.completed') {
|
||||||
|
const { user_id, plan } = event.data.object.metadata;
|
||||||
|
db.prepare('UPDATE users SET plan=? WHERE id=?').run(plan, user_id);
|
||||||
|
const u = db.prepare('SELECT * FROM users WHERE id=?').get(user_id);
|
||||||
|
if (u) await provisionInstance(Number(user_id), plan, u.region||'eu');
|
||||||
|
}
|
||||||
|
if (event.type === 'customer.subscription.deleted') {
|
||||||
|
const u = db.prepare('SELECT * FROM users WHERE stripe_subscription_id=?').get(event.data.object.id);
|
||||||
|
if (u) {
|
||||||
|
db.prepare('UPDATE users SET plan=? WHERE id=?').run('free', u.id);
|
||||||
|
const inst = db.prepare('SELECT onecloud_id FROM instances WHERE user_id=?').get(u.id);
|
||||||
|
if (inst?.onecloud_id) {
|
||||||
|
await ocRequest('POST', '/vm/image/create', { vm_id: inst.onecloud_id, label: `ix-final-${u.id}` });
|
||||||
|
await ocRequest('POST', '/vm/destroy', { vm_id: inst.onecloud_id });
|
||||||
|
db.prepare(`UPDATE instances SET status='destroyed' WHERE user_id=?`).run(u.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ received: true });
|
||||||
|
} catch (e) { res.status(400).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ADMIN ────────────────────────────────────────────────────────
|
||||||
|
app.get('/api/admin/stats', adminOnly, (req, res) => {
|
||||||
|
const u = db.prepare('SELECT count(*) as t FROM users').get().t;
|
||||||
|
const f = db.prepare("SELECT count(*) as t FROM users WHERE plan='free'").get().t;
|
||||||
|
const p = db.prepare("SELECT count(*) as t FROM users WHERE plan='pro'").get().t;
|
||||||
|
const b = db.prepare("SELECT count(*) as t FROM users WHERE plan='business'").get().t;
|
||||||
|
const e = db.prepare("SELECT count(*) as t FROM users WHERE plan='enterprise'").get().t;
|
||||||
|
const mrr = p*49 + b*199 + e*999;
|
||||||
|
const builds = db.prepare('SELECT COALESCE(sum(builds_count),0) as t FROM users').get().t;
|
||||||
|
const items = db.prepare("SELECT count(*) as t FROM store_items WHERE status='approved'").get().t;
|
||||||
|
const instances= db.prepare("SELECT count(*) as t FROM instances WHERE status='running'").get().t;
|
||||||
|
const revenue = db.prepare('SELECT COALESCE(sum(store_revenue),0) as t FROM users').get().t;
|
||||||
|
const mocks = db.prepare("SELECT count(*) as t FROM mock_payments WHERE status='completed'").get().t;
|
||||||
|
res.json({ users:u, free:f, pro:p, business:b, enterprise:e, mrr, arr:mrr*12, builds, store_items:items, instances, seller_revenue:revenue, mock_completions:mocks, payment_mode:PAYMENT_MODE });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/users', adminOnly, (req, res) => {
|
||||||
|
res.json(db.prepare('SELECT id,email,name,plan,builds_count,region,instance_status,store_seller,created_at FROM users ORDER BY created_at DESC LIMIT 200').all());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/instances', adminOnly, (req, res) => {
|
||||||
|
res.json(db.prepare('SELECT i.*,u.email,u.plan FROM instances i JOIN users u ON i.user_id=u.id ORDER BY i.created_at DESC').all());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/store', adminOnly, (req, res) => {
|
||||||
|
res.json(db.prepare(`SELECT s.*,u.email as seller_email FROM store_items s JOIN users u ON s.seller_id=u.id ORDER BY s.created_at DESC`).all());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/store/:id/approve', adminOnly, (req, res) => {
|
||||||
|
db.prepare("UPDATE store_items SET status='approved' WHERE id=?").run(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/store/:id/reject', adminOnly, (req, res) => {
|
||||||
|
db.prepare("UPDATE store_items SET status='rejected' WHERE id=?").run(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MISC ──────────────────────────────────────────────────────────
|
||||||
|
app.post('/api/contact/enterprise', (req, res) => {
|
||||||
|
const { company, email, use_case } = req.body;
|
||||||
|
if (!email) return res.status(400).json({ error: 'Email required' });
|
||||||
|
db.prepare('INSERT INTO enterprise_leads (company,email,use_case) VALUES (?,?,?)').run(company, email, use_case);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
const stats = db.prepare('SELECT count(*) as t FROM users').get();
|
||||||
|
res.json({ status: 'ok', service: 'ix-saas', version: '2.1.0', users: stats.t, payment_mode: PAYMENT_MODE, ts: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock checkout page
|
||||||
|
app.get('/mock-checkout', (req, res) => {
|
||||||
|
const { session, plan } = req.query;
|
||||||
|
const prices = { pro: 0, business: 0, enterprise: 0, studio_test: 0, lab_test: 0, enterprise_test: 0 };
|
||||||
|
res.send(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>IX Mock Checkout</title>
|
||||||
|
<style>body{font-family:monospace;background:#06060f;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||||
|
.box{background:#10101f;border:1px solid #c8501a;border-radius:12px;padding:2rem;text-align:center;max-width:400px}
|
||||||
|
h2{color:#c8501a;margin-bottom:1rem}p{color:#888;font-size:.9rem}
|
||||||
|
.price{font-size:2rem;color:#fff;margin:1rem 0}
|
||||||
|
button{background:#c8501a;color:#fff;border:none;padding:.8rem 2rem;border-radius:8px;cursor:pointer;font-size:1rem}
|
||||||
|
button:hover{background:#e06030}.note{font-size:.7rem;color:#444;margin-top:1rem}</style>
|
||||||
|
</head><body><div class="box">
|
||||||
|
<h2>⚡ IX Mock Checkout</h2>
|
||||||
|
<p>Plan: <strong style="color:#eee;text-transform:uppercase">${plan}</strong></p>
|
||||||
|
<div class="price">$${prices[plan]||'?'}/mo</div>
|
||||||
|
<p>Test plan — free activation — no real charge.</p>
|
||||||
|
<button onclick="complete()">Activate Test Plan →</button>
|
||||||
|
<div class="note">session: ${session}</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
async function complete() {
|
||||||
|
const r = await fetch('/api/billing/mock-complete', {
|
||||||
|
method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({session_id:'${session}'})
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.ok) window.location.href = '/?mock_success=1&plan=${plan}';
|
||||||
|
else alert('Error: ' + d.error);
|
||||||
|
}
|
||||||
|
</script></body></html>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static
|
||||||
|
|
||||||
|
// DEMO MODULE
|
||||||
|
const demoMod = require("./demo_module");
|
||||||
|
demoMod.registerDemoRoutes(app, db);
|
||||||
|
|
||||||
|
// Community V3 routes (before static)
|
||||||
|
const cv3 = require('./community_v3');
|
||||||
|
cv3.registerCommunityV3Routes(app);
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
app.use((req, res) => {
|
||||||
|
const idx = path.join(__dirname, 'public', 'index.html');
|
||||||
|
if (fs.existsSync(idx)) res.sendFile(idx);
|
||||||
|
else res.status(404).json({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cron: snapshot stale instances every 6h
|
||||||
|
setInterval(async () => {
|
||||||
|
const stale = db.prepare(`SELECT i.*,u.email FROM instances i JOIN users u ON i.user_id=u.id
|
||||||
|
WHERE i.status='running' AND datetime(i.last_active,'+30 days') < datetime('now')`).all();
|
||||||
|
for (const inst of stale) {
|
||||||
|
if (!inst.onecloud_id) continue;
|
||||||
|
await ocRequest('POST', '/vm/image/create', { vm_id: inst.onecloud_id, label: `ix-auto-${inst.user_id}` });
|
||||||
|
await ocRequest('POST', '/vm/shutdown', { vm_id: inst.onecloud_id });
|
||||||
|
db.prepare("UPDATE instances SET status='stopped' WHERE user_id=?").run(inst.user_id);
|
||||||
|
console.log(`[CRON] Snapshotted idle instance for ${inst.email}`);
|
||||||
|
}
|
||||||
|
}, 6 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
|
||||||
|
app.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`[IX SAAS v2.1] :${PORT} | payment=${PAYMENT_MODE} | onecloud=${OC_API_KEY?'yes':'no'}`);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user