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}`);
});