Files

316 lines
9.3 KiB
JavaScript

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(/<meta\s+property=["']og:title["']\s+content=["'](.*?)["']/i) ||
html.match(/<meta\s+content=["'](.*?)["']\s+property=["']og:title["']/i);
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
const title = ogTitleMatch?.[1] || titleMatch?.[1] || hostname;
// Extract description
const ogDescMatch = html.match(/<meta\s+property=["']og:description["']\s+content=["'](.*?)["']/i) ||
html.match(/<meta\s+name=["']description["']\s+content=["'](.*?)["']/i);
const description = ogDescMatch?.[1] || '';
// Extract image
const ogImageMatch = html.match(/<meta\s+property=["']og:image["']\s+content=["'](.*?)["']/i) ||
html.match(/<meta\s+content=["'](.*?)["']\s+property=["']og:image["']/i);
let image = ogImageMatch?.[1] || mshotsScreenshot;
if (image && image.startsWith('/')) {
image = `${validUrl.origin}${image}`;
}
res.json({
success: true,
url: validUrl.href,
hostname,
title: title.trim(),
description: description.trim(),
image,
favicon
});
} catch {
res.json({
success: true,
url: validUrl.href,
hostname,
title: hostname,
description: '',
image: mshotsScreenshot,
favicon
});
}
});
// Serve uploaded photos/media
app.use('/uploads', express.static(UPLOADS_DIR, {
maxAge: '7d',
immutable: true
}));
// Serve static frontend from dist
const distPath = path.resolve(__dirname, '../dist');
if (fs.existsSync(distPath)) {
app.use(express.static(distPath));
// SPA Fallback: serve index.html for unknown routes
app.use((req, res) => {
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}`);
});