mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-18 16:13:51 -06:00
* Make a BrightSign say what it is running, and what it is plugged into
A panel on a wall could not answer three questions an operator asks first:
which version am I, which page am I running, and which screen is that. All
three had answers already travelling over the socket; nothing was reading them.
VERSION. device_info.app_version was the literal '1.1.0-web' for every web
player, BrightSign included — the same string as PLAYER_VERSION, which already
travels separately as client_version. So the column carried no information at
all: a panel provisioned this morning and one running a year-old host reported
identically. app_version is now the ON-DEVICE host package, the artifact OTA
replaces and the only one here that can be stale, and PLAYER_VERSION is stamped
at serve time from VERSION rather than being a constant nobody bumped for the
whole 1.x line. No '-web' suffix: client_version is only compared for equality
today, but X.Y.Z-web is a semver PRERELEASE that sorts BELOW X.Y.Z, and this
project has been bitten by exactly that before.
The host version arrives asynchronously and can land after the page registers,
so register sends what it has and the heartbeat corrects the record — which also
catches the version changing under a live page, which is what a self-update is.
THE CARD SHOWED FOR NOBODY. The Info tab's version card sat inside the block
gated on android_version && !startsWith('Web/'). A BrightSign registers as
"Web/<ua>", so the panel that most needed a version never displayed one.
THE PAD THAT COULD NOT BE CLICKED. System View was gated on tier === 2. tier is
an Android device-owner concept, NOT NULL DEFAULT 0, written only by the APK —
so a BrightSign or Tizen panel sat at 0 forever and rendered HOME, BACK, POWER,
the D-pad and OK permanently pointer-events:none, for keys those players
genuinely handle. Greying an Android gate over a working control is the "button
that cannot work" the capability system exists to prevent, inverted. Only
Recents (KEYCODE_APP_SWITCH) and Settings are truly Android-only; those are now
the only things hidden.
THE PACKAGE POINTED AT THE WRONG SERVER. autorun.zip carried the committed
default, so a player self-updating from alpha or a self-hosted box was handed a
config pointing at screentinker.com — which surfaces as a pairing bug, miles
from the packaging code that caused it. It is now stamped with the URL it was
fetched from. The bytes therefore vary per origin, so the cache is keyed by
origin and BOTH routes derive it identically: the manifest checksum and the
served bytes must come from one buffer or every player downloads, fails
verification and retries forever.
EDID. getEdidIdentity() answers seven questions and cannot answer any others —
manufacturer, EDID version, physical size, gamma and the mode lists exist only
in the raw block, which getEdid() returns as 2048 bytes. The player ships those
on the register (identity, not a reading: it changes when someone swaps the
screen) and the SERVER parses them. That split is the point: a new field becomes
a server deploy instead of a bridge update behind a 4h CDN plus an OTA for the
host. Verified against real hardware — an XT245 with a CX101 decodes to RTK /
0x1010 / serial 1 / 2020w26 / 22x13cm, preferred 1920x1200@62, matching the
player's own DWS field for field. The odd-looking 62 is right: 168.5MHz over
2200 x 1245 is 61.5Hz, and rounding it to a nicer 60 would contradict the panel.
Also corrects two comments that had outgrown their reasoning: the BrightSign
capability baseline still explained its exclusions with "a canvas cannot read
the video plane", which native capture made obsolete, and player-parity.md
claimed the bridge is "always current" when a zone-wide Cloudflare Browser Cache
TTL had been rewriting its no-cache to max-age=14400 for months.
Every new guard is mutation-tested — the fix was reverted in the source and each
test confirmed to fail. 1676 -> 1714 tests, all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
* Run the ScreenTinker server on the player it serves
A BrightSign XT245 now downloads, installs and runs the server itself, with
the display showing what it is doing until it is up.
WHY IT NEEDED A NEW SHAPE
BrightSignOS cannot open a large autorun.zip. The 73MB build failed at boot
with "ZipArchive error at line 91", and the OS renamed it autorun.zip_invalid
- which is how a device that had already unpacked once came back up with no
autorun at all. The identical package cut to 32KB and five files boots fine;
paths (182 chars) and depth (8) are unremarkable, so the limit is in the
boot-time reader, not the archive. BrightSign's own notes acknowledge package
size as a problem and point at webpack; that route needs the dynamic requires
in scripts/ removed first, so instead autorun.zip carries only what starts the
process and the payload arrives over HTTP into a Node that has no such limit.
The payload can also be updated without re-provisioning the device.
WHY roNodeJs AND NOT THE WIDGET
The first version ran the server inside an roHtmlWidget with nodejs_enabled.
That is a Node context inside an Electron renderer, and it is not Node. Four
separate boot failures came out of it, each invisible to a local test because
a local test runs on real Node:
- shebangs are not stripped, so any `#!/usr/bin/env node` file dies with
"Failed to construct 'ContextifyScript': Invalid or unexpected token".
Note it names no token - "#" is not one. An ESM file compiled as CJS says
"Unexpected token 'export'" instead, which is how the two are told apart.
- require() of an ESM-only package is unsupported, which plain Node 24
handles. uuid 14 is ESM-only and 21 files import it.
- setInterval is the DOM's and returns a NUMBER, so setInterval(...).unref()
throws. Two call sites were unguarded; sixteen more were written
defensively and had been silently not unreffing.
- worker_threads cannot create a thread at all.
BrightSign's dev-cookbook is explicit: roNodeJs "for long running processes
like ... running a web server", roHtmlWidget "for browser-based apps". Their
cra-template examples do exactly this - server in roNodeJs, widget pointed at
localhost. It also fixes the lifecycle problem that was the original argument
against a server on this hardware: in a widget the server dies with the page,
taking an open SQLite WAL with it.
The shims for the first three are kept in the packager for now rather than
removed in the same change that moves the container, so that if something
breaks it is the move and not four simultaneous removals.
CHANGES THAT ARE NOT BRIGHTSIGN-SPECIFIC
db/database.js, routes/status.js fs.copyFileSync does not merely copy
bytes: it fchmods the destination to match the source. exFAT has no
permission bits, so the pre-migration snapshot failed with EPERM and the
failure path called process.exit(1) - which inside a widget also killed
the page, leaving a black screen and no diagnostic. The guard was right;
the copy was wrong. lib/fsutil.js copies without touching mode.
db/wal-checkpointer.js the module already degraded correctly when its
worker died or could not be respawned, but the FIRST spawn was not
wrapped, so a host that cannot make threads lost the whole server rather
than falling back to inline autocheckpoint.
db/sqlite-compat.js a better-sqlite3 facade over node:sqlite. With it the
bundle contains no native code at all, which is what lets an x86_64
laptop build a package for an aarch64 player. 1719/1719 tests pass on
Node 24 through this shim.
The packager refuses to build if a source file is untracked (git ls-files
decides what ships, and lib/fsutil.js reached a player without shipping
alongside the code that required it), if any .node binary is present, if a
shebang survives, or if a database, upload, cert or .env is staged - the first
build of this package swept up a real 33MB database and 105MB of uploads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
594 lines
31 KiB
JavaScript
594 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 { copyFileBytes } = require('../lib/fsutil'); // exFAT-safe; see lib/fsutil.js
|
|
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 {
|
|
copyFileBytes(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;
|