mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Phase 2.2b: content.js + status.js import scoped to workspace_id; uploads stamp workspace_id
This commit is contained in:
parent
afd2a10df2
commit
a5dbc5d665
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue