import express from 'express'; import cors from 'cors'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 80; const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data'); const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); const DESK_FILE = path.join(DATA_DIR, 'desk.json'); // Ensure storage directories exist if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } if (!fs.existsSync(UPLOADS_DIR)) { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } // Middleware app.use(cors()); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true, limit: '50mb' })); // Configure Multer for file/camera photo uploads const storage = multer.diskStorage({ destination: (_req, _file, cb) => { cb(null, UPLOADS_DIR); }, filename: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; const cleanExt = ['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(ext) ? ext : '.jpg'; const uniqueName = `glimpse_${Date.now()}_${Math.random().toString(36).substring(2, 8)}${cleanExt}`; cb(null, uniqueName); } }); const upload = multer({ storage, limits: { fileSize: 25 * 1024 * 1024 } // 25 MB max photo size }); // SSE Client Connection Pool const sseClients = new Set(); function broadcastEvent(eventData) { const payload = `data: ${JSON.stringify(eventData)}\n\n`; for (const client of sseClients) { try { client.res.write(payload); } catch { sseClients.delete(client); } } } // Keep-alive heartbeat for SSE every 20 seconds setInterval(() => { for (const client of sseClients) { try { client.res.write(':keep-alive\n\n'); } catch { sseClients.delete(client); } } }, 20000); // Helper to safely read desk state function readDeskState() { try { if (fs.existsSync(DESK_FILE)) { const raw = fs.readFileSync(DESK_FILE, 'utf-8'); const desk = JSON.parse(raw); if (desk && (!desk.boards || typeof desk.boards !== 'object')) { const currentBoard = desk.activeBoard || (desk.settings && desk.settings.deskTexture) || 'oak'; desk.boards = { [currentBoard]: { cards: desk.cards || [], props: desk.props || [], strokes: desk.strokes || [], yarnLinks: desk.yarnLinks || [], settings: desk.settings || {} } }; desk.activeBoard = currentBoard; } return desk; } } catch (err) { console.error('Error reading desk state:', err); } return null; } // Helper to safely write desk state (atomic write via temp file) function writeDeskState(state) { const tempFile = `${DESK_FILE}.tmp.${Date.now()}`; try { fs.writeFileSync(tempFile, JSON.stringify(state, null, 2), 'utf-8'); fs.renameSync(tempFile, DESK_FILE); return true; } catch (err) { console.error('Error writing desk state:', err); try { if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); } catch { // ignore } return false; } } // --- API Endpoints --- // Healthcheck app.get('/api/health', (_req, res) => { res.json({ status: 'healthy', uptime: process.uptime(), timestamp: new Date().toISOString() }); }); // SSE Stream for Real-time Cross-Device Sync app.get('/api/events', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Disable Traefik/Nginx buffering res.flushHeaders?.(); const client = { id: Math.random().toString(36).substring(2), res }; sseClients.add(client); res.write(`data: ${JSON.stringify({ type: 'connected', clientId: client.id })}\n\n`); req.on('close', () => { sseClients.delete(client); }); }); // Get current desk state app.get('/api/desk', (_req, res) => { const desk = readDeskState(); if (desk) { res.json({ success: true, desk }); } else { res.json({ success: true, desk: null, initialized: false }); } }); // Save desk state app.post('/api/desk', (req, res) => { const { cards, props, strokes, yarnLinks, settings, activeBoard, boards, originClientId } = req.body; if (!Array.isArray(cards)) { return res.status(400).json({ success: false, error: 'Invalid desk state: cards required' }); } const currentBoard = activeBoard || (settings && settings.deskTexture) || 'oak'; const updatedBoards = boards && typeof boards === 'object' ? { ...boards } : {}; updatedBoards[currentBoard] = { cards, props: Array.isArray(props) ? props : [], strokes: Array.isArray(strokes) ? strokes : [], yarnLinks: Array.isArray(yarnLinks) ? yarnLinks : (updatedBoards[currentBoard]?.yarnLinks || []), settings: settings || {} }; const newState = { cards, props: Array.isArray(props) ? props : [], strokes: Array.isArray(strokes) ? strokes : [], yarnLinks: Array.isArray(yarnLinks) ? yarnLinks : [], settings: settings || {}, activeBoard: currentBoard, boards: updatedBoards, updatedAt: Date.now() }; const saved = writeDeskState(newState); if (!saved) { return res.status(500).json({ success: false, error: 'Failed to persist desk state' }); } // Broadcast to all other devices in real-time broadcastEvent({ type: 'desk_updated', desk: newState, originClientId, timestamp: newState.updatedAt }); res.json({ success: true, updatedAt: newState.updatedAt }); }); // Upload photo/file endpoint app.post('/api/upload', upload.single('file'), (req, res) => { if (!req.file) { return res.status(400).json({ success: false, error: 'No file uploaded' }); } const publicUrl = `/uploads/${req.file.filename}`; res.json({ success: true, url: publicUrl, filename: req.file.filename, size: req.file.size }); }); // Bookmark web page metadata preview extractor app.get('/api/bookmark-meta', async (req, res) => { const targetUrl = req.query.url; if (!targetUrl || typeof targetUrl !== 'string') { return res.status(400).json({ success: false, error: 'url parameter required' }); } let validUrl; try { validUrl = new URL(targetUrl.startsWith('http') ? targetUrl : `https://${targetUrl}`); } catch { return res.status(400).json({ success: false, error: 'Invalid URL format' }); } const hostname = validUrl.hostname; const favicon = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(hostname)}&sz=128`; const mshotsScreenshot = `https://s0.wp.com/mshots/v1/${encodeURIComponent(validUrl.href)}?w=600`; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 4000); const response = await fetch(validUrl.href, { signal: controller.signal, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' } }); clearTimeout(timeout); const html = await response.text(); // Extract title const ogTitleMatch = html.match(/]*>([^<]+)<\/title>/i); const title = ogTitleMatch?.[1] || titleMatch?.[1] || hostname; // Extract description const ogDescMatch = html.match(/ { if (req.path.startsWith('/api') || req.path.startsWith('/uploads')) { return res.status(404).json({ error: 'Not found' }); } res.sendFile(path.join(distPath, 'index.html')); }); } // Start Server app.listen(PORT, '0.0.0.0', () => { console.log(`[Glimpse Backend] Server listening on http://0.0.0.0:${PORT}`); console.log(`[Glimpse Backend] Storage path: ${DATA_DIR}`); });