mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Phase 2.2c: content_folders gets workspace_id (schema + backfill); folders.js scoped; content.js folder-move strict same-workspace
This commit is contained in:
parent
a5dbc5d665
commit
a4610e8d0d
|
|
@ -90,6 +90,9 @@ const migrations = [
|
|||
"ALTER TABLE video_wall_devices ADD COLUMN canvas_y REAL",
|
||||
"ALTER TABLE video_wall_devices ADD COLUMN canvas_width REAL",
|
||||
"ALTER TABLE video_wall_devices ADD COLUMN canvas_height REAL",
|
||||
// Phase 2.2c: content_folders gets workspace_id. Phase 1 missed this table.
|
||||
"ALTER TABLE content_folders ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_content_folders_workspace ON content_folders(workspace_id)",
|
||||
];
|
||||
for (const sql of migrations) {
|
||||
try { db.exec(sql); } catch (e) { /* already exists */ }
|
||||
|
|
@ -327,6 +330,46 @@ function migrateGroupSchedules() {
|
|||
|
||||
migrateGroupSchedules();
|
||||
|
||||
// Phase 2.2c migration: backfill content_folders.workspace_id from owner's
|
||||
// default workspace. The ALTER lives in the migrations array above; this
|
||||
// one-shot populates the column for any rows that pre-date it.
|
||||
const PHASE6_MIGRATION_ID = 'phase6_content_folders_workspace';
|
||||
|
||||
function migrateFolderWorkspaceIds() {
|
||||
const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE6_MIGRATION_ID);
|
||||
if (already) return;
|
||||
|
||||
// Check the column exists before trying to backfill. (Defensive: on a fresh
|
||||
// install the schema.sql defines content_folders without the column, the
|
||||
// ALTER above adds it, and we proceed; but if anything went sideways we
|
||||
// skip rather than throw.)
|
||||
const cols = db.prepare("PRAGMA table_info(content_folders)").all();
|
||||
if (!cols.some(c => c.name === 'workspace_id')) {
|
||||
console.warn('Phase 2.2c migration: content_folders.workspace_id column missing, skipping backfill');
|
||||
return;
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE content_folders SET workspace_id = (
|
||||
SELECT w.id FROM workspaces w
|
||||
JOIN workspace_members wm ON wm.workspace_id = w.id
|
||||
WHERE wm.user_id = content_folders.user_id
|
||||
ORDER BY wm.joined_at ASC LIMIT 1
|
||||
)
|
||||
WHERE workspace_id IS NULL AND user_id IS NOT NULL
|
||||
`);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
const result = stmt.run();
|
||||
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(PHASE6_MIGRATION_ID);
|
||||
return result.changes;
|
||||
});
|
||||
const changes = tx();
|
||||
if (changes > 0) console.log(`Phase 2.2c migration: backfilled workspace_id on ${changes} content_folders row(s).`);
|
||||
}
|
||||
|
||||
migrateFolderWorkspaceIds();
|
||||
|
||||
// Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000)
|
||||
function pruneTelemetry(deviceId) {
|
||||
db.prepare(`
|
||||
|
|
|
|||
|
|
@ -284,15 +284,16 @@ router.put('/:id', (req, res) => {
|
|||
}
|
||||
if (folder !== undefined) { updates.push('folder = ?'); values.push(folder || null); }
|
||||
if (folder_id !== undefined) {
|
||||
// Verify the destination folder belongs to the same user. Only superadmin gets
|
||||
// cross-user access — matches the policy in routes/folders.js so a plain "admin"
|
||||
// can't move content into a folder they can't see in GET /api/folders.
|
||||
// Phase 2.2c: target folder must live in the same workspace as the
|
||||
// content row being modified. Strict same-workspace check - no
|
||||
// platform_admin override, because cross-workspace folder references
|
||||
// break the isolation model. To move content across workspaces, switch
|
||||
// workspace first.
|
||||
if (folder_id) {
|
||||
const target = db.prepare('SELECT user_id FROM content_folders WHERE id = ?').get(folder_id);
|
||||
const target = db.prepare('SELECT workspace_id FROM content_folders WHERE id = ?').get(folder_id);
|
||||
if (!target) return res.status(400).json({ error: 'Invalid folder_id' });
|
||||
const isSuperadmin = PLATFORM_ROLES.includes(req.user.role);
|
||||
if (!isSuperadmin && target.user_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Cannot move content to another user\'s folder' });
|
||||
if (target.workspace_id !== content.workspace_id) {
|
||||
return res.status(403).json({ error: 'Cannot move content to a folder in another workspace' });
|
||||
}
|
||||
}
|
||||
updates.push('folder_id = ?');
|
||||
|
|
|
|||
|
|
@ -3,75 +3,92 @@ const router = express.Router();
|
|||
const { v4: uuidv4 } = require('uuid');
|
||||
const { db } = require('../db/database');
|
||||
const { PLATFORM_ROLES } = require('../middleware/auth');
|
||||
// Phase 2.2c: workspace-aware access. Mirrors devices.js / content.js.
|
||||
const { accessContext } = require('../lib/tenancy');
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
// Per-user folder cap. The route has no rate limit (multer doesn't go through the
|
||||
// global API limiter chain), so without a count cap a single account could insert
|
||||
// millions of rows. 100 is a generous ceiling for a real organisational hierarchy
|
||||
// — admins/superadmins are exempt because they may manage cross-user data.
|
||||
const MAX_FOLDERS_PER_USER = 100;
|
||||
// Per-workspace folder cap. The route has no rate limit (multer doesn't go
|
||||
// through the global API limiter chain), so without a count cap a workspace
|
||||
// could insert millions of rows. 100 is generous for a real org hierarchy.
|
||||
const MAX_FOLDERS_PER_WORKSPACE = 100;
|
||||
|
||||
// Verify a folder belongs to the current user (or null = root, also allowed).
|
||||
// Returns the row, or null if it exists but isn't owned by the user.
|
||||
// Resolve a folder and the caller's access to its workspace. Returns:
|
||||
// { row, ctx } - access granted; ctx.workspaceRole / ctx.actingAs available
|
||||
// { row: { id: null } } - root (no folder id supplied) - always accessible
|
||||
// null - folder not found or no access
|
||||
//
|
||||
// Only superadmin gets cross-user access — matching the GET /api/folders listing
|
||||
// (which has always been superadmin-only). The previous mismatch let a regular
|
||||
// "admin" mutate folders they couldn't see, so the inconsistency was exploitable.
|
||||
function ownedFolder(req, folderId) {
|
||||
if (!folderId) return { id: null };
|
||||
// Platform-template folders (workspace_id IS NULL) are readable by anyone.
|
||||
// Writable only by platform_admin (same shape as content.js).
|
||||
function accessibleFolder(req, folderId, requireWrite = false) {
|
||||
if (!folderId) return { row: { id: null }, ctx: null };
|
||||
if (!UUID_RE.test(folderId)) return null;
|
||||
const row = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folderId);
|
||||
if (!row) return null;
|
||||
const isSuperadmin = PLATFORM_ROLES.includes(req.user.role);
|
||||
if (!isSuperadmin && row.user_id !== req.user.id) return null;
|
||||
return row;
|
||||
|
||||
// Platform-template path
|
||||
if (!row.workspace_id) {
|
||||
if (requireWrite && !PLATFORM_ROLES.includes(req.user.role)) return null;
|
||||
return { row, ctx: null };
|
||||
}
|
||||
|
||||
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(row.workspace_id);
|
||||
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
||||
if (!ctx) return null;
|
||||
if (requireWrite && !ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') return null;
|
||||
return { row, ctx };
|
||||
}
|
||||
|
||||
// List folders for the current user. Returns the full tree as a flat array;
|
||||
// the client builds the hierarchy from parent_id.
|
||||
// List folders accessible to the caller in their current workspace.
|
||||
// Includes platform-template folders (workspace_id IS NULL) for everyone.
|
||||
router.get('/', (req, res) => {
|
||||
const isAdmin = PLATFORM_ROLES.includes(req.user.role);
|
||||
const rows = isAdmin
|
||||
? db.prepare('SELECT * FROM content_folders ORDER BY name COLLATE NOCASE').all()
|
||||
: db.prepare('SELECT * FROM content_folders WHERE user_id = ? ORDER BY name COLLATE NOCASE').all(req.user.id);
|
||||
if (!req.workspaceId) return res.json([]);
|
||||
const rows = db.prepare(
|
||||
'SELECT * FROM content_folders WHERE (workspace_id = ? OR workspace_id IS NULL) ORDER BY name COLLATE NOCASE'
|
||||
).all(req.workspaceId);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// Create a folder.
|
||||
// Create a folder 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 folders.' });
|
||||
const name = (req.body.name || '').trim();
|
||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||
if (name.length > 100) return res.status(400).json({ error: 'name too long' });
|
||||
|
||||
const isSuperadmin = PLATFORM_ROLES.includes(req.user.role);
|
||||
if (!isSuperadmin) {
|
||||
const { count } = db.prepare('SELECT COUNT(*) AS count FROM content_folders WHERE user_id = ?').get(req.user.id);
|
||||
if (count >= MAX_FOLDERS_PER_USER) {
|
||||
// Per-workspace cap. Platform_admin exempt (cross-workspace admin tooling).
|
||||
if (!PLATFORM_ROLES.includes(req.user.role)) {
|
||||
const { count } = db.prepare('SELECT COUNT(*) AS count FROM content_folders WHERE workspace_id = ?').get(req.workspaceId);
|
||||
if (count >= MAX_FOLDERS_PER_WORKSPACE) {
|
||||
return res.status(429).json({
|
||||
error: `Folder limit reached (${MAX_FOLDERS_PER_USER}). Delete unused folders before creating more.`
|
||||
error: `Folder limit reached (${MAX_FOLDERS_PER_WORKSPACE}). Delete unused folders before creating more.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const parentId = req.body.parent_id || null;
|
||||
if (parentId) {
|
||||
const parent = ownedFolder(req, parentId);
|
||||
if (!parent || parent.id === null) return res.status(400).json({ error: 'Invalid parent_id' });
|
||||
const parent = accessibleFolder(req, parentId, true);
|
||||
if (!parent || parent.row.id === null) return res.status(400).json({ error: 'Invalid parent_id' });
|
||||
// Parent must be in the same workspace as the new folder.
|
||||
if (parent.row.workspace_id !== req.workspaceId) {
|
||||
return res.status(400).json({ error: 'Parent folder is in a different workspace' });
|
||||
}
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
db.prepare(
|
||||
'INSERT INTO content_folders (id, user_id, parent_id, name) VALUES (?, ?, ?, ?)'
|
||||
).run(id, req.user.id, parentId, name);
|
||||
'INSERT INTO content_folders (id, user_id, workspace_id, parent_id, name) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(id, req.user.id, req.workspaceId, parentId, name);
|
||||
|
||||
res.status(201).json(db.prepare('SELECT * FROM content_folders WHERE id = ?').get(id));
|
||||
});
|
||||
|
||||
// Rename / move a folder.
|
||||
router.put('/:id', (req, res) => {
|
||||
const folder = ownedFolder(req, req.params.id);
|
||||
if (!folder || folder.id === null) return res.status(404).json({ error: 'Folder not found' });
|
||||
const access = accessibleFolder(req, req.params.id, true);
|
||||
if (!access || access.row.id === null) return res.status(404).json({ error: 'Folder not found' });
|
||||
const folder = access.row;
|
||||
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
|
@ -88,10 +105,14 @@ router.put('/:id', (req, res) => {
|
|||
const newParent = req.body.parent_id || null;
|
||||
if (newParent === folder.id) return res.status(400).json({ error: 'Folder cannot be its own parent' });
|
||||
if (newParent) {
|
||||
const parent = ownedFolder(req, newParent);
|
||||
if (!parent || parent.id === null) return res.status(400).json({ error: 'Invalid parent_id' });
|
||||
const parent = accessibleFolder(req, newParent, true);
|
||||
if (!parent || parent.row.id === null) return res.status(400).json({ error: 'Invalid parent_id' });
|
||||
// New parent must be in the same workspace as this folder.
|
||||
if (parent.row.workspace_id !== folder.workspace_id) {
|
||||
return res.status(400).json({ error: 'Cannot move folder to a parent in another workspace' });
|
||||
}
|
||||
// Reject cycles: walk up from the new parent and ensure we never hit this folder.
|
||||
let cursor = parent;
|
||||
let cursor = parent.row;
|
||||
const seen = new Set([folder.id]);
|
||||
while (cursor && cursor.parent_id) {
|
||||
if (seen.has(cursor.parent_id)) {
|
||||
|
|
@ -115,10 +136,10 @@ router.put('/:id', (req, res) => {
|
|||
// Delete a folder. Content inside it falls back to root via ON DELETE SET NULL.
|
||||
// Subfolders cascade-delete; if the user wants to keep them they should move them first.
|
||||
router.delete('/:id', (req, res) => {
|
||||
const folder = ownedFolder(req, req.params.id);
|
||||
if (!folder || folder.id === null) return res.status(404).json({ error: 'Folder not found' });
|
||||
const access = accessibleFolder(req, req.params.id, true);
|
||||
if (!access || access.row.id === null) return res.status(404).json({ error: 'Folder not found' });
|
||||
|
||||
db.prepare('DELETE FROM content_folders WHERE id = ?').run(folder.id);
|
||||
db.prepare('DELETE FROM content_folders WHERE id = ?').run(access.row.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue