mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Found by driving the real server and a real browser, not by reading. Each fix has a
test that fails without it.
1. A missing upload answered 200 with the DASHBOARD. express.static falls through on a
miss and the SPA catch-all caught it, so GET /uploads/content/<gone>.mp4 returned
15KB of index.html as text/html — under the `immutable, max-age=30d` header the mount
sets before it knows the file exists. Every player downloader treats 200 as success,
so a panel stores the HTML page AS the video and caches it for a month, rendering a
black frame with nothing in any log. Reachable exactly when it hurts: a content
replace writes a new random filename and unlinks the old one. The mount now
terminates a miss with a 404 and drops the cache header.
2. Four dashboard->device socket handlers had no capability gate. dashboard:device-command
has always refused a command the panel cannot honour, and the comment above it is right
about why ("hiding the button is not enforcement — this socket is reachable directly").
Every word applied to the four handlers immediately above it, which had none: a display
declaring [] still received screenshot-request, remote-touch, remote-key and
remote-start. Measured, not inferred. They now refuse on remote.screenshot /
remote.input / remote.stream and name the capability in the ack; remote-stop stays
ungated for the same reason set_debug does. The undeclared fleet is unaffected — an
absent declaration still resolves to its platform baseline and keeps everything.
The wall panel list (#235) made this visible: it offered a Screenshot button for every
panel, including a BrightSign, which has no screenshot capability at all, and popped a
toast promising an image that was never coming. GET /api/devices now ships the RESOLVED
capability array rather than the raw column ('[]' as a STRING, which Array.isArray reads
as "pre-capability server, show everything" — wrong in the one case that matters), so
the wall list and the fleet cards can hide what a panel cannot do. The remote pad's
Scrn Off / Scrn On were gated on remote.input while the Info tab gated the same two
commands on display.power; both now agree.
3. A register with no `platform` ERASED the stored one. captureIdentity coerces a missing
field to the literal 'unknown' and persistIdentity wrote it straight over. That column
is load-bearing: platformFamily() reads it, so one reconnect from an older build turned
a Tizen panel into a browser tab and handed it a volume slider the .wgt has no handler
for — the exact control BASELINE.tizen exists to hide — while a BrightSign lost screen
power and reboot and gained screenshots it cannot take. platform and client_type are
now preserved (physical facts); client_version and contract_version still decay, because
there "we no longer know" is the truthful answer. client_type 'wgt' is also read as a
second signal for a Tizen TV.
4. PUT /api/content/:id/replace carried its own shorter copy of the ingest logic. Replacing
a video left duration_sec at the OLD clip's length and nulled width/height, so #237's
brand-new "default an item to the clip's own length" then handed out the wrong number
for every later add — 32s scheduled for a 5s video is 27s of frozen frame. Replacing an
image measured it with raw sharp metadata and thumbnailed without .rotate(),
re-introducing the EXIF-orientation bug #172 had just fixed at ingest. Both paths now
share lib/content-ingest.deriveMediaMetadata.
Verified working and NOT changed: all six item-duration insert paths (a 31.7s clip stores
32 everywhere, an explicit value always wins, and no path can store a 0); the content
revision bump + filepath refresh reaching a real device socket; a landscape wall producing
byte-identical geometry to the pre-#236 expression; a portrait wall reaching the player as
side-by-side halves; cross-workspace isolation across 29 probes.
Full suite green (1319).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
631 lines
31 KiB
JavaScript
631 lines
31 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { db } = require('../db/database');
|
|
const upload = require('../middleware/upload');
|
|
const config = require('../config');
|
|
const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription');
|
|
const { sanitizeString } = require('../middleware/sanitize');
|
|
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
|
|
// Phase 2.2b: workspace-aware access. Mirrors the pattern from devices.js.
|
|
const { accessContext } = require('../lib/tenancy');
|
|
// #73: the upload ingest (processing + insert) is now shared with the agency router.
|
|
const { ingestUploadedFile, deriveMediaMetadata } = require('../lib/content-ingest');
|
|
const { finalizeUpload, INLINE_SAFE_EXTS } = require('../lib/upload-sniff');
|
|
|
|
// Multer captures file.originalname directly from the multipart filename header,
|
|
// bypassing sanitizeBody. Apply the same HTML-escape here so a filename like
|
|
// `"><img src=x onerror=alert(1)>.jpg` is stored as `"><img...` and
|
|
// renders as text in every UI sink. Umlauts, spaces, dots, and other unicode are
|
|
// preserved - sanitizeString only touches `& < > " '`.
|
|
//
|
|
// .normalize('NFC') first: macOS clients send NFD-decomposed filenames (an
|
|
// umlaut like "u" + combining diaeresis U+0308 instead of the precomposed
|
|
// "u-umlaut" U+00FC). Linux + most renderers expect NFC; without this, names
|
|
// like "Begrussungsscreens.jpg" arrive with the combining char floating and
|
|
// display as mojibake. Single-point fix - every user-facing filename storage
|
|
// site (POST /, POST /remote, POST /embed, PUT /:id rename) flows through
|
|
// safeFilename, so normalizing here covers all paths.
|
|
function safeFilename(name) {
|
|
return sanitizeString((name || '').normalize('NFC'));
|
|
}
|
|
|
|
// SSRF gate for remote_url. Returns null if valid, else { status, error }.
|
|
// Used by both POST /remote and PUT /:id so a user can't bypass the check by
|
|
// uploading a benign URL and then PUT-updating it to file:///etc/passwd.
|
|
function validateRemoteUrl(url) {
|
|
let parsed;
|
|
try { parsed = new URL(url); }
|
|
catch { return { status: 400, error: 'Invalid URL format' }; }
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
return { status: 400, error: 'URL must use http or https' };
|
|
}
|
|
const hostname = parsed.hostname.toLowerCase();
|
|
const isPrivate = hostname === 'localhost' || hostname === '0.0.0.0' ||
|
|
hostname.startsWith('127.') || hostname.startsWith('10.') ||
|
|
hostname.startsWith('192.168.') || hostname.startsWith('169.254.') ||
|
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
|
hostname.startsWith('fc') || hostname.startsWith('fd') || hostname === '::1' ||
|
|
hostname.endsWith('.local') || hostname.endsWith('.internal');
|
|
if (isPrivate) return { status: 400, error: 'Internal URLs are not allowed' };
|
|
return null;
|
|
}
|
|
|
|
// List content in the caller's current workspace, plus any platform-template
|
|
// rows (workspace_id IS NULL) that are shared with all workspaces.
|
|
// Phase 2.2b: workspace-scoped. Cross-workspace visibility comes from
|
|
// switch-workspace, not a special list filter.
|
|
// folder_id filter: omit for everything; "root" or "" for root-level only; <uuid> for that folder.
|
|
router.get('/', (req, res) => {
|
|
if (!req.workspaceId) return res.json([]);
|
|
const folder = req.query.folder;
|
|
const folderId = req.query.folder_id;
|
|
let sql = 'SELECT * FROM content WHERE (workspace_id = ? OR workspace_id IS NULL)';
|
|
const params = [req.workspaceId];
|
|
// #157: by default hide expired/deactivated content (the "live" set). ?include_expired=1
|
|
// returns everything so the library's "Show expired" view can surface + restore them.
|
|
if (req.query.include_expired !== '1' && req.query.include_expired !== 'true') {
|
|
sql += " AND is_active = 1 AND (expires_at IS NULL OR expires_at > strftime('%s','now'))";
|
|
}
|
|
if (folder) { sql += ' AND folder = ?'; params.push(folder); }
|
|
// #214: a text search (?q=) spans the whole workspace, not just the open folder —
|
|
// "searching for a logo on page 1 shouldn't miss logos in another folder". When q is
|
|
// absent we keep the folder-scoped browse behaviour.
|
|
const q = (req.query.q || '').trim();
|
|
if (!q && folderId !== undefined) {
|
|
if (folderId === 'root' || folderId === '') {
|
|
sql += ' AND folder_id IS NULL';
|
|
} else {
|
|
sql += ' AND folder_id = ?';
|
|
params.push(folderId);
|
|
}
|
|
}
|
|
if (q) {
|
|
// Leading-wildcard LIKE (no index) — fine for the library's scale. Escape the LIKE
|
|
// metacharacters so a filename with % or _ is matched literally.
|
|
const esc = q.replace(/[\\%_]/g, (m) => '\\' + m);
|
|
sql += " AND filename LIKE ? ESCAPE '\\'";
|
|
params.push('%' + esc + '%');
|
|
}
|
|
// #214: type filter. youtube (video/youtube) and web (any other remote_url) are split
|
|
// out from plain uploaded video/image so the UI's four buckets map cleanly.
|
|
switch (req.query.type) {
|
|
case 'image': sql += " AND mime_type LIKE 'image/%'"; break;
|
|
case 'video': sql += " AND mime_type LIKE 'video/%' AND mime_type != 'video/youtube'"; break;
|
|
case 'youtube': sql += " AND mime_type = 'video/youtube'"; break;
|
|
case 'web': sql += " AND remote_url IS NOT NULL AND mime_type != 'video/youtube'"; break;
|
|
// default / 'all' / unknown: no type constraint
|
|
}
|
|
// #214: whitelisted sort (never interpolate user input into ORDER BY). Default keeps the
|
|
// legacy newest-first ordering.
|
|
const SORTS = {
|
|
date_desc: 'created_at DESC',
|
|
date_asc: 'created_at ASC',
|
|
name: 'filename COLLATE NOCASE ASC',
|
|
size: 'file_size DESC',
|
|
};
|
|
sql += ' ORDER BY ' + (SORTS[req.query.sort] || SORTS.date_desc) + ' LIMIT ? OFFSET ?';
|
|
params.push(Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0);
|
|
const content = db.prepare(sql).all(...params);
|
|
res.json(content);
|
|
});
|
|
|
|
// Get folders list for the caller's current workspace.
|
|
router.get('/folders', (req, res) => {
|
|
if (!req.workspaceId) return res.json([]);
|
|
const folders = db.prepare(
|
|
'SELECT folder, COUNT(*) as count FROM content WHERE folder IS NOT NULL AND (workspace_id = ? OR workspace_id IS NULL) GROUP BY folder ORDER BY folder'
|
|
).all(req.workspaceId);
|
|
res.json(folders);
|
|
});
|
|
|
|
// Upload content
|
|
// #212: multi-file upload. Accept the new `files` field (up to 20) and keep the legacy
|
|
// single `file` field so older clients / API callers / the replace flow are unaffected.
|
|
const uploadContentFiles = upload.fields([
|
|
{ name: 'files', maxCount: 20 },
|
|
{ name: 'file', maxCount: 1 },
|
|
]);
|
|
router.post('/', checkStorageLimit, uploadContentFiles, async (req, res) => {
|
|
try {
|
|
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before uploading.' });
|
|
const files = [...((req.files && req.files.files) || []), ...((req.files && req.files.file) || [])];
|
|
if (files.length === 0) return res.status(400).json({ error: 'No file uploaded' });
|
|
|
|
// #73: shared ingest - identical processing + insert for dashboard and agency uploads.
|
|
const folderId = req.body.folder_id || null;
|
|
const results = [];
|
|
for (const file of files) {
|
|
results.push(await ingestUploadedFile({ file, userId: req.user.id, workspaceId: req.workspaceId, folderId }));
|
|
}
|
|
// Backward-compatible shape: a single upload still returns the content object (what
|
|
// every existing caller reads); a multi-file upload returns the array of them.
|
|
res.status(201).json(results.length === 1 ? results[0] : results);
|
|
} catch (err) {
|
|
if (err && err.name === 'UnsupportedUploadError') return res.status(400).json({ error: err.message });
|
|
console.error('Upload error:', err);
|
|
res.status(500).json({ error: 'Upload failed' });
|
|
}
|
|
});
|
|
|
|
// Add remote URL content
|
|
router.post('/remote', checkRemoteUrl, (req, res) => {
|
|
try {
|
|
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before adding remote content.' });
|
|
const { url, name, mime_type } = req.body;
|
|
if (!url) return res.status(400).json({ error: 'url is required' });
|
|
const urlErr = validateRemoteUrl(url);
|
|
if (urlErr) return res.status(urlErr.status).json({ error: urlErr.error });
|
|
|
|
const id = uuidv4();
|
|
const filename = name || url.split('/').pop()?.split('?')[0] || 'remote_content';
|
|
const mimeType = mime_type || (url.match(/\.(mp4|webm|mkv|avi|mov)/i) ? 'video/mp4' : 'image/jpeg');
|
|
|
|
db.prepare(`
|
|
INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, remote_url)
|
|
VALUES (?, ?, ?, ?, '', ?, 0, ?)
|
|
`).run(id, req.user.id, req.workspaceId, safeFilename(filename), mimeType, url);
|
|
|
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
|
res.status(201).json(content);
|
|
} catch (err) {
|
|
console.error('Remote URL add error:', err);
|
|
res.status(500).json({ error: 'Failed to add remote URL' });
|
|
}
|
|
});
|
|
|
|
// Add YouTube content (available to all plans - no storage used)
|
|
router.post('/youtube', async (req, res) => {
|
|
try {
|
|
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before adding YouTube content.' });
|
|
const { url, name } = req.body;
|
|
if (!url) return res.status(400).json({ error: 'url is required' });
|
|
|
|
// Extract YouTube video ID from various URL formats
|
|
const videoId = extractYoutubeId(url);
|
|
if (!videoId) return res.status(400).json({ error: 'Invalid YouTube URL' });
|
|
|
|
// Fetch title + aspect from YouTube oEmbed, queried with the ORIGINAL url so a
|
|
// /shorts/ link reports its true vertical dimensions. A Short is detected from
|
|
// the /shorts/ URL form OR portrait oEmbed dims (height > width). We persist that
|
|
// as st_aspect=vertical on the embed URL so every player can render it 9:16
|
|
// without re-querying oEmbed on each loop (remote_url is the only signal players
|
|
// get; the /shorts/ origin is otherwise lost after ingest). YouTube ignores the
|
|
// unknown param, and players read the video id — not the full URL — for the embed.
|
|
let filename = name;
|
|
let isVertical = /\/shorts\//i.test(url);
|
|
try {
|
|
const oembedRes = await fetch(`https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`);
|
|
if (oembedRes.ok) {
|
|
const oembed = await oembedRes.json();
|
|
if (!filename) filename = oembed.title;
|
|
if (oembed.height && oembed.width && Number(oembed.height) > Number(oembed.width)) isVertical = true;
|
|
}
|
|
} catch {}
|
|
if (!filename) filename = `YouTube: ${videoId}`;
|
|
|
|
const id = uuidv4();
|
|
const embedUrl = `https://www.youtube.com/embed/${videoId}?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=${videoId}&enablejsapi=1${isVertical ? '&st_aspect=vertical' : ''}`;
|
|
// thumbnail_path is a REMOTE URL here; the /api/content/:id/thumbnail route proxies
|
|
// remote thumbnails server-side (so this isn't a local-file path). Future option for
|
|
// CDN independence: download the thumbnail at ingest into contentDir + backfill
|
|
// existing rows, then this would store a local filename like image uploads do.
|
|
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
|
|
|
db.prepare(`
|
|
INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path)
|
|
VALUES (?, ?, ?, ?, '', 'video/youtube', 0, ?, ?)
|
|
`).run(id, req.user.id, req.workspaceId, safeFilename(filename), embedUrl, thumbnailUrl);
|
|
|
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
|
res.status(201).json(content);
|
|
} catch (err) {
|
|
console.error('YouTube add error:', err);
|
|
res.status(500).json({ error: 'Failed to add YouTube video' });
|
|
}
|
|
});
|
|
|
|
function extractYoutubeId(url) {
|
|
const patterns = [
|
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
|
/^([a-zA-Z0-9_-]{11})$/ // bare video ID
|
|
];
|
|
for (const p of patterns) {
|
|
const m = url.match(p);
|
|
if (m) return m[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Phase 2.2b: workspace-aware access. Mirrors the device check pattern.
|
|
// Platform-template content (workspace_id IS NULL) is readable by anyone
|
|
// and writable only by platform_admin.
|
|
function checkContentRead(req, res) {
|
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id);
|
|
if (!content) { res.status(404).json({ error: 'Content not found' }); return null; }
|
|
// Platform-template row: readable by anyone authenticated.
|
|
if (!content.workspace_id) return content;
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(content.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
|
|
return content;
|
|
}
|
|
|
|
function checkContentWrite(req, res) {
|
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id);
|
|
if (!content) { res.status(404).json({ error: 'Content not found' }); return null; }
|
|
// Platform-template row: only platform_admin may write.
|
|
if (!content.workspace_id) {
|
|
if (!PLATFORM_ROLES.includes(req.user.role)) {
|
|
res.status(403).json({ error: 'Platform admin required to modify shared content' }); return null;
|
|
}
|
|
return content;
|
|
}
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(content.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
|
|
// Workspace_viewer is read-only; acting-as (platform_admin or org owner/admin) and editor/admin pass.
|
|
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
|
|
res.status(403).json({ error: 'Read-only access' }); return null;
|
|
}
|
|
return content;
|
|
}
|
|
|
|
// #213: boolean form of checkContentWrite for batch paths (no res side effects). True if
|
|
// req.user may modify this content row. Mirrors checkContentWrite's authorization exactly.
|
|
function contentWritable(req, content) {
|
|
if (!content) return false;
|
|
if (!content.workspace_id) return PLATFORM_ROLES.includes(req.user.role);
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(content.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) return false;
|
|
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') return false;
|
|
return true;
|
|
}
|
|
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
// #213: shared single-row teardown used by DELETE /:id and POST /batch/delete. Removes the
|
|
// row's files, scrubs it from published snapshots in its workspace, deletes the row (cascades
|
|
// playlist_items). Returns the device ids whose playlists referenced it so the caller can push
|
|
// updates. Pure DB+FS, no HTTP. `content.id` MUST be a validated UUID (LIKE scrub) and the
|
|
// caller MUST have authorized the write. File unlinks are wrapped so they never throw.
|
|
function purgeContentRow(content) {
|
|
const id = content.id;
|
|
const unlink = (rel) => {
|
|
if (!rel) return;
|
|
const p = path.join(config.contentDir, path.basename(rel));
|
|
if (fs.existsSync(p)) { try { fs.unlinkSync(p); } catch (e) { /* best-effort */ } }
|
|
};
|
|
unlink(content.filepath);
|
|
unlink(content.thumbnail_path);
|
|
unlink(content.subtitle_url); // #216 sidecar (undefined on pre-#216 rows — no-op)
|
|
|
|
const affected = db.prepare(`
|
|
SELECT DISTINCT d.id as device_id FROM devices d
|
|
JOIN playlists p ON d.playlist_id = p.id
|
|
JOIN playlist_items pi ON pi.playlist_id = p.id
|
|
WHERE pi.content_id = ?
|
|
`).all(id).map(r => r.device_id);
|
|
|
|
const snapshotPlaylists = db.prepare(
|
|
"SELECT id, published_snapshot FROM playlists WHERE workspace_id = ? AND published_snapshot LIKE ?"
|
|
).all(content.workspace_id, `%${id}%`);
|
|
for (const pl of snapshotPlaylists) {
|
|
try {
|
|
const items = JSON.parse(pl.published_snapshot);
|
|
const filtered = items.filter(item => item.content_id !== id);
|
|
if (filtered.length !== items.length) {
|
|
db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?').run(JSON.stringify(filtered), pl.id);
|
|
}
|
|
} catch (e) { /* corrupt snapshot, skip */ }
|
|
}
|
|
|
|
db.prepare('DELETE FROM content WHERE id = ?').run(id);
|
|
return affected;
|
|
}
|
|
|
|
// #213: push a playlist refresh to a set of device ids (deduped). Silent on any failure.
|
|
function pushContentUpdates(req, deviceIds) {
|
|
try {
|
|
const io = req.app.get('io');
|
|
if (!io) return;
|
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
|
const commandQueue = require('../lib/command-queue');
|
|
const deviceNs = io.of('/device');
|
|
for (const id of new Set(deviceIds)) {
|
|
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, id, buildPlaylistPayload);
|
|
}
|
|
} catch (e) { /* silent */ }
|
|
}
|
|
|
|
// #213: batch delete. Validates + authorizes EVERY id first (atomic — the whole batch is
|
|
// rejected if any id is malformed/missing/forbidden), then deletes in one transaction.
|
|
router.post('/batch/delete', (req, res) => {
|
|
const ids = Array.isArray(req.body.ids) ? req.body.ids : null;
|
|
if (!ids || ids.length === 0) return res.status(400).json({ error: 'ids must be a non-empty array' });
|
|
if (ids.length > 500) return res.status(400).json({ error: 'Too many items (max 500 per batch)' });
|
|
|
|
const rows = [];
|
|
for (const id of ids) {
|
|
if (typeof id !== 'string' || !UUID_RE.test(id)) return res.status(400).json({ error: `Invalid content ID: ${id}` });
|
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
|
if (!content) return res.status(404).json({ error: `Content not found: ${id}` });
|
|
if (!contentWritable(req, content)) return res.status(403).json({ error: `Access denied for content: ${id}` });
|
|
rows.push(content);
|
|
}
|
|
|
|
const affected = new Set();
|
|
db.transaction(() => {
|
|
for (const content of rows) for (const d of purgeContentRow(content)) affected.add(d);
|
|
})();
|
|
pushContentUpdates(req, affected);
|
|
res.json({ success: true, deleted: rows.length, affectedDevices: [...affected] });
|
|
});
|
|
|
|
// #213: batch move. Reassigns folder_id for many items at once. Folder is organizational only
|
|
// (not in the published snapshot), so no device push is needed. Same atomic validate-all-first.
|
|
router.post('/batch/move', (req, res) => {
|
|
const ids = Array.isArray(req.body.ids) ? req.body.ids : null;
|
|
const folderId = req.body.folder_id || null;
|
|
if (!ids || ids.length === 0) return res.status(400).json({ error: 'ids must be a non-empty array' });
|
|
if (ids.length > 500) return res.status(400).json({ error: 'Too many items (max 500 per batch)' });
|
|
|
|
const rows = [];
|
|
for (const id of ids) {
|
|
if (typeof id !== 'string' || !UUID_RE.test(id)) return res.status(400).json({ error: `Invalid content ID: ${id}` });
|
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
|
if (!content) return res.status(404).json({ error: `Content not found: ${id}` });
|
|
if (!contentWritable(req, content)) return res.status(403).json({ error: `Access denied for content: ${id}` });
|
|
rows.push(content);
|
|
}
|
|
// Target folder (if any) must exist and share the workspace of every moved item.
|
|
if (folderId) {
|
|
const target = db.prepare('SELECT workspace_id FROM content_folders WHERE id = ?').get(folderId);
|
|
if (!target) return res.status(400).json({ error: 'Invalid folder_id' });
|
|
for (const content of rows) {
|
|
if (target.workspace_id !== content.workspace_id) {
|
|
return res.status(403).json({ error: 'Cannot move content to a folder in another workspace' });
|
|
}
|
|
}
|
|
}
|
|
|
|
db.transaction(() => {
|
|
const stmt = db.prepare('UPDATE content SET folder_id = ? WHERE id = ?');
|
|
for (const content of rows) stmt.run(folderId, content.id);
|
|
})();
|
|
res.json({ success: true, moved: rows.length, folder_id: folderId });
|
|
});
|
|
|
|
// Get content metadata
|
|
router.get('/:id', (req, res) => {
|
|
const content = checkContentRead(req, res);
|
|
if (!content) return;
|
|
res.json(content);
|
|
});
|
|
|
|
// Update content metadata
|
|
router.put('/:id', (req, res) => {
|
|
const content = checkContentWrite(req, res);
|
|
if (!content) return;
|
|
|
|
const { filename, mime_type, remote_url, folder, folder_id, expires_at, unstable_connection,
|
|
captions_enabled, captions_lang, subtitle_url, subtitle_lang } = req.body;
|
|
const updates = [];
|
|
const values = [];
|
|
if (filename !== undefined) { updates.push('filename = ?'); values.push(safeFilename(filename)); }
|
|
if (mime_type !== undefined) { updates.push('mime_type = ?'); values.push(mime_type); }
|
|
if (remote_url !== undefined) {
|
|
if (remote_url) {
|
|
const urlErr = validateRemoteUrl(remote_url);
|
|
if (urlErr) return res.status(urlErr.status).json({ error: urlErr.error });
|
|
}
|
|
updates.push('remote_url = ?');
|
|
values.push(remote_url || null);
|
|
}
|
|
if (folder !== undefined) { updates.push('folder = ?'); values.push(folder || null); }
|
|
if (folder_id !== undefined) {
|
|
// Phase 2.2c: target folder must live in the same workspace as the
|
|
// content row being modified. Strict same-workspace check - no
|
|
// platform_admin override, because cross-workspace folder references
|
|
// break the isolation model. To move content across workspaces, switch
|
|
// workspace first.
|
|
if (folder_id) {
|
|
const target = db.prepare('SELECT workspace_id FROM content_folders WHERE id = ?').get(folder_id);
|
|
if (!target) return res.status(400).json({ error: 'Invalid folder_id' });
|
|
if (target.workspace_id !== content.workspace_id) {
|
|
return res.status(403).json({ error: 'Cannot move content to a folder in another workspace' });
|
|
}
|
|
}
|
|
updates.push('folder_id = ?');
|
|
values.push(folder_id || null);
|
|
}
|
|
// #157: set/clear expiry (epoch seconds, or null = never). Whenever expiry changes we
|
|
// reset is_active=1 — the expiry sweep's once-only marker. That means: clearing/extending
|
|
// to a future time reactivates the item immediately; setting a PAST time leaves it "active"
|
|
// for the sweep to flip to 0 AND republish the playlists that carried it (the immediate-
|
|
// expiry path). Publish-time filtering already excludes past-expiry items regardless.
|
|
if (expires_at !== undefined) {
|
|
let val = null;
|
|
if (expires_at !== null && expires_at !== '') {
|
|
val = Number(expires_at);
|
|
if (!Number.isFinite(val) || val <= 0) {
|
|
return res.status(400).json({ error: 'expires_at must be epoch seconds (positive integer) or null' });
|
|
}
|
|
val = Math.floor(val);
|
|
}
|
|
updates.push('expires_at = ?'); values.push(val);
|
|
updates.push('is_active = 1');
|
|
}
|
|
// #217: force a lower YouTube quality ceiling for weak/unstable WiFi. Stored 0/1;
|
|
// accepts booleans or 0/1 from the client and coerces to an integer.
|
|
if (unstable_connection !== undefined) {
|
|
updates.push('unstable_connection = ?');
|
|
values.push(unstable_connection ? 1 : 0);
|
|
}
|
|
// #216: caption/subtitle metadata. The subtitle FILE is uploaded via POST /:id/subtitle;
|
|
// these fields toggle YouTube captions, set languages, or clear a subtitle (subtitle_url=null).
|
|
if (captions_enabled !== undefined) {
|
|
updates.push('captions_enabled = ?'); values.push(captions_enabled ? 1 : 0);
|
|
}
|
|
if (captions_lang !== undefined) {
|
|
updates.push('captions_lang = ?'); values.push(captions_lang ? String(captions_lang).slice(0, 10) : null);
|
|
}
|
|
if (subtitle_url !== undefined) {
|
|
// Only null (clear) is accepted here — a real subtitle_url is set by the upload endpoint.
|
|
updates.push('subtitle_url = ?'); values.push(subtitle_url ? String(subtitle_url).slice(0, 255) : null);
|
|
}
|
|
if (subtitle_lang !== undefined) {
|
|
updates.push('subtitle_lang = ?'); values.push(subtitle_lang ? String(subtitle_lang).slice(0, 10) : null);
|
|
}
|
|
|
|
if (updates.length > 0) {
|
|
values.push(req.params.id);
|
|
db.prepare(`UPDATE content SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
}
|
|
|
|
res.json(db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id));
|
|
});
|
|
|
|
// Replace content file
|
|
router.put('/:id/replace', upload.single('file'), async (req, res) => {
|
|
const content = checkContentWrite(req, res);
|
|
if (!content) return;
|
|
if (!req.file) return res.status(400).json({ error: 'No file provided' });
|
|
|
|
// Delete old file
|
|
if (content.filepath) {
|
|
const oldPath = path.join(config.contentDir, content.filepath);
|
|
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
|
}
|
|
// Delete old thumbnail
|
|
if (content.thumbnail_path) {
|
|
const oldThumb = path.join(config.contentDir, content.thumbnail_path);
|
|
if (fs.existsSync(oldThumb)) fs.unlinkSync(oldThumb);
|
|
}
|
|
|
|
// Same content-derived naming as the main ingest path (lib/upload-sniff) — the caller
|
|
// does not choose the extension here either. A non-media upload 400s.
|
|
let filepath, mime;
|
|
try { ({ filepath, mime } = finalizeUpload(req.file)); }
|
|
catch (e) { return res.status(e.status || 400).json({ error: e.message }); }
|
|
|
|
// Re-derive EVERYTHING the bytes decide, through the SAME function the upload path uses.
|
|
// This route used to carry a shorter copy that handled images only, and got three things
|
|
// wrong that an upload gets right:
|
|
// - a replaced VIDEO lost its duration (the row kept the OLD clip's length, so #237's
|
|
// "default an item to the clip's own length" then handed out the wrong number), its
|
|
// dimensions, and its thumbnail;
|
|
// - a replaced IMAGE was measured with raw sharp metadata instead of imageDisplayDims and
|
|
// thumbnailed without .rotate(), re-introducing the EXIF-orientation bug (#170) that
|
|
// ingest fixes — a portrait photo came back landscape with blue bars;
|
|
// - both left width/height NULL for video, which is what the orientation-aware paths read.
|
|
const { width, height, durationSec, thumbnailPath } = await deriveMediaMetadata(req.file.path, filepath, mime);
|
|
|
|
// Bump the revision: this is the ONLY operation in the product that changes an asset's bytes
|
|
// without changing its id, so it is the only thing that can make a player's cached copy wrong.
|
|
// Players key their media cache on the revision, so this is what evicts it.
|
|
//
|
|
// strftime seconds can collide with the previous value if a replace lands inside the same second
|
|
// as the upload (a small file, a scripted replace) — and a revision that does not change is a
|
|
// cache that never updates. MAX(now, previous + 1) guarantees it moves.
|
|
// duration_sec comes from the NEW bytes. COALESCE-to-NULL rather than keeping the old value:
|
|
// a replace that turns a video into an image genuinely has no duration, and a stale one would
|
|
// silently become the default for every later playlist add (lib/item-duration.js).
|
|
db.prepare(`UPDATE content
|
|
SET filepath = ?, mime_type = ?, file_size = ?, thumbnail_path = ?, width = ?, height = ?,
|
|
duration_sec = ?,
|
|
updated_at = MAX(CAST(strftime('%s','now') AS INTEGER), COALESCE(NULLIF(updated_at, 0), created_at) + 1)
|
|
WHERE id = ?`)
|
|
.run(filepath, mime, req.file.size, thumbnailPath, width, height, durationSec, req.params.id);
|
|
|
|
// ...and tell the panels, which the old code did not. Without this the new bytes reached a screen
|
|
// only when something else happened to trigger a playlist refresh — an operator replacing a video
|
|
// watched the dashboard update and the screen keep playing the old one.
|
|
const affected = db.prepare(`
|
|
SELECT DISTINCT d.id as device_id FROM devices d
|
|
JOIN playlists p ON d.playlist_id = p.id
|
|
JOIN playlist_items pi ON pi.playlist_id = p.id
|
|
WHERE pi.content_id = ?
|
|
`).all(req.params.id).map((r) => r.device_id);
|
|
pushContentUpdates(req, affected);
|
|
|
|
res.json(db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id));
|
|
});
|
|
|
|
// #216: upload a WebVTT subtitle track for an uploaded video. Stores the .vtt in the
|
|
// content dir (served at /uploads/content/<file>) and records its filename + language on
|
|
// the content row. Replaces any existing subtitle (old file removed).
|
|
router.post('/:id/subtitle', upload.subtitleUpload.single('subtitle'), async (req, res) => {
|
|
const content = checkContentWrite(req, res);
|
|
if (!content) {
|
|
// checkContentWrite already sent the response; clean up the orphaned upload.
|
|
if (req.file) { try { fs.unlinkSync(req.file.path); } catch {} }
|
|
return;
|
|
}
|
|
if (!req.file) return res.status(400).json({ error: 'No subtitle file provided' });
|
|
|
|
// Remove the previous subtitle file if there was one.
|
|
if (content.subtitle_url) {
|
|
const old = path.join(config.contentDir, path.basename(content.subtitle_url));
|
|
if (fs.existsSync(old)) { try { fs.unlinkSync(old); } catch {} }
|
|
}
|
|
const lang = req.body.subtitle_lang ? String(req.body.subtitle_lang).slice(0, 10) : (content.subtitle_lang || null);
|
|
db.prepare('UPDATE content SET subtitle_url = ?, subtitle_lang = ? WHERE id = ?')
|
|
.run(req.file.filename, lang, req.params.id);
|
|
res.json(db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id));
|
|
});
|
|
|
|
// Uploads share the dashboard origin — see server.js hardenUploadResponse. Same rule
|
|
// applied here so these routes are safe on their own merits, not because another mount
|
|
// happens to be registered first.
|
|
function hardenUploadResponse(res, filename) {
|
|
res.setHeader('Content-Security-Policy', 'sandbox');
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
if (!INLINE_SAFE_EXTS.has(path.extname(String(filename || '')).toLowerCase())) {
|
|
res.setHeader('Content-Type', 'application/octet-stream');
|
|
res.setHeader('Content-Disposition', 'attachment');
|
|
}
|
|
}
|
|
|
|
// Serve content file
|
|
router.get('/:id/file', (req, res) => {
|
|
const content = checkContentRead(req, res);
|
|
if (!content) return;
|
|
if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' });
|
|
// Prevent path traversal
|
|
const safePath = path.resolve(config.contentDir, path.basename(content.filepath));
|
|
if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' });
|
|
hardenUploadResponse(res, content.filepath);
|
|
res.sendFile(safePath);
|
|
});
|
|
|
|
// Serve thumbnail
|
|
router.get('/:id/thumbnail', (req, res) => {
|
|
const content = checkContentRead(req, res);
|
|
if (!content) return;
|
|
if (!content.thumbnail_path) return res.status(404).json({ error: 'Thumbnail not found' });
|
|
const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path));
|
|
if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' });
|
|
hardenUploadResponse(res, content.thumbnail_path);
|
|
res.sendFile(safePath);
|
|
});
|
|
|
|
// Delete content
|
|
router.delete('/:id', (req, res) => {
|
|
const content = checkContentWrite(req, res);
|
|
if (!content) return;
|
|
// Validate UUID format to prevent LIKE wildcard injection in the snapshot scrub.
|
|
if (!UUID_RE.test(req.params.id)) return res.status(400).json({ error: 'Invalid content ID format' });
|
|
|
|
// #213: shared teardown (file removal + snapshot scrub + row delete). Returns the affected
|
|
// device ids so we can push a refresh.
|
|
const affectedDevices = purgeContentRow(content);
|
|
pushContentUpdates(req, affectedDevices);
|
|
res.json({ success: true, affectedDevices });
|
|
});
|
|
|
|
module.exports = router;
|