Phase 2.2b: content.js + status.js import scoped to workspace_id; uploads stamp workspace_id

This commit is contained in:
ScreenTinker 2026-05-11 20:50:25 -05:00
parent afd2a10df2
commit a5dbc5d665
2 changed files with 76 additions and 29 deletions

View file

@ -9,6 +9,8 @@ const config = require('../config');
const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription'); const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription');
const { sanitizeString } = require('../middleware/sanitize'); const { sanitizeString } = require('../middleware/sanitize');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth'); 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');
// Multer captures file.originalname directly from the multipart filename header, // Multer captures file.originalname directly from the multipart filename header,
// bypassing sanitizeBody. Apply the same HTML-escape here so a filename like // bypassing sanitizeBody. Apply the same HTML-escape here so a filename like
@ -40,14 +42,17 @@ function validateRemoteUrl(url) {
return null; return null;
} }
// List content for current user (admins see all). // 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. // folder_id filter: omit for everything; "root" or "" for root-level only; <uuid> for that folder.
router.get('/', (req, res) => { router.get('/', (req, res) => {
const isAdmin = PLATFORM_ROLES.includes(req.user.role); if (!req.workspaceId) return res.json([]);
const folder = req.query.folder; const folder = req.query.folder;
const folderId = req.query.folder_id; const folderId = req.query.folder_id;
let sql = `SELECT * FROM content ${isAdmin ? 'WHERE 1=1' : 'WHERE (user_id = ? OR user_id IS NULL)'}`; let sql = 'SELECT * FROM content WHERE (workspace_id = ? OR workspace_id IS NULL)';
const params = isAdmin ? [] : [req.user.id]; const params = [req.workspaceId];
if (folder) { sql += ' AND folder = ?'; params.push(folder); } if (folder) { sql += ' AND folder = ?'; params.push(folder); }
if (folderId !== undefined) { if (folderId !== undefined) {
if (folderId === 'root' || folderId === '') { if (folderId === 'root' || folderId === '') {
@ -63,18 +68,19 @@ router.get('/', (req, res) => {
res.json(content); res.json(content);
}); });
// Get folders list // Get folders list for the caller's current workspace.
router.get('/folders', (req, res) => { router.get('/folders', (req, res) => {
const isAdmin = PLATFORM_ROLES.includes(req.user.role); if (!req.workspaceId) return res.json([]);
const folders = db.prepare( const folders = db.prepare(
`SELECT folder, COUNT(*) as count FROM content WHERE folder IS NOT NULL ${isAdmin ? '' : 'AND (user_id = ? OR user_id IS NULL)'} GROUP BY folder ORDER BY folder` '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(...(isAdmin ? [] : [req.user.id])); ).all(req.workspaceId);
res.json(folders); res.json(folders);
}); });
// Upload content // Upload content
router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => { router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => {
try { try {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before uploading.' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const id = uuidv4(); const id = uuidv4();
@ -126,9 +132,9 @@ router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => {
} }
db.prepare(` db.prepare(`
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height) INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, req.user.id, safeFilename(req.file.originalname), filepath, req.file.mimetype, req.file.size, durationSec, thumbnailPath, width, height); `).run(id, req.user.id, req.workspaceId, safeFilename(req.file.originalname), filepath, req.file.mimetype, req.file.size, durationSec, thumbnailPath, width, height);
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id); const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
res.status(201).json(content); res.status(201).json(content);
@ -141,6 +147,7 @@ router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => {
// Add remote URL content // Add remote URL content
router.post('/remote', checkRemoteUrl, (req, res) => { router.post('/remote', checkRemoteUrl, (req, res) => {
try { 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; const { url, name, mime_type } = req.body;
if (!url) return res.status(400).json({ error: 'url is required' }); if (!url) return res.status(400).json({ error: 'url is required' });
const urlErr = validateRemoteUrl(url); const urlErr = validateRemoteUrl(url);
@ -151,9 +158,9 @@ router.post('/remote', checkRemoteUrl, (req, res) => {
const mimeType = mime_type || (url.match(/\.(mp4|webm|mkv|avi|mov)/i) ? 'video/mp4' : 'image/jpeg'); const mimeType = mime_type || (url.match(/\.(mp4|webm|mkv|avi|mov)/i) ? 'video/mp4' : 'image/jpeg');
db.prepare(` db.prepare(`
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url) INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, remote_url)
VALUES (?, ?, ?, '', ?, 0, ?) VALUES (?, ?, ?, ?, '', ?, 0, ?)
`).run(id, req.user.id, safeFilename(filename), mimeType, url); `).run(id, req.user.id, req.workspaceId, safeFilename(filename), mimeType, url);
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id); const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
res.status(201).json(content); res.status(201).json(content);
@ -166,6 +173,7 @@ router.post('/remote', checkRemoteUrl, (req, res) => {
// Add YouTube content (available to all plans - no storage used) // Add YouTube content (available to all plans - no storage used)
router.post('/youtube', async (req, res) => { router.post('/youtube', async (req, res) => {
try { 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; const { url, name } = req.body;
if (!url) return res.status(400).json({ error: 'url is required' }); if (!url) return res.status(400).json({ error: 'url is required' });
@ -191,9 +199,9 @@ router.post('/youtube', async (req, res) => {
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
db.prepare(` db.prepare(`
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path) INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path)
VALUES (?, ?, ?, '', 'video/youtube', 0, ?, ?) VALUES (?, ?, ?, ?, '', 'video/youtube', 0, ?, ?)
`).run(id, req.user.id, safeFilename(filename), embedUrl, thumbnailUrl); `).run(id, req.user.id, req.workspaceId, safeFilename(filename), embedUrl, thumbnailUrl);
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id); const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
res.status(201).json(content); res.status(201).json(content);
@ -215,26 +223,50 @@ function extractYoutubeId(url) {
return null; return null;
} }
// Helper: check content ownership // Phase 2.2b: workspace-aware access. Mirrors the device check pattern.
function checkContentAccess(req, res) { // 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); 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; } if (!content) { res.status(404).json({ error: 'Content not found' }); return null; }
if (!ELEVATED_ROLES.includes(req.user.role) && content.user_id && content.user_id !== req.user.id) { // Platform-template row: readable by anyone authenticated.
res.status(403).json({ error: 'Access denied' }); return null; 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; return content;
} }
// Get content metadata // Get content metadata
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
const content = checkContentAccess(req, res); const content = checkContentRead(req, res);
if (!content) return; if (!content) return;
res.json(content); res.json(content);
}); });
// Update content metadata // Update content metadata
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const content = checkContentAccess(req, res); const content = checkContentWrite(req, res);
if (!content) return; if (!content) return;
const { filename, mime_type, remote_url, folder, folder_id } = req.body; const { filename, mime_type, remote_url, folder, folder_id } = req.body;
@ -277,7 +309,7 @@ router.put('/:id', (req, res) => {
// Replace content file // Replace content file
router.put('/:id/replace', upload.single('file'), async (req, res) => { router.put('/:id/replace', upload.single('file'), async (req, res) => {
const content = checkContentAccess(req, res); const content = checkContentWrite(req, res);
if (!content) return; if (!content) return;
if (!req.file) return res.status(400).json({ error: 'No file provided' }); if (!req.file) return res.status(400).json({ error: 'No file provided' });
@ -318,7 +350,7 @@ router.put('/:id/replace', upload.single('file'), async (req, res) => {
// Serve content file // Serve content file
router.get('/:id/file', (req, res) => { router.get('/:id/file', (req, res) => {
const content = checkContentAccess(req, res); const content = checkContentRead(req, res);
if (!content) return; if (!content) return;
if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' }); if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' });
// Prevent path traversal // Prevent path traversal
@ -329,7 +361,7 @@ router.get('/:id/file', (req, res) => {
// Serve thumbnail // Serve thumbnail
router.get('/:id/thumbnail', (req, res) => { router.get('/:id/thumbnail', (req, res) => {
const content = checkContentAccess(req, res); const content = checkContentRead(req, res);
if (!content) return; if (!content) return;
if (!content.thumbnail_path) return res.status(404).json({ error: 'Thumbnail not found' }); if (!content.thumbnail_path) return res.status(404).json({ error: 'Thumbnail not found' });
const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path)); const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path));
@ -339,7 +371,7 @@ router.get('/:id/thumbnail', (req, res) => {
// Delete content // Delete content
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const content = checkContentAccess(req, res); const content = checkContentWrite(req, res);
if (!content) return; if (!content) return;
// Delete file from disk (skip for remote URL content) // Delete file from disk (skip for remote URL content)

View file

@ -179,11 +179,13 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'Token required' }); if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'Token required' });
let userId; let userId;
let workspaceId;
try { try {
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const jwtConfig = require('../config'); const jwtConfig = require('../config');
const decoded = jwt.verify(authHeader.split(' ')[1], jwtConfig.jwtSecret); const decoded = jwt.verify(authHeader.split(' ')[1], jwtConfig.jwtSecret);
userId = decoded.id; userId = decoded.id;
workspaceId = decoded.current_workspace_id || null;
if (!userId) return res.status(401).json({ error: 'Invalid token' }); if (!userId) return res.status(401).json({ error: 'Invalid token' });
} catch { } catch {
return res.status(401).json({ error: 'Invalid token' }); return res.status(401).json({ error: 'Invalid token' });
@ -192,6 +194,19 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(userId); const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) return res.status(404).json({ error: 'User not found' });
// Phase 2.2b: imports stamp workspace_id on devices and content so the
// rows are visible to the workspace-filtered list endpoints. Fall back to
// the importer's first accessible workspace if the JWT didn't carry one.
if (!workspaceId) {
const w = db.prepare(`
SELECT w.id FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE wm.user_id = ? ORDER BY wm.joined_at ASC LIMIT 1
`).get(userId);
workspaceId = w?.id || null;
}
if (!workspaceId) return res.status(403).json({ error: 'No workspace context for import. Switch to a workspace first.' });
let data; let data;
let extractedFiles = {}; // Map of old content ID -> { filepath, thumbnail } let extractedFiles = {}; // Map of old content ID -> { filepath, thumbnail }
@ -262,7 +277,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
const newId = uuid.v4(); const newId = uuid.v4();
idMap.devices[d.id] = newId; idMap.devices[d.id] = newId;
const pairingCode = String(Math.floor(100000 + Math.random() * 900000)); const pairingCode = String(Math.floor(100000 + Math.random() * 900000));
db.prepare(`INSERT INTO devices (id, user_id, name, pairing_code, status, screen_width, screen_height, created_at) VALUES (?, ?, ?, ?, 'provisioning', ?, ?, ?)`).run(newId, userId, d.name, pairingCode, d.screen_width || null, d.screen_height || null, d.created_at || Math.floor(Date.now() / 1000)); 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++; stats.devices++;
} }
@ -299,7 +314,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
} }
} }
db.prepare(`INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, duration_sec, remote_url, thumbnail_path, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, 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)); 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++; stats.content++;
} }