215 lines
5.6 KiB
JavaScript
215 lines
5.6 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');
|
|
return JSON.parse(raw);
|
|
}
|
|
} 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, settings, originClientId } = req.body;
|
|
|
|
if (!Array.isArray(cards)) {
|
|
return res.status(400).json({ success: false, error: 'Invalid desk state: cards required' });
|
|
}
|
|
|
|
const newState = {
|
|
cards,
|
|
props: Array.isArray(props) ? props : [],
|
|
strokes: Array.isArray(strokes) ? strokes : [],
|
|
settings: settings || {},
|
|
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
|
|
});
|
|
});
|
|
|
|
// 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.get('*', (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}`);
|
|
});
|