@@ -491,8 +497,34 @@ async function loadContent() {
});
});
+ // #213: selection checkboxes (with shift-click range). `content` is the current page's
+ // ordered list, so a range fills between the anchor and the clicked item.
+ grid.querySelectorAll('.content-select').forEach(cb => {
+ cb.addEventListener('click', (e) => {
+ const id = cb.dataset.contentId;
+ if (e.shiftKey && state.lastClickedId) {
+ const order = content.map(c => c.id);
+ const a = order.indexOf(state.lastClickedId);
+ const b = order.indexOf(id);
+ if (a !== -1 && b !== -1) {
+ const [lo, hi] = a < b ? [a, b] : [b, a];
+ const on = cb.checked; // apply the clicked box's new state across the range
+ for (let i = lo; i <= hi; i++) { if (on) state.selected.add(order[i]); else state.selected.delete(order[i]); }
+ }
+ } else if (cb.checked) {
+ state.selected.add(id);
+ } else {
+ state.selected.delete(id);
+ }
+ state.lastClickedId = id;
+ loadContent(); // re-render to reflect range + selection outlines + toolbar
+ });
+ });
+
// Delete handler via event delegation
grid.onclick = async (e) => {
+ // #213: ignore clicks originating on a selection checkbox (handled above).
+ if (e.target.closest('.content-select-wrap')) return;
// Preview on click (not on delete button)
const previewTarget = e.target.closest('.content-item-preview');
if (previewTarget) {
@@ -552,11 +584,87 @@ async function loadContent() {
}, 3000);
};
+ // #213: batch-operations toolbar reflects the current selection.
+ renderBatchToolbar(content);
+
} catch (err) {
grid.innerHTML = `
${t('content.failed_to_load')}
${esc(err.message)}
`;
}
}
+// #213: the batch toolbar — shown only when something is selected. `visible` is the current
+// page's items, used by "select all". Actions validate/act atomically server-side; on success
+// the selection is cleared and the grid reloaded.
+function renderBatchToolbar(visible) {
+ const bar = document.getElementById('batchToolbar');
+ if (!bar) return;
+ const count = state.selected.size;
+ if (count === 0) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
+
+ const allVisibleSelected = visible.length > 0 && visible.every(c => state.selected.has(c.id));
+ bar.style.display = 'flex';
+ bar.style.cssText = 'display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;padding:10px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg)';
+ bar.innerHTML = `
+
${t('content.batch_selected', { count })}
+
+
+
+
+
+ `;
+
+ bar.querySelector('#batchSelectAll').onclick = () => {
+ if (allVisibleSelected) visible.forEach(c => state.selected.delete(c.id));
+ else visible.forEach(c => state.selected.add(c.id));
+ loadContent();
+ };
+
+ bar.querySelector('#batchMoveFolder').onchange = async (e) => {
+ const val = e.target.value;
+ if (!val) return;
+ const folderId = val === '__root__' ? null : val;
+ const ids = [...state.selected];
+ try {
+ await api.batchMoveContent(ids, folderId);
+ showToast(t('content.toast.batch_moved', { count: ids.length }), 'success');
+ state.selected.clear();
+ state.lastClickedId = null;
+ loadContent();
+ } catch (err) {
+ showToast(err.message, 'error');
+ e.target.value = '';
+ }
+ };
+
+ const delBtn = bar.querySelector('#batchDelete');
+ delBtn.onclick = async () => {
+ const ids = [...state.selected];
+ if (delBtn.dataset.confirming !== 'true') {
+ delBtn.dataset.confirming = 'true';
+ delBtn.textContent = t('content.batch_delete_confirm', { count: ids.length });
+ setTimeout(() => { if (delBtn.dataset.confirming === 'true') { delBtn.dataset.confirming = 'false'; delBtn.textContent = t('content.batch_delete', { count: ids.length }); } }, 3000);
+ return;
+ }
+ try {
+ delBtn.disabled = true;
+ await api.batchDeleteContent(ids);
+ showToast(t('content.toast.batch_deleted', { count: ids.length }), 'success');
+ state.selected.clear();
+ state.lastClickedId = null;
+ loadContent();
+ } catch (err) {
+ showToast(err.message, 'error');
+ delBtn.disabled = false;
+ delBtn.dataset.confirming = 'false';
+ delBtn.textContent = t('content.batch_delete', { count: ids.length });
+ }
+ };
+}
+
function showEditModal(contentItem, onSave) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
diff --git a/server/routes/content.js b/server/routes/content.js
index b78dde9..50b018b 100644
--- a/server/routes/content.js
+++ b/server/routes/content.js
@@ -271,6 +271,132 @@ function checkContentWrite(req, res) {
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);
@@ -411,65 +537,14 @@ router.get('/:id/thumbnail', (req, res) => {
router.delete('/:id', (req, res) => {
const content = checkContentWrite(req, res);
if (!content) return;
-
- // Delete file from disk (skip for remote URL content)
- if (content.filepath) {
- const filePath = path.join(config.contentDir, content.filepath);
- if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
- }
-
- // Delete thumbnail
- if (content.thumbnail_path) {
- const thumbPath = path.join(config.contentDir, content.thumbnail_path);
- if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath);
- }
-
- // Get devices that have this content in their playlist (via playlist_items)
- const affectedDevices = 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);
-
- // Scrub published snapshots that reference this content
- // Validate UUID format to prevent LIKE wildcard injection
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+ // 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' });
- // Phase 2.2k: scope snapshot scrubbing by content.workspace_id (was content.user_id).
- // Playlists referencing this content live in the same workspace; user_id-keying missed
- // cross-user playlists in the same workspace once playlists became workspace-scoped.
- const snapshotPlaylists = db.prepare(
- "SELECT id, published_snapshot FROM playlists WHERE workspace_id = ? AND published_snapshot LIKE ?"
- ).all(content.workspace_id, `%${req.params.id}%`);
- for (const pl of snapshotPlaylists) {
- try {
- const items = JSON.parse(pl.published_snapshot);
- const filtered = items.filter(item => item.content_id !== req.params.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 */ }
- }
- // Delete from DB (cascades to playlist_items via ON DELETE CASCADE)
- db.prepare('DELETE FROM content WHERE id = ?').run(req.params.id);
-
- // Push updated snapshots to affected devices
- try {
- const io = req.app.get('io');
- if (io) {
- const { buildPlaylistPayload } = require('../ws/deviceSocket');
- const commandQueue = require('../lib/command-queue');
- const deviceNs = io.of('/device');
- for (const d of affectedDevices) {
- commandQueue.queueOrEmitPlaylistUpdate(deviceNs, d.device_id, buildPlaylistPayload);
- }
- }
- } catch (e) { /* silent */ }
-
- res.json({ success: true, affectedDevices: affectedDevices.map(d => d.device_id) });
+ // #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;
diff --git a/server/test/content-batch-ops.test.js b/server/test/content-batch-ops.test.js
new file mode 100644
index 0000000..b84482f
--- /dev/null
+++ b/server/test/content-batch-ops.test.js
@@ -0,0 +1,124 @@
+'use strict';
+
+// #213 batch operations. POST /content/batch/delete and /batch/move. The atomic
+// validate-all-first contract and the shared snapshot-scrub path are the parts worth guarding.
+
+const os = require('node:os');
+const path = require('node:path');
+const fs = require('node:fs');
+const crypto = require('node:crypto');
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'st-batch-'));
+process.env.DATA_DIR = TMP;
+process.env.SELF_HOSTED = 'true';
+process.env.NODE_ENV = 'test';
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const express = require('express');
+const { db } = require('../db/database');
+const { publishPlaylist } = require('../routes/playlists');
+
+const UUID = () => crypto.randomUUID();
+const USER = 'u-batch';
+let server, base;
+
+function post(pathname, body) {
+ const data = Buffer.from(JSON.stringify(body));
+ return new Promise((resolve, reject) => {
+ const req = http.request(`${base}${pathname}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Content-Length': data.length },
+ }, (r) => { let o = ''; r.on('data', (c) => (o += c)); r.on('end', () => resolve({ status: r.statusCode, json: o ? JSON.parse(o) : null })); });
+ req.on('error', reject);
+ req.end(data);
+ });
+}
+
+function mkContent(id, { filepath = '', workspace = 'ws-batch' } = {}) {
+ db.prepare('INSERT INTO content (id, filename, mime_type, filepath, workspace_id) VALUES (?, ?, ?, ?, ?)')
+ .run(id, id + '.mp4', 'video/mp4', filepath, workspace);
+}
+
+before(async () => {
+ fs.mkdirSync(require('../config').contentDir, { recursive: true });
+ db.prepare("INSERT INTO users (id, email, password_hash, plan_id) VALUES (?, ?, 'x', 'free')").run(USER, USER + '@t.local');
+ db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('org-batch', 'Org', ?)").run(USER);
+ db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-batch', 'org-batch', 'WS')").run();
+ db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-other', 'org-batch', 'Other')").run();
+ db.prepare("INSERT INTO content_folders (id, name, user_id, workspace_id) VALUES ('f-batch', 'Folder', 'u-batch', 'ws-batch')").run();
+
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => { req.workspaceId = 'ws-batch'; req.user = { id: USER, role: 'platform_admin' }; next(); });
+ app.use('/content', require('../routes/content'));
+ server = http.createServer(app);
+ await new Promise((r) => server.listen(0, r));
+ base = `http://127.0.0.1:${server.address().port}`;
+});
+
+after(() => new Promise((r) => server.close(r)));
+
+test('batch delete removes all rows, their files, and scrubs published snapshots', async () => {
+ const a = UUID(), b = UUID();
+ // give `a` a real file on disk to prove it's removed
+ const fp = a + '.mp4';
+ fs.writeFileSync(path.join(require('../config').contentDir, fp), 'x');
+ mkContent(a, { filepath: fp });
+ mkContent(b);
+
+ // a published playlist carrying both -> must be scrubbed of both
+ const pl = UUID();
+ db.prepare("INSERT INTO playlists (id, user_id, workspace_id, name, status) VALUES (?, ?, 'ws-batch', 'P', 'draft')").run(pl, USER);
+ db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order) VALUES (?, ?, 0)').run(pl, a);
+ db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order) VALUES (?, ?, 1)').run(pl, b);
+ publishPlaylist(pl);
+ assert.equal(JSON.parse(db.prepare('SELECT published_snapshot FROM playlists WHERE id=?').get(pl).published_snapshot).length, 2);
+
+ const res = await post('/content/batch/delete', { ids: [a, b] });
+ assert.equal(res.status, 200);
+ assert.equal(res.json.deleted, 2);
+ assert.equal(db.prepare('SELECT COUNT(*) n FROM content WHERE id IN (?,?)').get(a, b).n, 0);
+ assert.ok(!fs.existsSync(path.join(require('../config').contentDir, fp)), 'file removed from disk');
+ assert.equal(JSON.parse(db.prepare('SELECT published_snapshot FROM playlists WHERE id=?').get(pl).published_snapshot).length, 0);
+});
+
+test('batch delete is atomic: one bad id rejects the whole batch, nothing deleted', async () => {
+ const a = UUID();
+ mkContent(a);
+ const res = await post('/content/batch/delete', { ids: [a, UUID() /* not found */] });
+ assert.equal(res.status, 404);
+ assert.equal(db.prepare('SELECT COUNT(*) n FROM content WHERE id=?').get(a).n, 1, 'valid row survived the rejected batch');
+});
+
+test('batch delete rejects a malformed id (LIKE-injection guard)', async () => {
+ const res = await post('/content/batch/delete', { ids: ['not-a-uuid'] });
+ assert.equal(res.status, 400);
+});
+
+test('batch move reassigns folder for all ids', async () => {
+ const a = UUID(), b = UUID();
+ mkContent(a); mkContent(b);
+ const res = await post('/content/batch/move', { ids: [a, b], folder_id: 'f-batch' });
+ assert.equal(res.status, 200);
+ assert.equal(res.json.moved, 2);
+ for (const id of [a, b]) assert.equal(db.prepare('SELECT folder_id FROM content WHERE id=?').get(id).folder_id, 'f-batch');
+ // move back to root
+ const res2 = await post('/content/batch/move', { ids: [a, b], folder_id: null });
+ assert.equal(res2.status, 200);
+ assert.equal(db.prepare('SELECT folder_id FROM content WHERE id=?').get(a).folder_id, null);
+});
+
+test('batch move to a folder in another workspace is refused', async () => {
+ const a = UUID();
+ mkContent(a);
+ db.prepare("INSERT INTO content_folders (id, name, user_id, workspace_id) VALUES ('f-other', 'Other', 'u-batch', 'ws-other')").run();
+ const res = await post('/content/batch/move', { ids: [a], folder_id: 'f-other' });
+ assert.equal(res.status, 403);
+ assert.equal(db.prepare('SELECT folder_id FROM content WHERE id=?').get(a).folder_id, null, 'not moved');
+});
+
+test('empty / oversized batches are rejected', async () => {
+ assert.equal((await post('/content/batch/delete', { ids: [] })).status, 400);
+ assert.equal((await post('/content/batch/move', { ids: [], folder_id: null })).status, 400);
+});