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