mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
528 lines
27 KiB
JavaScript
528 lines
27 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 { PLATFORM_ROLES } = require('../middleware/auth');
|
|
|
|
// Public status page
|
|
router.get('/', (req, res) => {
|
|
const totalDevices = db.prepare('SELECT COUNT(*) as count FROM devices').get().count;
|
|
const onlineDevices = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status = 'online'").get().count;
|
|
const totalContent = db.prepare('SELECT COUNT(*) as count FROM content').get().count;
|
|
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
|
const uptime = process.uptime();
|
|
|
|
// Public status - minimal info only (no user counts, no server internals)
|
|
let version = '1.5.1';
|
|
try { version = require('fs').readFileSync(require('path').join(__dirname, '..', '..', 'VERSION'), 'utf8').trim(); } catch {}
|
|
|
|
res.json({
|
|
status: 'ok',
|
|
version,
|
|
uptime_human: formatUptime(uptime),
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
});
|
|
|
|
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`;
|
|
}
|
|
|
|
// 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' });
|
|
|
|
try {
|
|
const jwt = require('jsonwebtoken');
|
|
const config = require('../config');
|
|
const decoded = jwt.verify(token, config.jwtSecret);
|
|
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(decoded.id);
|
|
if (!user || !PLATFORM_ROLES.includes(user.role)) return res.status(403).json({ error: 'Platform admin only' });
|
|
} catch {
|
|
return res.status(401).json({ error: 'Invalid token' });
|
|
}
|
|
|
|
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;
|
|
try {
|
|
const jwt = require('jsonwebtoken');
|
|
const config = require('../config');
|
|
const decoded = jwt.verify(token, config.jwtSecret);
|
|
userId = decoded.id;
|
|
if (!userId) return res.status(401).json({ error: 'Invalid token' });
|
|
} catch {
|
|
return res.status(401).json({ error: 'Invalid token' });
|
|
}
|
|
|
|
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' });
|
|
|
|
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 = db.prepare('SELECT * FROM white_labels WHERE user_id = ?').get(userId);
|
|
|
|
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 jwt = require('jsonwebtoken');
|
|
const jwtConfig = require('../config');
|
|
const decoded = jwt.verify(authHeader.split(' ')[1], jwtConfig.jwtSecret);
|
|
userId = decoded.id;
|
|
workspaceId = decoded.current_workspace_id || null;
|
|
if (!userId) return res.status(401).json({ error: 'Invalid token' });
|
|
} catch {
|
|
return res.status(401).json({ error: 'Invalid token' });
|
|
}
|
|
|
|
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(userId);
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
|
|
// 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 = String(Math.floor(100000 + Math.random() * 900000));
|
|
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) {
|
|
const ext = path.extname(f.name);
|
|
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, widget_type, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, 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, name, width, height, is_template, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)`).run(newId, userId, 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, name, description, is_auto_generated, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(newId, userId, 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, name, config, created_at) VALUES (?, ?, ?, ?, ?)`).run(newId, userId, 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, name, color, created_at) VALUES (?, ?, ?, ?, ?)`).run(newId, userId, 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
|
|
if (data.white_label) {
|
|
const wl = data.white_label;
|
|
const existing = db.prepare('SELECT id FROM white_labels WHERE user_id = ?').get(userId);
|
|
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 user_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, userId);
|
|
} else {
|
|
db.prepare(`INSERT INTO white_labels (id, user_id, brand_name, logo_url, favicon_url, primary_color, bg_color, custom_domain, custom_css, hide_branding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid.v4(), userId, 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, name, description, is_auto_generated) VALUES (?, ?, ?, ?, 1)')
|
|
.run(playlistId, userId, `${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;
|