← back to Web Viewer 3877

server.js

223 lines

const express = require('express');
const helmet = require('helmet');
const { createServer } = require('http');
const { Server } = require('socket.io');
const cheerio = require('cheerio');
const cors = require('cors');
const path = require('path');
const { safeFetch } = require('./lib/safe-fetch');

const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
const server = createServer(app);

// LEDGER NOTE 2026-05-19 — CORS is intentionally left as origin:"*" pending
// a real allowlist. The frontend at public/index.html uses io() (same-origin)
// and fetch() against same-origin paths, so no documented cross-origin client
// exists. SSRF was the higher-priority hardening (shipped this commit); a
// follow-up should set CORS_ALLOWED_ORIGINS env-var and gate both Express and
// Socket.IO on it before this lands on a public IP. Leaving open for now to
// avoid breaking any undocumented embed.
const io = new Server(server, {
  cors: {
    origin: "*",
    methods: ["GET", "POST"]
  }
});

const PORT = process.env.PORT || 3877;

// Middleware
app.use(cors());
app.use(express.json());

// Snapshot-file 404-guard — blocks accidental serving of editor/backup files
// even if one ever lands in public/. Standing rule (fleet-refactor-sweep).
app.use((req, res, next) => {
  if (/\.(bak|orig|rej|swp)(\.|$)|\.pre-|~$/i.test(req.path)) {
    return res.status(404).end();
  }
  next();
});

app.use(express.static('public'));

// Store active conversations
const conversations = new Map();

// Fetch and process web content. All URL validation + SSRF defense is in
// lib/safe-fetch.js — never call axios.get(userUrl) directly from this file.
async function fetchWebContent(url) {
  let response;
  try {
    response = await safeFetch(url);
  } catch (err) {
    // Preserve SSRF-helper status codes for the API layer to translate.
    if (err.isSsrfBlock) throw err;
    const wrapped = new Error(`Failed to fetch content: ${err.message}`);
    wrapped.statusCode = 502;
    throw wrapped;
  }

  try {
    const $ = cheerio.load(response.data);
    
    // Remove scripts and styles
    $('script').remove();
    $('style').remove();
    
    // Extract text content
    const title = $('title').text().trim();
    const metaDescription = $('meta[name="description"]').attr('content') || '';
    
    // Get main content
    let content = '';
    
    // Try to find main content areas
    const contentSelectors = [
      'main', 'article', '[role="main"]', 
      '#content', '.content', '#main', '.main'
    ];
    
    for (const selector of contentSelectors) {
      if ($(selector).length) {
        content = $(selector).text().trim();
        break;
      }
    }
    
    // Fallback to body if no main content found
    if (!content) {
      content = $('body').text().trim();
    }
    
    // Clean up whitespace
    content = content.replace(/\s+/g, ' ').substring(0, 5000);
    
    return {
      url,
      title,
      description: metaDescription,
      content,
      timestamp: new Date().toISOString()
    };
  } catch (error) {
    throw new Error(`Failed to fetch content: ${error.message}`);
  }
}

// API Routes
app.post('/api/fetch', async (req, res) => {
  const { url } = req.body;
  
  if (!url) {
    return res.status(400).json({ error: 'URL is required' });
  }
  
  try {
    const content = await fetchWebContent(url);
    res.json(content);
  } catch (error) {
    const status = error.statusCode || 500;
    res.status(status).json({ error: error.message });
  }
});

// Socket.IO for real-time conversation
io.on('connection', (socket) => {
  console.log('User connected:', socket.id);
  
  // Initialize conversation for this socket
  conversations.set(socket.id, {
    messages: [],
    currentUrl: null,
    webContent: null
  });
  
  socket.on('load-url', async (url) => {
    try {
      const content = await fetchWebContent(url);
      const conversation = conversations.get(socket.id);
      conversation.currentUrl = url;
      conversation.webContent = content;
      
      socket.emit('content-loaded', content);
      socket.emit('bot-message', {
        message: `I've loaded "${content.title}". What would you like to know about this page?`,
        timestamp: new Date().toISOString()
      });
    } catch (error) {
      socket.emit('error', { message: error.message });
    }
  });
  
  socket.on('user-message', (message) => {
    const conversation = conversations.get(socket.id);
    
    if (!conversation.webContent) {
      socket.emit('bot-message', {
        message: 'Please load a webpage first by entering a URL.',
        timestamp: new Date().toISOString()
      });
      return;
    }
    
    // Add user message to conversation
    conversation.messages.push({
      role: 'user',
      content: message,
      timestamp: new Date().toISOString()
    });
    
    // Simple response logic - in production, integrate with AI
    let response = '';
    const lowerMessage = message.toLowerCase();
    const content = conversation.webContent;
    
    if (lowerMessage.includes('summary') || lowerMessage.includes('summarize')) {
      response = `Here's a summary of "${content.title}": ${content.description || content.content.substring(0, 200) + '...'}`;
    } else if (lowerMessage.includes('title')) {
      response = `The page title is: "${content.title}"`;
    } else if (lowerMessage.includes('when')) {
      response = `This content was fetched at ${new Date(content.timestamp).toLocaleString()}`;
    } else if (lowerMessage.includes('search') || lowerMessage.includes('find')) {
      const searchTerm = message.split(/search|find/i)[1]?.trim();
      if (searchTerm && content.content.toLowerCase().includes(searchTerm.toLowerCase())) {
        response = `Yes, I found "${searchTerm}" in the content of this page.`;
      } else {
        response = `I couldn't find "${searchTerm}" in this page's content.`;
      }
    } else {
      response = `I understand you're asking about "${message}". The page "${content.title}" contains information that might be relevant. What specific aspect would you like to explore?`;
    }
    
    // Add bot response to conversation
    conversation.messages.push({
      role: 'assistant',
      content: response,
      timestamp: new Date().toISOString()
    });
    
    socket.emit('bot-message', {
      message: response,
      timestamp: new Date().toISOString()
    });
  });
  
  socket.on('clear-conversation', () => {
    const conversation = conversations.get(socket.id);
    conversation.messages = [];
    socket.emit('conversation-cleared');
  });
  
  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.id);
    conversations.delete(socket.id);
  });
});

// Start server
server.listen(PORT, () => {
  console.log(`Web Viewer running on http://45.61.58.125:${PORT}`);
});