File size: 4,834 Bytes
f580254 6d545e0 f580254 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | const http = require('http');
const express = require('express');
const path = require('path');
const fs = require('fs');
const { WebSocketServer } = require('ws');
const { spawn } = require('child_process');
const pty = require('node-pty');
const app = express();
const server = http.createServer(app);
const PORT = process.env.PORT || 7860;
const TERMINAL_TOKEN = process.env.TERMINAL_TOKEN || ""; // set in Space Secrets for security
const JOB_WEBHOOK_TOKEN = process.env.JOB_WEBHOOK_TOKEN || ""; // bearer token for webhook auth
const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN || ""; // xoxb- token from Slack app
const SLACK_CHANNEL_ID = process.env.SLACK_CHANNEL_ID || ""; // channel ID to post heartbeat
const HEARTBEAT_LOG_PATH = process.env.HEARTBEAT_LOG_PATH || '/tmp/heartbeat.log';
// Behind a proxy (HF), trust X-Forwarded-* for req.ip
app.set('trust proxy', true);
// Serve static client
app.use(express.static(path.join(__dirname, 'public')));
// JSON parser for webhook
app.use(express.json());
// Basic liveness endpoint
app.get('/health', (_req, res) => res.status(200).send('ok'));
// Minimal webhook to send a heartbeat message to Slack
app.post('/tasks/heartbeat', async (req, res) => {
try {
const authHeader = req.headers['authorization'] || '';
const bearer = authHeader.startsWith('Bearer ')
? authHeader.slice('Bearer '.length)
: (req.query.token || '');
// Dev-friendly auth: enforce only when JOB_WEBHOOK_TOKEN is set
if (JOB_WEBHOOK_TOKEN && bearer !== JOB_WEBHOOK_TOKEN) {
return res.sendStatus(401);
}
if (!SLACK_BOT_TOKEN || !SLACK_CHANNEL_ID) {
return res.status(500).json({ error: 'Slack not configured' });
}
const now = new Date().toISOString();
const host = req.headers.host || 'unknown-host';
const text = `Heartbeat: web terminal Space alive at ${now} (host: ${host})`;
const resp = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Authorization': `Bearer ${SLACK_BOT_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ channel: SLACK_CHANNEL_ID, text })
});
const data = await resp.json().catch(() => ({}));
const ip = req.ip;
const ua = req.headers['user-agent'] || '';
const logLine = `${now}\tip=${ip}\tua=${JSON.stringify(ua)}\tstatus=${data && data.ok ? 'ok' : 'error'}\n`;
try {
const dir = path.dirname(HEARTBEAT_LOG_PATH);
// ensure directory exists (best-effort)
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(HEARTBEAT_LOG_PATH, logLine, 'utf8');
} catch (_) { /* ignore log failures */ }
// Kick off pCloud backup from ~/.claude to /claude-huggingface/claude (non-blocking)
try {
const child = spawn('python', ['/app/scripts/upload_claude_to_pcloud.py'], {
cwd: __dirname,
stdio: 'inherit',
env: process.env,
detached: false
});
// no await; run in background
} catch (_) { /* ignore spawn errors */ }
if (!data.ok) {
return res.status(502).json({ error: 'Slack API error', details: data });
}
return res.status(202).json({ ok: true, ts: data.ts, channel: data.channel });
} catch (err) {
return res.status(500).json({ error: 'server_error' });
}
});
// WebSocket for terminal
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws, req) => {
try {
// Simple shared-secret check via query string: /ws?token=...
const url = new URL(req.url, `http://${req.headers.host}`);
const token = url.searchParams.get('token') || '';
if (TERMINAL_TOKEN && token !== TERMINAL_TOKEN) {
ws.close(1008, 'Invalid token'); // Policy Violation
return;
}
const shell = process.env.SHELL || '/bin/bash';
const ptyProcess = pty.spawn(shell, ['-l'], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: process.env.HOME || process.cwd(),
env: process.env
});
ptyProcess.onData(data => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'output', data }));
}
});
ws.on('message', (msg) => {
try {
const { type, data, cols, rows } = JSON.parse(msg.toString());
if (type === 'input' && typeof data === 'string') {
ptyProcess.write(data);
} else if (type === 'resize' && Number.isInteger(cols) && Number.isInteger(rows)) {
ptyProcess.resize(cols, rows);
}
} catch (_) {/* ignore malformed */}
});
ws.on('close', () => {
try { ptyProcess.kill(); } catch (_) {}
});
} catch (err) {
try { ws.close(1011, 'Server error'); } catch (_) {}
}
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`Web terminal listening on :${PORT}`);
});
|