Phase 2.2d: widgets.js scoped to workspace_id; import + widget-reference defense bundled

This commit is contained in:
ScreenTinker 2026-05-11 21:13:51 -05:00
parent a4610e8d0d
commit efce13e05d
3 changed files with 57 additions and 48 deletions

View file

@ -323,7 +323,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
const newId = uuid.v4();
idMap.widgets[w.id] = newId;
const config = typeof w.config === 'string' ? w.config : JSON.stringify(w.config || {});
db.prepare(`INSERT INTO widgets (id, user_id, widget_type, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, w.widget_type, w.name, config, w.created_at || Math.floor(Date.now() / 1000));
db.prepare(`INSERT INTO widgets (id, user_id, workspace_id, widget_type, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, w.widget_type, w.name, config, w.created_at || Math.floor(Date.now() / 1000));
stats.widgets++;
}

View file

@ -6,16 +6,23 @@ const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const appConfig = require('../config');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2d: workspace-aware access. Same pattern as devices.js / content.js.
const { accessContext } = require('../lib/tenancy');
// For preview only: inline /api/content/:id/file and /thumbnail URLs as data URIs,
// scoped to the current user. Lets the srcdoc preview iframe show logos/bg images
// before the widget is saved (post-save they're reachable via the widget-reference gate).
// scoped to the caller's current workspace. Lets the srcdoc preview iframe show
// logos/bg images before the widget is saved (post-save they're reachable via
// the widget-reference gate).
const MAX_INLINE_BYTES = 10 * 1024 * 1024; // 10MB cap — base64 expands ~1.33x
const MIME_RE = /^image\/[a-zA-Z0-9.+-]+$/;
function inlineUserContent(html, userId) {
function inlineUserContent(html, workspaceId) {
if (!workspaceId) return html;
return html.replace(/\/api\/content\/([a-f0-9-]+)\/(file|thumbnail)/gi, (match, id, kind) => {
const c = db.prepare('SELECT filepath, thumbnail_path, mime_type, user_id FROM content WHERE id = ?').get(id);
if (!c || c.user_id !== userId) return match;
const c = db.prepare('SELECT filepath, thumbnail_path, mime_type, workspace_id FROM content WHERE id = ?').get(id);
// Inline content only when it lives in the caller's workspace, or is a
// platform-template row (workspace_id IS NULL) shared with everyone.
if (!c) return match;
if (c.workspace_id && c.workspace_id !== workspaceId) return match;
const filename = kind === 'thumbnail' ? c.thumbnail_path : c.filepath;
if (!filename) return match;
const mime = kind === 'thumbnail' ? 'image/jpeg' : c.mime_type;
@ -58,71 +65,72 @@ function safeUrl(url) {
} catch { return 'about:blank'; }
}
// List widgets.
// Visibility model:
// superadmin: all widgets
// admin: own + public (null owner) + widgets owned by members of teams
// this admin owns (matches /auth/users visibility)
// user: own + public (null owner)
// List widgets accessible to the caller's current workspace, plus any
// platform-template rows (workspace_id IS NULL) shared with all workspaces.
// Phase 2.2d: workspace-scoped. Cross-workspace visibility comes from
// switch-workspace, not a special list branch.
router.get('/', (req, res) => {
if (PLATFORM_ROLES.includes(req.user.role)) {
const widgets = db.prepare('SELECT * FROM widgets ORDER BY created_at DESC').all();
return res.json(widgets);
}
if (req.user.role === 'admin') {
const widgets = db.prepare(`
SELECT DISTINCT w.* FROM widgets w
LEFT JOIN team_members tm_target ON w.user_id = tm_target.user_id
LEFT JOIN team_members tm_admin
ON tm_admin.team_id = tm_target.team_id
AND tm_admin.user_id = ?
AND tm_admin.role = 'owner'
WHERE w.user_id = ?
OR w.user_id IS NULL
OR tm_admin.team_id IS NOT NULL
ORDER BY w.created_at DESC
`).all(req.user.id, req.user.id);
return res.json(widgets);
}
if (!req.workspaceId) return res.json([]);
const widgets = db.prepare(
'SELECT * FROM widgets WHERE user_id = ? OR user_id IS NULL ORDER BY created_at DESC'
).all(req.user.id);
'SELECT * FROM widgets WHERE (workspace_id = ? OR workspace_id IS NULL) ORDER BY created_at DESC'
).all(req.workspaceId);
res.json(widgets);
});
// Create widget
// Create widget in the caller's current workspace.
router.post('/', (req, res) => {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating widgets.' });
const { widget_type, name, config } = req.body;
if (!widget_type || !name) return res.status(400).json({ error: 'widget_type and name required' });
const id = uuidv4();
db.prepare('INSERT INTO widgets (id, user_id, widget_type, name, config) VALUES (?, ?, ?, ?, ?)')
.run(id, req.user.id, widget_type, name, JSON.stringify(config || {}));
db.prepare('INSERT INTO widgets (id, user_id, workspace_id, widget_type, name, config) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, req.user.id, req.workspaceId, widget_type, name, JSON.stringify(config || {}));
res.status(201).json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(id));
});
// Helper: check widget ownership
function checkWidgetAccess(req, res) {
// Phase 2.2d: workspace-aware access. Mirrors the device/content pattern.
// Platform-template widgets (workspace_id IS NULL) are readable by anyone
// authenticated and writable only by platform_admin.
function checkWidgetRead(req, res) {
const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
if (!widget) { res.status(404).json({ error: 'Widget not found' }); return null; }
// Allow access if: admin, owner, no owner (public), or render route (no req.user)
if (req.user && !ELEVATED_ROLES.includes(req.user.role) && widget.user_id && widget.user_id !== req.user.id) {
res.status(403).json({ error: 'Access denied' }); return null;
if (!widget.workspace_id) return widget;
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(widget.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 widget;
}
function checkWidgetWrite(req, res) {
const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
if (!widget) { res.status(404).json({ error: 'Widget not found' }); return null; }
if (!widget.workspace_id) {
if (!PLATFORM_ROLES.includes(req.user.role)) {
res.status(403).json({ error: 'Platform admin required to modify shared widgets' }); return null;
}
return widget;
}
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(widget.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
res.status(403).json({ error: 'Read-only access' }); return null;
}
return widget;
}
// Get widget
router.get('/:id', (req, res) => {
const widget = checkWidgetAccess(req, res);
const widget = checkWidgetRead(req, res);
if (!widget) return;
res.json(widget);
});
// Update widget
router.put('/:id', (req, res) => {
const widget = checkWidgetAccess(req, res);
const widget = checkWidgetWrite(req, res);
if (!widget) return;
const { name, config } = req.body;
@ -134,7 +142,7 @@ router.put('/:id', (req, res) => {
// Delete widget
router.delete('/:id', (req, res) => {
const widget = checkWidgetAccess(req, res);
const widget = checkWidgetWrite(req, res);
if (!widget) return;
db.prepare('DELETE FROM widgets WHERE id = ?').run(req.params.id);
res.json({ success: true });
@ -170,7 +178,7 @@ router.post('/preview', (req, res) => {
if (!widget_type || typeof widget_type !== 'string') return res.status(400).json({ error: 'widget_type required' });
if (!KNOWN_WIDGET_TYPES.has(widget_type)) return res.status(400).json({ error: 'Unknown widget_type' });
let html = renderWidgetHtml(widget_type, config || {});
if (req.user && req.user.id) html = inlineUserContent(html, req.user.id);
if (req.workspaceId) html = inlineUserContent(html, req.workspaceId);
res.setHeader('Content-Type', 'text/html');
res.send(html);
});

View file

@ -273,11 +273,12 @@ app.get('/api/content/:id/file', (req, res) => {
if (!content) return res.status(404).json({ error: 'Content not found' });
if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' });
const inPlaylist = db.prepare('SELECT id FROM playlist_items WHERE content_id = ? LIMIT 1').get(req.params.id);
// Scope widget lookup to content owner's widgets only — prevents a user from unlocking
// another user's content by creating their own widget that references the UUID.
// Scope widget lookup to widgets in the content's workspace — prevents a user
// in another workspace from unlocking this content by creating a widget that
// references the UUID. Phase 2.2d: keyed off content.workspace_id (was user_id).
// Perf note: LIKE scan on widgets.config is O(n) per request. Fine at current scale
// (<100 widgets); revisit with a content_widget_refs join table if this grows.
const inWidget = inPlaylist ? null : db.prepare('SELECT id FROM widgets WHERE user_id = ? AND config LIKE ? LIMIT 1').get(content.user_id, `%/api/content/${req.params.id}/%`);
const inWidget = inPlaylist ? null : db.prepare('SELECT id FROM widgets WHERE workspace_id = ? AND config LIKE ? LIMIT 1').get(content.workspace_id, `%/api/content/${req.params.id}/%`);
if (!inPlaylist && !inWidget) return res.status(403).json({ error: 'Content not assigned to any playlist or widget' });
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' });