done
Browse files- .github/workflows/keep-alive.yml +13 -0
- dev/mock-gateway.js +80 -2
- frontend/src/config.js +6 -3
- render.yaml +26 -0
.github/workflows/keep-alive.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Keep Render awake
|
| 2 |
+
# Pings /health every ~14 min so the free Render web service never spins down.
|
| 3 |
+
# NOTE: edit the URL below to your real Render URL after the first deploy.
|
| 4 |
+
on:
|
| 5 |
+
schedule:
|
| 6 |
+
- cron: '*/14 * * * *'
|
| 7 |
+
workflow_dispatch: {} # lets you trigger it manually too
|
| 8 |
+
jobs:
|
| 9 |
+
ping:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- name: Ping health endpoint
|
| 13 |
+
run: curl -fsS https://astromesh.onrender.com/health || true
|
dev/mock-gateway.js
CHANGED
|
@@ -13,12 +13,34 @@ const https = require('https');
|
|
| 13 |
const crypto = require('crypto');
|
| 14 |
const fs = require('fs');
|
| 15 |
const path = require('path');
|
|
|
|
| 16 |
const { URL } = require('url');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
// Reuse the frontend's installed SGP4 lib for real conjunction screening.
|
| 18 |
let satLib = null;
|
| 19 |
try { satLib = require(path.join(__dirname, '..', 'frontend', 'node_modules', 'satellite.js')); } catch { /* optional */ }
|
| 20 |
|
| 21 |
-
const PORT = 8090;
|
| 22 |
|
| 23 |
// ---------------------------------------------------------------------------
|
| 24 |
// Minimal .env loader (no deps) — loads repo-root .env into process.env
|
|
@@ -311,12 +333,35 @@ setTimeout(() => {
|
|
| 311 |
// HTTP helpers
|
| 312 |
// ---------------------------------------------------------------------------
|
| 313 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
function cors(res) {
|
| 315 |
-
res.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
| 317 |
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
| 318 |
}
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
function json(res, status, body) {
|
| 321 |
cors(res);
|
| 322 |
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
@@ -1195,6 +1240,7 @@ async function handleRequest(req, res) {
|
|
| 1195 |
const parsed = new URL(req.url, `http://localhost:${PORT}`);
|
| 1196 |
const path = parsed.pathname;
|
| 1197 |
const method = req.method.toUpperCase();
|
|
|
|
| 1198 |
|
| 1199 |
// Preflight
|
| 1200 |
if (method === 'OPTIONS') {
|
|
@@ -1272,6 +1318,8 @@ async function handleRequest(req, res) {
|
|
| 1272 |
// ---- POST routes ----
|
| 1273 |
|
| 1274 |
if (method === 'POST' && path === '/api/chat') {
|
|
|
|
|
|
|
| 1275 |
const body = await readBody(req);
|
| 1276 |
const msgs = Array.isArray(body.messages) ? body.messages
|
| 1277 |
: (body.message ? [{ role: 'user', content: String(body.message) }] : []);
|
|
@@ -1392,6 +1440,36 @@ async function handleRequest(req, res) {
|
|
| 1392 |
return json(res, 200, { status: 'reset', leader_id: leaderId });
|
| 1393 |
}
|
| 1394 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1395 |
// 404
|
| 1396 |
cors(res);
|
| 1397 |
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
|
|
| 13 |
const crypto = require('crypto');
|
| 14 |
const fs = require('fs');
|
| 15 |
const path = require('path');
|
| 16 |
+
const path_ = path; // alias: `path` is shadowed inside handleRequest
|
| 17 |
const { URL } = require('url');
|
| 18 |
+
|
| 19 |
+
// Built frontend dir (single-origin serving). Set SERVE_FRONTEND=0 to disable.
|
| 20 |
+
const DIST_DIR = process.env.SERVE_FRONTEND === '0'
|
| 21 |
+
? null
|
| 22 |
+
: path.join(__dirname, '..', 'frontend', 'dist');
|
| 23 |
+
const MIME = {
|
| 24 |
+
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
| 25 |
+
'.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml',
|
| 26 |
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
|
| 27 |
+
'.gif': 'image/gif', '.ico': 'image/x-icon', '.glb': 'model/gltf-binary',
|
| 28 |
+
'.gltf': 'model/gltf+json', '.bin': 'application/octet-stream', '.wasm': 'application/wasm',
|
| 29 |
+
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.txt': 'text/plain',
|
| 30 |
+
};
|
| 31 |
+
function sendFile(res, filePath) {
|
| 32 |
+
const ext = path_.extname(filePath).toLowerCase();
|
| 33 |
+
const type = MIME[ext] || 'application/octet-stream';
|
| 34 |
+
// hash-named build assets are immutable; cache hard. index.html stays fresh.
|
| 35 |
+
const cache = filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable';
|
| 36 |
+
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': cache });
|
| 37 |
+
fs.createReadStream(filePath).pipe(res);
|
| 38 |
+
}
|
| 39 |
// Reuse the frontend's installed SGP4 lib for real conjunction screening.
|
| 40 |
let satLib = null;
|
| 41 |
try { satLib = require(path.join(__dirname, '..', 'frontend', 'node_modules', 'satellite.js')); } catch { /* optional */ }
|
| 42 |
|
| 43 |
+
const PORT = process.env.PORT || 8090;
|
| 44 |
|
| 45 |
// ---------------------------------------------------------------------------
|
| 46 |
// Minimal .env loader (no deps) — loads repo-root .env into process.env
|
|
|
|
| 333 |
// HTTP helpers
|
| 334 |
// ---------------------------------------------------------------------------
|
| 335 |
|
| 336 |
+
// Comma-separated list in env, e.g. "https://astromesh.onrender.com,https://astromesh.web.app"
|
| 337 |
+
// Defaults to "*" (open) for local dev. Set ALLOWED_ORIGINS in production to lock it down.
|
| 338 |
+
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || '*')
|
| 339 |
+
.split(',').map((s) => s.trim()).filter(Boolean);
|
| 340 |
+
|
| 341 |
function cors(res) {
|
| 342 |
+
const origin = res._reqOrigin;
|
| 343 |
+
if (ALLOWED_ORIGINS.includes('*')) {
|
| 344 |
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
| 345 |
+
} else if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
| 346 |
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
| 347 |
+
res.setHeader('Vary', 'Origin');
|
| 348 |
+
}
|
| 349 |
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
| 350 |
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
| 351 |
}
|
| 352 |
|
| 353 |
+
// ---- simple in-memory per-IP rate limiter (protects the paid Groq endpoint) ----
|
| 354 |
+
const _rlHits = new Map();
|
| 355 |
+
function rateLimited(req, max, windowMs) {
|
| 356 |
+
const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|
| 357 |
+
|| (req.socket && req.socket.remoteAddress) || 'unknown';
|
| 358 |
+
const now = Date.now();
|
| 359 |
+
const arr = (_rlHits.get(ip) || []).filter((t) => now - t < windowMs);
|
| 360 |
+
arr.push(now);
|
| 361 |
+
_rlHits.set(ip, arr);
|
| 362 |
+
return arr.length > max;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
function json(res, status, body) {
|
| 366 |
cors(res);
|
| 367 |
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
|
|
| 1240 |
const parsed = new URL(req.url, `http://localhost:${PORT}`);
|
| 1241 |
const path = parsed.pathname;
|
| 1242 |
const method = req.method.toUpperCase();
|
| 1243 |
+
res._reqOrigin = req.headers.origin; // used by cors() for the allow-list
|
| 1244 |
|
| 1245 |
// Preflight
|
| 1246 |
if (method === 'OPTIONS') {
|
|
|
|
| 1318 |
// ---- POST routes ----
|
| 1319 |
|
| 1320 |
if (method === 'POST' && path === '/api/chat') {
|
| 1321 |
+
// 20 AI calls / minute / IP — stops anyone draining the Groq key via the public URL
|
| 1322 |
+
if (rateLimited(req, 20, 60000)) return json(res, 429, { error: 'Too many requests, slow down.' });
|
| 1323 |
const body = await readBody(req);
|
| 1324 |
const msgs = Array.isArray(body.messages) ? body.messages
|
| 1325 |
: (body.message ? [{ role: 'user', content: String(body.message) }] : []);
|
|
|
|
| 1440 |
return json(res, 200, { status: 'reset', leader_id: leaderId });
|
| 1441 |
}
|
| 1442 |
|
| 1443 |
+
// ---- /celestrak proxy (so the browser never calls celestrak.org directly) ----
|
| 1444 |
+
if (method === 'GET' && path.startsWith('/celestrak/')) {
|
| 1445 |
+
const target = 'https://celestrak.org/' + path.slice('/celestrak/'.length) + (parsed.search || '');
|
| 1446 |
+
https.get(target, (up) => {
|
| 1447 |
+
cors(res);
|
| 1448 |
+
res.writeHead(up.statusCode || 200, { 'Content-Type': up.headers['content-type'] || 'text/plain' });
|
| 1449 |
+
up.pipe(res);
|
| 1450 |
+
}).on('error', (e) => json(res, 502, { error: 'celestrak proxy failed', detail: e.message }));
|
| 1451 |
+
return;
|
| 1452 |
+
}
|
| 1453 |
+
|
| 1454 |
+
// ---- static frontend (single-origin: serves the built Vue app) ----
|
| 1455 |
+
if (method === 'GET' && DIST_DIR) {
|
| 1456 |
+
const rel = path === '/' ? 'index.html' : path.replace(/^\/+/, '');
|
| 1457 |
+
const filePath = path_.join(DIST_DIR, rel);
|
| 1458 |
+
// block path traversal
|
| 1459 |
+
if (filePath.startsWith(DIST_DIR)) {
|
| 1460 |
+
try {
|
| 1461 |
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
| 1462 |
+
return sendFile(res, filePath);
|
| 1463 |
+
}
|
| 1464 |
+
} catch (_) {}
|
| 1465 |
+
// SPA fallback: any unknown non-API path -> index.html
|
| 1466 |
+
if (!path.startsWith('/api') && !path.startsWith('/control') && !path.startsWith('/celestrak')) {
|
| 1467 |
+
const idx = path_.join(DIST_DIR, 'index.html');
|
| 1468 |
+
if (fs.existsSync(idx)) return sendFile(res, idx);
|
| 1469 |
+
}
|
| 1470 |
+
}
|
| 1471 |
+
}
|
| 1472 |
+
|
| 1473 |
// 404
|
| 1474 |
cors(res);
|
| 1475 |
res.writeHead(404, { 'Content-Type': 'application/json' });
|
frontend/src/config.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Same-origin: in dev the Vite proxy forwards /api + /ws to :8090; in production
|
| 2 |
+
// the gateway serves this frontend AND /api + /ws from the very same origin.
|
| 3 |
+
// Nothing here exposes the backend host — the browser only ever sees its own URL.
|
| 4 |
+
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
| 5 |
+
export const GATEWAY = '' // relative — same origin
|
| 6 |
+
export const WS_URL = `${proto}://${window.location.host}/ws`
|
render.yaml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Render deploy — single service (gateway serves frontend + /api + /ws, all same-origin).
|
| 2 |
+
# Secrets are set in the Render dashboard, never committed.
|
| 3 |
+
services:
|
| 4 |
+
- type: web
|
| 5 |
+
name: astromesh
|
| 6 |
+
runtime: node
|
| 7 |
+
plan: free
|
| 8 |
+
# Installs frontend deps (incl. satellite.js used by the gateway) and builds the Vue app.
|
| 9 |
+
buildCommand: cd frontend && npm install && npm run build
|
| 10 |
+
startCommand: node dev/mock-gateway.js
|
| 11 |
+
healthCheckPath: /health
|
| 12 |
+
autoDeploy: true
|
| 13 |
+
envVars:
|
| 14 |
+
- key: GROQ_MODEL
|
| 15 |
+
value: openai/gpt-oss-120b
|
| 16 |
+
# Set these in the dashboard (Environment tab) — do NOT hardcode:
|
| 17 |
+
- key: SPACETRACK_IDENTITY
|
| 18 |
+
sync: false
|
| 19 |
+
- key: SPACETRACK_PASSWORD
|
| 20 |
+
sync: false
|
| 21 |
+
- key: GROQ_API_KEY
|
| 22 |
+
sync: false
|
| 23 |
+
# After first deploy, set this to your live URL to lock CORS, e.g.
|
| 24 |
+
# https://astromesh.onrender.com
|
| 25 |
+
- key: ALLOWED_ORIGINS
|
| 26 |
+
sync: false
|