mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Phase 2.2d: widgets.js scoped to workspace_id; import + widget-reference defense bundled
This commit is contained in:
parent
a4610e8d0d
commit
efce13e05d
|
|
@ -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++;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
Loading…
Reference in a new issue