316 lines
9.3 KiB
JavaScript
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}`);
|
|
});
|