mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Bold reported loop lag that grew with uptime and reset on restart. The signature they saw — mean = p50 = p99 = max, identical to two decimals — is not a fixed cost paid on every cycle. It is what an IntervalHistogram window reports when it recorded exactly ONE delay: the mean is the raw value, and every percentile returns the bucket ceiling above it. Reproduced against their exact numbers (1329.07 / 1329.59). So the loop took one long turn that swallowed the sampling second, episodically — which is what they later confirmed independently. The turn is ours, and it is now measured rather than theorised. Probing the real worker against a real WAL with one reader mid-transaction: a single main-thread write blocked for 4,936ms behind the worker's wal_checkpoint(TRUNCATE), which then reported WAL 8.8MB -> 8.8MB. TRUNCATE is the blocking form and its locks are held ACROSS connections, so moving it to a worker kept the fsync off the loop but not the lock; and it does not throw when it cannot get those locks, it returns busy=1 having sat on SQLite's 5s busy timeout and reclaimed nothing. Five seconds of stalled loop for zero benefit, and silent. It was reached far too easily. The rule was "escalate if the WAL grew across three consecutive 15s runs" — which any sustained 45-second write burst satisfies. A customer's fleet powering on in the morning does it daily. Two gates, because either alone leaves the hole open. A size FLOOR, so a WAL in the lower half of its budget can't buy a blocking checkpoint it has nothing to reclaim from. And a COOLDOWN, because the floor alone fixes nothing for Bold — their WAL already sits at 6.2MB against a 16MB high-water, above any sane floor, so every burst would still escalate. However long the pressure lasts, our own maintenance may now stall the loop at most once per window. The high-water rule bypasses both and is untouched: a runaway WAL is the one case worth blocking for, so the "WAL cannot grow forever" invariant is exactly as strong as before. A busy TRUNCATE now says so in the log instead of reading like a success. Also softened the adjacent path: when the worker is declared unrecoverable, engageFallback() re-arms inline autocheckpoint on the main connection — a state that is STICKY for the life of the process, i.e. exactly the shape of "degrades with uptime, a restart fixes it". It used to also run an unconditional main-thread TRUNCATE on the way in; that now happens only when the WAL is genuinely over high-water, and the fallback state is served on /api/status rather than being inferable only from a log line that may have rolled. Telemetry, so the next report is self-explanatory: loop_lag carries `samples` (~50 when healthy, 1 when a single turn swallowed the second), `tick_gap_ms` measured on the WALL CLOCK independently of the histogram, and `worst_tick_gap_ms`/`worst_tick_at` — monotone, so five-minute polling can no longer miss an episode. Band semantics are deliberately unchanged. A one-sample window during a real stall is the correct trigger for the shed valve; suppressing it would blind the protection at exactly the moment it is needed. Separately, device_telemetry gets the age sweep it never had. The per-heartbeat row cap only ever trims the device whose heartbeat is being handled, so a device that STOPS reporting leaves its rows behind forever. The new sweep is per-device (rides idx_telemetry_device rather than scanning), chunked and yielding like the device_status_log one, and defaults to 30 days to match the uptime report's own default window — so it cannot remove rows that report would have shown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
593 lines
31 KiB
JavaScript
593 lines
31 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { db } = require('../db/database');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const config = require('../config');
|
|
const { sixDigitCode } = require('../lib/numeric-code');
|
|
const VERSION = require('../version');
|
|
const { PLATFORM_ROLES, resolveSessionUser } = require('../middleware/auth');
|
|
const { INLINE_SAFE_EXTS } = require('../lib/upload-sniff');
|
|
const loopLag = require('../services/loop-lag');
|
|
// #146 P3.8: soak observability — internal limiter/maintenance states.
|
|
const flapLimiter = require('../lib/flap-limiter');
|
|
const otaBreaker = require('../lib/ota-breaker');
|
|
const otaDownloadGuard = require('../lib/ota-download-guard');
|
|
const logCoalescer = require('../lib/log-coalescer');
|
|
const { getMaintenanceStats } = require('../db/database');
|
|
const { getCheckpointerState } = require('../db/wal-checkpointer'); // #240
|
|
const heartbeat = require('../services/heartbeat');
|
|
const appSettings = require('../lib/app-settings');
|
|
|
|
// Public status page
|
|
router.get('/', (req, res) => {
|
|
const uptime = process.uptime();
|
|
const version = VERSION;
|
|
|
|
const body = {
|
|
status: 'ok',
|
|
version,
|
|
uptime_human: formatUptime(uptime),
|
|
timestamp: new Date().toISOString(),
|
|
// #142: current event-loop lag snapshot, so site lag is diagnosable from the
|
|
// health endpoint independent of any throttling. Cheap (in-memory read).
|
|
loop_lag: loopLag.getLag(),
|
|
// #146: ALWAYS-ON live-fleet gauge — devices with a live WS socket THIS INSTANT
|
|
// (from the heartbeat connection map), NOT devices.status='online' (which lags by
|
|
// the offline-timeout). The single most-glanced operational number; never gated.
|
|
devices_connected: heartbeat.getConnectedCount(),
|
|
};
|
|
|
|
// #146: the debug block is admin-toggleable (app_settings.status_debug_enabled),
|
|
// defaulting to the STATUS_DEBUG_ENABLED env behavior. Cheap cached boolean. When off,
|
|
// the `debug` key is omitted entirely. Aggregate counts only (no ids/secrets).
|
|
if (appSettings.getBool('status_debug_enabled', config.statusDebugEnabled)) {
|
|
body.debug = {
|
|
flap: flapLimiter.stats(), // buckets, quarantined, refused{Total,LastWindow}, quarantineStarts{Total,LastWindow}
|
|
ota_breaker: otaBreaker.stats(), // rateBackoff{Total,LastWindow}
|
|
ota_download: otaDownloadGuard.stats(), // inFlight, served/shed ThisWindow + Total
|
|
maintenance: getMaintenanceStats(), // deleted, ms, at, running, sweepsTotal
|
|
wal_checkpoint: getCheckpointerState(), // #240 worker alive?, sticky fallback?, respawns, WAL bytes
|
|
log_coalescer_buffer: logCoalescer._size(),
|
|
};
|
|
}
|
|
|
|
res.json(body);
|
|
});
|
|
|
|
function formatUptime(seconds) {
|
|
const d = Math.floor(seconds / 86400);
|
|
const h = Math.floor((seconds % 86400) / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
if (d > 0) return `${d}d ${h}h ${m}m`;
|
|
if (h > 0) return `${h}h ${m}m`;
|
|
return `${m}m`;
|
|
}
|
|
|
|
// These three routes take the session token from the query string / Authorization header
|
|
// and resolve it themselves rather than sitting behind requireAuth. resolveSessionUser is
|
|
// the SAME resolver requireAuth uses, so they inherit every check it makes (pre-TOTP
|
|
// refusal, live user row, forced password change). Each keeps the exact status/body it
|
|
// returned before for the invalid-token case.
|
|
function denySession(res, err) {
|
|
if (err && err.code === 'mfa_required') return res.status(401).json({ error: 'mfa_required' });
|
|
if (err && err.code === 'password_change_required') return res.status(403).json({ error: 'password_change_required' });
|
|
return res.status(401).json({ error: 'Invalid token' });
|
|
}
|
|
|
|
// Full database backup (superadmin only)
|
|
router.get('/backup', (req, res) => {
|
|
const token = req.query.token;
|
|
if (!token) return res.status(401).json({ error: 'Token required' });
|
|
|
|
let session;
|
|
try {
|
|
session = resolveSessionUser(token);
|
|
} catch (err) {
|
|
// An unknown user id stays indistinguishable from "not a platform admin" (as before),
|
|
// so this endpoint never confirms whether a given id exists.
|
|
if (err.code === 'user_not_found') return res.status(403).json({ error: 'Platform admin only' });
|
|
return denySession(res, err);
|
|
}
|
|
// A break-glass identity has no users row, so it could never pass the role check here
|
|
// before; keep it that way rather than letting the synthetic role claim decide.
|
|
if (session.viaRecovery || !PLATFORM_ROLES.includes(session.user.role)) {
|
|
return res.status(403).json({ error: 'Platform admin only' });
|
|
}
|
|
|
|
const dbPath = require('../config').dbPath;
|
|
res.download(dbPath, `remotedisplay-backup-${new Date().toISOString().split('T')[0]}.db`);
|
|
});
|
|
|
|
// User data export (own data only)
|
|
router.get('/export', (req, res) => {
|
|
const token = req.query.token;
|
|
if (!token) return res.status(401).json({ error: 'Token required' });
|
|
|
|
let userId;
|
|
let workspaceId;
|
|
try {
|
|
const session = resolveSessionUser(token);
|
|
// For a break-glass identity this is the synthetic recovery id, which has no users
|
|
// row - the lookup below then 404s exactly as the inline verify did before.
|
|
userId = session.user.id;
|
|
workspaceId = session.decoded.current_workspace_id || null;
|
|
if (!userId) return res.status(401).json({ error: 'Invalid token' });
|
|
} catch (err) {
|
|
if (err.code === 'user_not_found') return res.status(404).json({ error: 'User not found' });
|
|
return denySession(res, err);
|
|
}
|
|
|
|
// Re-read with the export's own column list (it needs created_at, which the session
|
|
// resolver doesn't select).
|
|
const user = db.prepare('SELECT id, email, name, role, auth_provider, plan_id, created_at FROM users WHERE id = ?').get(userId);
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
|
|
// Phase 2.2f: export workspace-scoped branding. Fall back to first-accessible
|
|
// workspace if the JWT didn't carry one.
|
|
if (!workspaceId) {
|
|
const w = db.prepare(`
|
|
SELECT w.id FROM workspaces w
|
|
JOIN workspace_members wm ON wm.workspace_id = w.id
|
|
WHERE wm.user_id = ? ORDER BY wm.joined_at ASC LIMIT 1
|
|
`).get(userId);
|
|
workspaceId = w?.id || null;
|
|
}
|
|
|
|
const devices = db.prepare('SELECT id, name, status, ip_address, android_version, app_version, screen_width, screen_height, created_at FROM devices WHERE user_id = ?').all(userId);
|
|
const deviceIds = devices.map(d => d.id);
|
|
const devicePlaceholders = deviceIds.map(() => '?').join(',') || "'__none__'";
|
|
|
|
const content = db.prepare('SELECT id, filename, mime_type, file_size, duration_sec, remote_url, width, height, created_at FROM content WHERE user_id = ?').all(userId);
|
|
const widgets = db.prepare('SELECT id, widget_type, name, config, created_at FROM widgets WHERE user_id = ?').all(userId);
|
|
const layouts = db.prepare('SELECT id, name, width, height, is_template, template_category, created_at FROM layouts WHERE user_id = ? AND is_template = 0').all(userId);
|
|
const layoutIds = layouts.map(l => l.id);
|
|
const layoutPlaceholders = layoutIds.map(() => '?').join(',') || "'__none__'";
|
|
const layoutZones = layoutIds.length ? db.prepare(`SELECT * FROM layout_zones WHERE layout_id IN (${layoutPlaceholders})`).all(...layoutIds) : [];
|
|
|
|
const playlists = db.prepare('SELECT id, name, description, is_auto_generated, created_at, updated_at FROM playlists WHERE user_id = ?').all(userId);
|
|
const playlistIds = playlists.map(p => p.id);
|
|
const playlistPlaceholders = playlistIds.map(() => '?').join(',') || "'__none__'";
|
|
const playlistItems = playlistIds.length ? db.prepare(`SELECT id, playlist_id, content_id, widget_id, sort_order, duration_sec FROM playlist_items WHERE playlist_id IN (${playlistPlaceholders})`).all(...playlistIds) : [];
|
|
|
|
const schedules = db.prepare('SELECT id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at FROM schedules WHERE user_id = ?').all(userId);
|
|
const videoWalls = db.prepare('SELECT * FROM video_walls WHERE user_id = ?').all(userId);
|
|
const wallIds = videoWalls.map(w => w.id);
|
|
const wallPlaceholders = wallIds.map(() => '?').join(',') || "'__none__'";
|
|
const wallDevices = wallIds.length ? db.prepare(`SELECT * FROM video_wall_devices WHERE wall_id IN (${wallPlaceholders})`).all(...wallIds) : [];
|
|
|
|
const kioskPages = db.prepare('SELECT id, name, config, created_at FROM kiosk_pages WHERE user_id = ?').all(userId);
|
|
const deviceGroups = db.prepare('SELECT id, name, color, created_at FROM device_groups WHERE user_id = ?').all(userId);
|
|
const groupIds = deviceGroups.map(g => g.id);
|
|
const groupPlaceholders = groupIds.map(() => '?').join(',') || "'__none__'";
|
|
const groupMembers = groupIds.length ? db.prepare(`SELECT * FROM device_group_members WHERE group_id IN (${groupPlaceholders})`).all(...groupIds) : [];
|
|
const alertConfigs = db.prepare('SELECT id, alert_type, enabled, config, created_at FROM alert_configs WHERE user_id = ?').all(userId);
|
|
const whiteLabel = workspaceId ? db.prepare('SELECT * FROM white_labels WHERE workspace_id = ?').get(workspaceId) : null;
|
|
|
|
const exportData = {
|
|
format: 'screentinker-export-v2',
|
|
exported_at: new Date().toISOString(),
|
|
user,
|
|
devices: devices.map(d => {
|
|
const dev = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(d.id);
|
|
return { ...d, playlist_id: dev?.playlist_id || null };
|
|
}),
|
|
content,
|
|
widgets: widgets.map(w => ({ ...w, config: JSON.parse(w.config || '{}') })),
|
|
layouts,
|
|
layout_zones: layoutZones,
|
|
playlists,
|
|
playlist_items: playlistItems,
|
|
schedules,
|
|
video_walls: videoWalls,
|
|
video_wall_devices: wallDevices,
|
|
kiosk_pages: kioskPages.map(k => ({ ...k, config: JSON.parse(k.config || '{}') })),
|
|
device_groups: deviceGroups,
|
|
device_group_members: groupMembers,
|
|
alert_configs: alertConfigs.map(a => ({ ...a, config: JSON.parse(a.config || '{}') })),
|
|
white_label: whiteLabel || null,
|
|
};
|
|
|
|
// If include_files requested, bundle as ZIP with content files
|
|
if (req.query.include_files === 'true') {
|
|
const archiver = require('archiver');
|
|
const dateStr = new Date().toISOString().split('T')[0];
|
|
res.setHeader('Content-Type', 'application/zip');
|
|
res.setHeader('Content-Disposition', `attachment; filename=screentinker-export-${dateStr}.zip`);
|
|
|
|
const archive = archiver('zip', { zlib: { level: 5 } });
|
|
archive.pipe(res);
|
|
|
|
// Collect file info and add files to archive
|
|
const filesToInclude = [];
|
|
for (const c of exportData.content) {
|
|
if (c.remote_url || !c.filename) continue;
|
|
const row = db.prepare('SELECT filepath, thumbnail_path FROM content WHERE id = ?').get(c.id);
|
|
if (row?.filepath) {
|
|
const filePath = path.join(config.contentDir, path.basename(row.filepath));
|
|
if (fs.existsSync(filePath)) {
|
|
c.original_filepath = path.basename(row.filepath);
|
|
archive.file(filePath, { name: `files/${c.id}/${c.original_filepath}` });
|
|
}
|
|
}
|
|
if (row?.thumbnail_path) {
|
|
const thumbPath = path.join(config.contentDir, path.basename(row.thumbnail_path));
|
|
if (fs.existsSync(thumbPath)) {
|
|
c.original_thumbnail = path.basename(row.thumbnail_path);
|
|
archive.file(thumbPath, { name: `files/${c.id}/${c.original_thumbnail}` });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add JSON manifest (after filepath fields are populated)
|
|
archive.append(JSON.stringify(exportData, null, 2), { name: 'export.json' });
|
|
archive.finalize();
|
|
return;
|
|
}
|
|
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Content-Disposition', `attachment; filename=screentinker-export-${new Date().toISOString().split('T')[0]}.json`);
|
|
res.json(exportData);
|
|
});
|
|
|
|
// User data import (JSON or ZIP with files)
|
|
const multer = require('multer');
|
|
const importUpload = multer({ dest: path.join(os.tmpdir(), 'screentinker-import'), limits: { fileSize: 2 * 1024 * 1024 * 1024 } }); // 2GB max
|
|
|
|
router.post('/import', importUpload.single('file'), async (req, res) => {
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'Token required' });
|
|
|
|
let userId;
|
|
let workspaceId;
|
|
try {
|
|
const session = resolveSessionUser(authHeader.split(' ')[1]);
|
|
// A break-glass identity has no users row: the lookup this replaced returned nothing
|
|
// for it, so the route 404'd. Preserve that.
|
|
if (session.viaRecovery) return res.status(404).json({ error: 'User not found' });
|
|
userId = session.user.id;
|
|
workspaceId = session.decoded.current_workspace_id || null;
|
|
if (!userId) return res.status(401).json({ error: 'Invalid token' });
|
|
} catch (err) {
|
|
if (err.code === 'user_not_found') return res.status(404).json({ error: 'User not found' });
|
|
return denySession(res, err);
|
|
}
|
|
|
|
// Phase 2.2b: imports stamp workspace_id on devices and content so the
|
|
// rows are visible to the workspace-filtered list endpoints. Fall back to
|
|
// the importer's first accessible workspace if the JWT didn't carry one.
|
|
if (!workspaceId) {
|
|
const w = db.prepare(`
|
|
SELECT w.id FROM workspaces w
|
|
JOIN workspace_members wm ON wm.workspace_id = w.id
|
|
WHERE wm.user_id = ? ORDER BY wm.joined_at ASC LIMIT 1
|
|
`).get(userId);
|
|
workspaceId = w?.id || null;
|
|
}
|
|
if (!workspaceId) return res.status(403).json({ error: 'No workspace context for import. Switch to a workspace first.' });
|
|
|
|
let data;
|
|
let extractedFiles = {}; // Map of old content ID -> { filepath, thumbnail }
|
|
|
|
if (req.file) {
|
|
// ZIP upload — extract export.json and files/
|
|
try {
|
|
const unzipper = require('unzipper');
|
|
const extractDir = path.join(os.tmpdir(), `screentinker-import-${Date.now()}`);
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
|
|
await new Promise((resolve, reject) => {
|
|
fs.createReadStream(req.file.path)
|
|
.pipe(unzipper.Extract({ path: extractDir }))
|
|
.on('close', resolve)
|
|
.on('error', reject);
|
|
});
|
|
|
|
// Read the JSON manifest
|
|
const jsonPath = path.join(extractDir, 'export.json');
|
|
if (!fs.existsSync(jsonPath)) {
|
|
fs.unlinkSync(req.file.path);
|
|
return res.status(400).json({ error: 'ZIP does not contain export.json' });
|
|
}
|
|
data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
|
|
|
// Map extracted files by content ID, with path traversal validation
|
|
const filesDir = path.join(extractDir, 'files');
|
|
const resolvedExtractDir = path.resolve(extractDir);
|
|
if (fs.existsSync(filesDir)) {
|
|
for (const contentDir of fs.readdirSync(filesDir)) {
|
|
const contentPath = path.resolve(filesDir, contentDir);
|
|
// Validate path is within extractDir to prevent directory traversal
|
|
if (!contentPath.startsWith(resolvedExtractDir)) continue;
|
|
if (!fs.statSync(contentPath).isDirectory()) continue;
|
|
const files = fs.readdirSync(contentPath);
|
|
extractedFiles[contentDir] = files.map(f => {
|
|
const filePath = path.resolve(contentPath, f);
|
|
// Validate each file path is within extractDir
|
|
if (!filePath.startsWith(resolvedExtractDir)) return null;
|
|
return { name: f, path: filePath };
|
|
}).filter(Boolean);
|
|
}
|
|
}
|
|
|
|
// Cleanup uploaded zip
|
|
fs.unlinkSync(req.file.path);
|
|
} catch (err) {
|
|
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch {}
|
|
return res.status(400).json({ error: 'Failed to extract ZIP: ' + err.message });
|
|
}
|
|
} else {
|
|
data = req.body;
|
|
}
|
|
if (!data || !data.format || !data.format.startsWith('screentinker-export')) {
|
|
return res.status(400).json({ error: 'Invalid export file. Must be a ScreenTinker export JSON.' });
|
|
}
|
|
|
|
const isV2 = data.format === 'screentinker-export-v2';
|
|
const uuid = require('uuid');
|
|
const stats = { devices: 0, content: 0, widgets: 0, layouts: 0, playlists: 0, schedules: 0, video_walls: 0, kiosk_pages: 0, device_groups: 0 };
|
|
|
|
// Map old IDs to new IDs
|
|
const idMap = { devices: {}, content: {}, widgets: {}, layouts: {}, zones: {}, playlists: {}, groups: {}, walls: {}, kiosk: {} };
|
|
|
|
const importDb = db.transaction(() => {
|
|
// Import devices (as offline, unlinked - they'll need re-pairing)
|
|
for (const d of (data.devices || [])) {
|
|
const newId = uuid.v4();
|
|
idMap.devices[d.id] = newId;
|
|
const pairingCode = sixDigitCode(); // CSPRNG (lib/numeric-code): this code claims a device
|
|
db.prepare(`INSERT INTO devices (id, user_id, workspace_id, name, pairing_code, status, screen_width, screen_height, created_at) VALUES (?, ?, ?, ?, ?, 'provisioning', ?, ?, ?)`).run(newId, userId, workspaceId, d.name, pairingCode, d.screen_width || null, d.screen_height || null, d.created_at || Math.floor(Date.now() / 1000));
|
|
stats.devices++;
|
|
}
|
|
|
|
// Import content metadata + files from ZIP if available
|
|
for (const c of (data.content || [])) {
|
|
const newId = uuid.v4();
|
|
idMap.content[c.id] = newId;
|
|
|
|
let newFilepath = '';
|
|
let newThumbnail = null;
|
|
|
|
// Copy files from ZIP extract if available
|
|
const files = extractedFiles[c.id];
|
|
if (files && files.length > 0) {
|
|
for (const f of files) {
|
|
// The archive chooses this name, so the extension is caller-controlled — the
|
|
// same defect the upload path fixes. Constrain it to the media allowlist; an
|
|
// entry with any other extension is skipped rather than written to the content
|
|
// dir under a name the browser would treat as an active document.
|
|
const ext = path.extname(f.name).toLowerCase();
|
|
if (!INLINE_SAFE_EXTS.has(ext)) continue;
|
|
const destName = `${newId}${ext}`;
|
|
const destPath = path.join(config.contentDir, destName);
|
|
try {
|
|
fs.copyFileSync(f.path, destPath);
|
|
// Match original filepath vs thumbnail
|
|
if (c.original_filepath && f.name === c.original_filepath) {
|
|
newFilepath = destName;
|
|
} else if (c.original_thumbnail && f.name === c.original_thumbnail) {
|
|
newThumbnail = destName;
|
|
} else if (!newFilepath) {
|
|
// Fallback: first non-thumbnail file is the content
|
|
newFilepath = destName;
|
|
}
|
|
stats.files_restored = (stats.files_restored || 0) + 1;
|
|
} catch (err) {
|
|
// File copy failed, content will need re-upload
|
|
}
|
|
}
|
|
}
|
|
|
|
db.prepare(`INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, remote_url, thumbnail_path, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, c.filename, newFilepath, c.mime_type, c.file_size || 0, c.duration_sec || null, c.remote_url || null, newThumbnail, c.width || null, c.height || null, c.created_at || Math.floor(Date.now() / 1000));
|
|
stats.content++;
|
|
}
|
|
|
|
// Import widgets
|
|
for (const w of (data.widgets || [])) {
|
|
const newId = uuid.v4();
|
|
idMap.widgets[w.id] = newId;
|
|
const config = typeof w.config === 'string' ? w.config : JSON.stringify(w.config || {});
|
|
db.prepare(`INSERT INTO widgets (id, user_id, workspace_id, widget_type, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, w.widget_type, w.name, config, w.created_at || Math.floor(Date.now() / 1000));
|
|
stats.widgets++;
|
|
}
|
|
|
|
// Import layouts and zones
|
|
for (const l of (data.layouts || [])) {
|
|
const newId = uuid.v4();
|
|
idMap.layouts[l.id] = newId;
|
|
db.prepare(`INSERT INTO layouts (id, user_id, workspace_id, name, width, height, is_template, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)`).run(newId, userId, workspaceId, l.name, l.width || 1920, l.height || 1080, l.created_at || Math.floor(Date.now() / 1000));
|
|
stats.layouts++;
|
|
}
|
|
for (const z of (data.layout_zones || [])) {
|
|
const newLayoutId = idMap.layouts[z.layout_id];
|
|
if (!newLayoutId) continue;
|
|
const newId = uuid.v4();
|
|
idMap.zones[z.id] = newId;
|
|
db.prepare(`INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, newLayoutId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent, z.z_index || 0, z.zone_type || 'content', z.fit_mode || 'cover', z.background_color || '#000000', z.sort_order || 0);
|
|
}
|
|
|
|
// Import playlists (v2) or convert assignments to playlists (v1)
|
|
if (isV2) {
|
|
for (const p of (data.playlists || [])) {
|
|
const newId = uuid.v4();
|
|
idMap.playlists[p.id] = newId;
|
|
db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, description, is_auto_generated, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(newId, userId, workspaceId, p.name, p.description || '', p.is_auto_generated || 0, p.created_at || Math.floor(Date.now() / 1000), p.updated_at || Math.floor(Date.now() / 1000));
|
|
stats.playlists++;
|
|
}
|
|
for (const pi of (data.playlist_items || [])) {
|
|
const playlistId = idMap.playlists[pi.playlist_id];
|
|
if (!playlistId) continue;
|
|
const contentId = pi.content_id ? idMap.content[pi.content_id] : null;
|
|
const widgetId = pi.widget_id ? idMap.widgets[pi.widget_id] : null;
|
|
if (!contentId && !widgetId) continue;
|
|
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)').run(playlistId, contentId, widgetId, pi.sort_order || 0, pi.duration_sec || 10);
|
|
}
|
|
// Set device playlist_id references
|
|
for (const d of (data.devices || [])) {
|
|
if (d.playlist_id && idMap.playlists[d.playlist_id]) {
|
|
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(idMap.playlists[d.playlist_id], idMap.devices[d.id]);
|
|
}
|
|
}
|
|
} else {
|
|
// v1: defer playlist creation to after the transaction so we can async-probe videos
|
|
// Just stash the mapping for now; actual insertion happens below after importDb()
|
|
}
|
|
|
|
// Import schedules
|
|
for (const s of (data.schedules || [])) {
|
|
const devId = s.device_id ? (idMap.devices[s.device_id] || null) : null;
|
|
const grpId = s.group_id ? (idMap.groups[s.group_id] || null) : null;
|
|
// Must have either a mapped device or group target
|
|
if (!devId && !grpId) continue;
|
|
const newId = uuid.v4();
|
|
const playlistId = s.playlist_id ? (idMap.playlists[s.playlist_id] || null) : null;
|
|
db.prepare(`INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, grpId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, playlistId, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#3B82F6', s.created_at || Math.floor(Date.now() / 1000));
|
|
stats.schedules++;
|
|
}
|
|
|
|
// Import video walls
|
|
for (const w of (data.video_walls || [])) {
|
|
const newId = uuid.v4();
|
|
idMap.walls[w.id] = newId;
|
|
db.prepare(`INSERT INTO video_walls (id, user_id, name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, screen_w_mm, screen_h_mm, sync_mode, content_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, w.name, w.grid_cols, w.grid_rows, w.bezel_h_mm || 0, w.bezel_v_mm || 0, w.screen_w_mm || 400, w.screen_h_mm || 225, w.sync_mode || 'leader', w.content_id ? (idMap.content[w.content_id] || null) : null, w.created_at || Math.floor(Date.now() / 1000));
|
|
stats.video_walls++;
|
|
}
|
|
for (const wd of (data.video_wall_devices || [])) {
|
|
const wallId = idMap.walls[wd.wall_id];
|
|
const devId = idMap.devices[wd.device_id];
|
|
if (!wallId || !devId) continue;
|
|
db.prepare(`INSERT INTO video_wall_devices (wall_id, device_id, grid_col, grid_row, rotation) VALUES (?, ?, ?, ?, ?)`).run(wallId, devId, wd.grid_col, wd.grid_row, wd.rotation || 0);
|
|
}
|
|
|
|
// Import kiosk pages
|
|
for (const k of (data.kiosk_pages || [])) {
|
|
const newId = uuid.v4();
|
|
idMap.kiosk[k.id] = newId;
|
|
const config = typeof k.config === 'string' ? k.config : JSON.stringify(k.config || {});
|
|
db.prepare(`INSERT INTO kiosk_pages (id, user_id, workspace_id, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, k.name, config, k.created_at || Math.floor(Date.now() / 1000));
|
|
stats.kiosk_pages++;
|
|
}
|
|
|
|
// Import device groups
|
|
for (const g of (data.device_groups || [])) {
|
|
const newId = uuid.v4();
|
|
idMap.groups[g.id] = newId;
|
|
db.prepare(`INSERT INTO device_groups (id, user_id, workspace_id, name, color, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, g.name, g.color || '#3B82F6', g.created_at || Math.floor(Date.now() / 1000));
|
|
stats.device_groups++;
|
|
}
|
|
for (const gm of (data.device_group_members || [])) {
|
|
const groupId = idMap.groups[gm.group_id];
|
|
const devId = idMap.devices[gm.device_id];
|
|
if (!groupId || !devId) continue;
|
|
db.prepare(`INSERT OR IGNORE INTO device_group_members (group_id, device_id) VALUES (?, ?)`).run(groupId, devId);
|
|
}
|
|
|
|
// Import alert configs
|
|
for (const a of (data.alert_configs || [])) {
|
|
const newId = uuid.v4();
|
|
const config = typeof a.config === 'string' ? a.config : JSON.stringify(a.config || {});
|
|
db.prepare(`INSERT INTO alert_configs (id, user_id, alert_type, enabled, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, a.alert_type, a.enabled !== undefined ? a.enabled : 1, config, a.created_at || Math.floor(Date.now() / 1000));
|
|
}
|
|
|
|
// Import white label - UPSERT into the importer's current workspace.
|
|
if (data.white_label && workspaceId) {
|
|
const wl = data.white_label;
|
|
const existing = db.prepare('SELECT id FROM white_labels WHERE workspace_id = ?').get(workspaceId);
|
|
if (existing) {
|
|
db.prepare(`UPDATE white_labels SET brand_name=?, logo_url=?, favicon_url=?, primary_color=?, bg_color=?, custom_domain=?, custom_css=?, hide_branding=?, updated_at=strftime('%s','now') WHERE workspace_id=?`).run(wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0, workspaceId);
|
|
} else {
|
|
db.prepare(`INSERT INTO white_labels (id, user_id, workspace_id, brand_name, logo_url, favicon_url, primary_color, bg_color, custom_domain, custom_css, hide_branding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid.v4(), userId, workspaceId, wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0);
|
|
}
|
|
}
|
|
});
|
|
|
|
try {
|
|
importDb();
|
|
|
|
// v1: convert assignments to per-device playlists AFTER transaction (content files now on disk)
|
|
if (!isV2 && data.assignments?.length) {
|
|
const { execFile } = require('child_process');
|
|
|
|
async function probeImportedContent(newContentId) {
|
|
const c = db.prepare('SELECT id, mime_type, filepath, duration_sec FROM content WHERE id = ?').get(newContentId);
|
|
if (!c || !c.mime_type?.startsWith('video/') || !c.filepath) return c?.duration_sec ? Math.ceil(c.duration_sec) : null;
|
|
if (c.duration_sec) return Math.ceil(c.duration_sec);
|
|
try {
|
|
const fullPath = path.join(config.contentDir, c.filepath);
|
|
const stdout = await new Promise((resolve, reject) => {
|
|
execFile('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', fullPath],
|
|
{ timeout: 15000 }, (err, out) => err ? reject(err) : resolve(out));
|
|
});
|
|
const info = JSON.parse(stdout);
|
|
if (info.format?.duration) {
|
|
const dur = parseFloat(info.format.duration);
|
|
db.prepare('UPDATE content SET duration_sec = ? WHERE id = ?').run(dur, c.id);
|
|
return Math.ceil(dur);
|
|
}
|
|
} catch (e) { /* probe failed, fall back to default */ }
|
|
return null;
|
|
}
|
|
|
|
const assignmentsByDevice = {};
|
|
for (const a of (data.assignments || [])) {
|
|
if (!assignmentsByDevice[a.device_id]) assignmentsByDevice[a.device_id] = [];
|
|
assignmentsByDevice[a.device_id].push(a);
|
|
}
|
|
|
|
for (const [oldDevId, assignments] of Object.entries(assignmentsByDevice)) {
|
|
const devId = idMap.devices[oldDevId];
|
|
if (!devId) continue;
|
|
const devName = (data.devices || []).find(d => d.id === oldDevId)?.name || 'Display';
|
|
const playlistId = uuid.v4();
|
|
|
|
const items = [];
|
|
for (const a of assignments) {
|
|
const contentId = a.content_id ? idMap.content[a.content_id] : null;
|
|
const widgetId = a.widget_id ? idMap.widgets[a.widget_id] : null;
|
|
if (!contentId && !widgetId) continue;
|
|
let duration = a.duration_sec || 10;
|
|
if (contentId) {
|
|
const probed = await probeImportedContent(contentId);
|
|
if (probed) duration = probed;
|
|
}
|
|
items.push({ contentId, widgetId, sort_order: a.sort_order || 0, duration });
|
|
}
|
|
|
|
db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, description, is_auto_generated) VALUES (?, ?, ?, ?, ?, 1)')
|
|
.run(playlistId, userId, workspaceId, `${devName} (imported)`, 'Converted from v1 assignments');
|
|
for (const item of items) {
|
|
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)')
|
|
.run(playlistId, item.contentId, item.widgetId, item.sort_order, item.duration);
|
|
}
|
|
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, devId);
|
|
stats.playlists++;
|
|
}
|
|
}
|
|
|
|
// Collect pairing codes for imported devices
|
|
const devicePairings = (data.devices || []).map(d => {
|
|
const newId = idMap.devices[d.id];
|
|
const dev = db.prepare('SELECT name, pairing_code FROM devices WHERE id = ?').get(newId);
|
|
return dev ? { name: dev.name, pairing_code: dev.pairing_code } : null;
|
|
}).filter(Boolean);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Import complete',
|
|
stats,
|
|
device_pairings: devicePairings,
|
|
notes: [
|
|
'Devices need to be re-paired. Use the pairing codes below or re-pair from the Displays page.',
|
|
stats.files_restored ? `${stats.files_restored} content files restored from export.` : 'File-based content needs to be re-uploaded. Remote URL content works immediately.',
|
|
'All IDs have been regenerated to avoid conflicts.',
|
|
]
|
|
});
|
|
} catch (err) {
|
|
console.error('Import error:', err);
|
|
res.status(500).json({ error: 'Import failed: ' + err.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|