getMessage(), $e->status, $e->errorCode); } $clientId = (int)$tenant['client_id']; $corpusId = (int)$tenant['corpus_id']; $clientUser = (int)($tenant['client_user_id'] ?? 0); $tenantRole = (string)($tenant['role'] ?? 'editor'); $db = dbnToolsDb(); $method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); $action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list_tree')); try { switch ($action) { case 'list_tree': dbnToolsRequireMethod('GET'); respondTree($db, $clientId, $corpusId, $clientUser, $tenantRole); break; case 'get_breadcrumb': dbnToolsRequireMethod('GET'); $fid = (int)($_GET['folder_id'] ?? 0); dbnToolsRespond(['ok' => true, 'breadcrumb' => dbnDmsBreadcrumb($fid ?: null, $clientId)]); break; case 'create': dbnToolsRequireMethod('POST'); respondCreate($db, $clientId, $corpusId, $clientUser, $tenantRole); break; case 'rename': dbnToolsRequireMethod('POST'); respondRename($db, $clientId, $clientUser, $tenantRole); break; case 'recolor': dbnToolsRequireMethod('POST'); respondRecolor($db, $clientId, $clientUser, $tenantRole); break; case 'move': dbnToolsRequireMethod('POST'); respondMove($db, $clientId, $clientUser, $tenantRole); break; case 'delete': dbnToolsRequireMethod('POST'); respondDelete($db, $clientId, $clientUser, $tenantRole); break; case 'list_permissions': dbnToolsRequireMethod('GET'); respondListPermissions($db, $clientId, $clientUser, $tenantRole); break; case 'set_permission': dbnToolsRequireMethod('POST'); respondSetPermission($db, $clientId, $clientUser, $tenantRole); break; case 'remove_permission': dbnToolsRequireMethod('POST'); respondRemovePermission($db, $clientId, $clientUser, $tenantRole); break; default: dbnToolsError('Unknown action.', 400, 'unknown_action'); } } catch (DbnToolsHttpException $e) { dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra); } catch (Throwable $e) { error_log('[dbn-dms/folders] ' . $e->getMessage()); dbnToolsError('Folder operation failed.', 500, 'folder_op_failed'); } function respondTree(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): void { $stmt = $db->prepare( "SELECT f.id, f.parent_id, f.name, f.slug, f.color, f.description, f.sort_order, f.created_at, COALESCE(c.cnt, 0) AS doc_count FROM client_folders f LEFT JOIN ( SELECT folder_id, COUNT(*) AS cnt FROM client_documents WHERE client_id = ? AND deleted_at IS NULL GROUP BY folder_id ) c ON c.folder_id = f.id WHERE f.client_id = ? AND f.corpus_id = ? AND f.deleted_at IS NULL ORDER BY f.sort_order ASC, f.name ASC" ); $stmt->execute([$clientId, $clientId, $corpusId]); $rows = $stmt->fetchAll(); // Filter by read ACL. $visible = []; foreach ($rows as $row) { if (dbnDmsUserCanAccessFolder((int)$row['id'], 'read', $clientId, $userId, $tenantRole)) { $visible[(int)$row['id']] = [ 'id' => (int)$row['id'], 'parent_id' => $row['parent_id'] ? (int)$row['parent_id'] : null, 'name' => (string)$row['name'], 'slug' => (string)$row['slug'], 'color' => $row['color'] ?? null, 'description'=> $row['description'] ?? null, 'sort_order' => (int)$row['sort_order'], 'doc_count' => (int)$row['doc_count'], 'children' => [], ]; } } // Tree assembly. $roots = []; foreach ($visible as $id => &$node) { $pid = $node['parent_id']; if ($pid && isset($visible[$pid])) { $visible[$pid]['children'][] = &$node; } else { $roots[] = &$node; } } unset($node); // Unassigned bucket count. $unassigned = $db->prepare( 'SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND folder_id IS NULL AND deleted_at IS NULL' ); $unassigned->execute([$clientId]); // Trash count. $trash = $db->prepare( 'SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND deleted_at IS NOT NULL' ); $trash->execute([$clientId]); dbnToolsRespond([ 'ok' => true, 'tree' => $roots, 'unassigned_count' => (int)$unassigned->fetchColumn(), 'trash_count' => (int)$trash->fetchColumn(), 'max_depth' => DBN_DMS_MAX_FOLDER_DEPTH, ]); } function respondCreate(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): void { $input = dbnToolsJsonInput(20_000); $name = trim((string)($input['name'] ?? '')); $parentId = isset($input['parent_id']) && $input['parent_id'] !== null && $input['parent_id'] !== '' ? (int)$input['parent_id'] : null; $color = trim((string)($input['color'] ?? '')); $desc = trim((string)($input['description'] ?? '')); if ($name === '' || mb_strlen($name, 'UTF-8') > 200) { dbnToolsError('Folder name is required (1–200 chars).', 422, 'invalid_name'); } if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) { dbnToolsError('Color must be a #RRGGBB hex value.', 422, 'invalid_color'); } if (mb_strlen($desc, 'UTF-8') > 1000) { dbnToolsError('Description is too long (max 1000 chars).', 422, 'description_too_long'); } $parentDepth = dbnDmsFolderDepth($parentId, $clientId); if ($parentDepth + 1 > DBN_DMS_MAX_FOLDER_DEPTH) { dbnToolsError("Folder depth limit reached (max " . DBN_DMS_MAX_FOLDER_DEPTH . " levels).", 422, 'depth_exceeded'); } if (!dbnDmsUserCanAccessFolder($parentId, 'manage', $clientId, $userId, $tenantRole)) { dbnToolsError('You do not have permission to create folders here.', 403, 'forbidden'); } if ($parentId !== null) { $parentCheck = $db->prepare('SELECT id FROM client_folders WHERE id = ? AND client_id = ? AND deleted_at IS NULL'); $parentCheck->execute([$parentId, $clientId]); if (!$parentCheck->fetchColumn()) { dbnToolsError('Parent folder not found.', 404, 'parent_not_found'); } } $slug = dbnDmsUniqueSlug($db, $clientId, $corpusId, $name); $stmt = $db->prepare( 'INSERT INTO client_folders (client_id, corpus_id, parent_id, name, slug, description, color, sort_order, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, NOW())' ); $stmt->execute([ $clientId, $corpusId, $parentId, $name, $slug, $desc !== '' ? $desc : null, $color !== '' ? $color : null, $userId ?: null, ]); $id = (int)$db->lastInsertId(); dbnDmsLogAudit($clientId, $userId ?: null, 'folder_create', ['name' => $name, 'parent_id' => $parentId], null, $id); dbnToolsRespond(['ok' => true, 'folder_id' => $id, 'slug' => $slug], 201); } function respondRename(PDO $db, int $clientId, int $userId, string $tenantRole): void { $input = dbnToolsJsonInput(10_000); $fid = (int)($input['folder_id'] ?? 0); $name = trim((string)($input['name'] ?? '')); if ($fid <= 0 || $name === '' || mb_strlen($name, 'UTF-8') > 200) { dbnToolsError('folder_id and a valid name (1–200) are required.', 422, 'invalid_input'); } if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) { dbnToolsError('You do not have permission to rename this folder.', 403, 'forbidden'); } $stmt = $db->prepare('UPDATE client_folders SET name = ?, updated_at = NOW() WHERE id = ? AND client_id = ?'); $stmt->execute([$name, $fid, $clientId]); dbnDmsLogAudit($clientId, $userId ?: null, 'folder_rename', ['name' => $name], null, $fid); dbnToolsRespond(['ok' => true]); } function respondRecolor(PDO $db, int $clientId, int $userId, string $tenantRole): void { $input = dbnToolsJsonInput(2_000); $fid = (int)($input['folder_id'] ?? 0); $color = trim((string)($input['color'] ?? '')); if ($fid <= 0) { dbnToolsError('folder_id is required.', 422, 'invalid_input'); } if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) { dbnToolsError('Color must be a #RRGGBB hex value.', 422, 'invalid_color'); } if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) { dbnToolsError('Forbidden.', 403, 'forbidden'); } $stmt = $db->prepare('UPDATE client_folders SET color = ?, updated_at = NOW() WHERE id = ? AND client_id = ?'); $stmt->execute([$color !== '' ? $color : null, $fid, $clientId]); dbnToolsRespond(['ok' => true]); } function respondMove(PDO $db, int $clientId, int $userId, string $tenantRole): void { $input = dbnToolsJsonInput(5_000); $fid = (int)($input['folder_id'] ?? 0); $parentId = isset($input['parent_id']) && $input['parent_id'] !== null && $input['parent_id'] !== '' ? (int)$input['parent_id'] : null; if ($fid <= 0) { dbnToolsError('folder_id is required.', 422, 'invalid_input'); } if ($parentId !== null && $parentId === $fid) { dbnToolsError('Folder cannot be its own parent.', 422, 'invalid_parent'); } if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) { dbnToolsError('Forbidden on source.', 403, 'forbidden_source'); } if ($parentId !== null && !dbnDmsUserCanAccessFolder($parentId, 'manage', $clientId, $userId, $tenantRole)) { dbnToolsError('Forbidden on destination.', 403, 'forbidden_dest'); } // Cycle + depth checks. if ($parentId !== null) { $chain = dbnDmsFolderChain($parentId, $clientId); foreach ($chain as $c) { if ((int)$c['id'] === $fid) { dbnToolsError('Cannot move a folder into one of its own descendants.', 422, 'invalid_cycle'); } } $newDepth = count($chain) + 1; // +1 for the moved folder itself $childDepth = dbnDmsSubtreeMaxDepth($db, $fid); if ($newDepth + ($childDepth - 1) > DBN_DMS_MAX_FOLDER_DEPTH) { dbnToolsError('Move would exceed max folder depth.', 422, 'depth_exceeded'); } } $stmt = $db->prepare('UPDATE client_folders SET parent_id = ?, updated_at = NOW() WHERE id = ? AND client_id = ?'); $stmt->execute([$parentId, $fid, $clientId]); dbnDmsLogAudit($clientId, $userId ?: null, 'folder_move', ['parent_id' => $parentId], null, $fid); dbnToolsRespond(['ok' => true]); } function respondDelete(PDO $db, int $clientId, int $userId, string $tenantRole): void { $input = dbnToolsJsonInput(5_000); $fid = (int)($input['folder_id'] ?? 0); if ($fid <= 0) { dbnToolsError('folder_id is required.', 422, 'invalid_input'); } if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) { dbnToolsError('Forbidden.', 403, 'forbidden'); } // Soft delete folder + cascade soft-delete on descendant folders. $db->beginTransaction(); try { $allIds = dbnDmsCollectSubtreeIds($db, $fid, $clientId); $allIds[] = $fid; $placeholders = implode(',', array_fill(0, count($allIds), '?')); $stmt = $db->prepare( "UPDATE client_folders SET deleted_at = NOW(), deleted_by = ? WHERE client_id = ? AND id IN ({$placeholders})" ); $stmt->execute(array_merge([$userId ?: null, $clientId], $allIds)); // Documents inside: also soft-delete (they appear in Trash). $docStmt = $db->prepare( "UPDATE client_documents SET deleted_at = NOW(), deleted_by = ? WHERE client_id = ? AND folder_id IN ({$placeholders}) AND deleted_at IS NULL" ); $docStmt->execute(array_merge([$userId ?: null, $clientId], $allIds)); $db->commit(); } catch (Throwable $e) { $db->rollBack(); throw $e; } dbnDmsLogAudit($clientId, $userId ?: null, 'folder_delete', [], null, $fid); dbnToolsRespond(['ok' => true]); } function respondListPermissions(PDO $db, int $clientId, int $userId, string $tenantRole): void { $fid = (int)($_GET['folder_id'] ?? 0); if ($fid <= 0) { dbnToolsError('folder_id is required.', 422, 'invalid_input'); } if (!dbnDmsUserCanAccessFolder($fid, 'read', $clientId, $userId, $tenantRole)) { dbnToolsError('Forbidden.', 403, 'forbidden'); } $stmt = $db->prepare( "SELECT p.id, p.folder_id, p.min_role, p.user_id, p.can_read, p.can_write, p.can_manage, p.created_at, u.email AS user_email, u.display_name AS user_name FROM client_folder_permissions p LEFT JOIN client_users u ON u.id = p.user_id WHERE p.folder_id = ? AND p.client_id = ? ORDER BY p.id ASC" ); $stmt->execute([$fid, $clientId]); dbnToolsRespond(['ok' => true, 'permissions' => $stmt->fetchAll()]); } function respondSetPermission(PDO $db, int $clientId, int $userId, string $tenantRole): void { $input = dbnToolsJsonInput(10_000); $fid = (int)($input['folder_id'] ?? 0); $minRole = trim((string)($input['min_role'] ?? '')); $targetUid= isset($input['user_id']) && $input['user_id'] ? (int)$input['user_id'] : null; $canRead = !empty($input['can_read']) ? 1 : 0; $canWrite = !empty($input['can_write']) ? 1 : 0; $canManage= !empty($input['can_manage']) ? 1 : 0; if ($fid <= 0) { dbnToolsError('folder_id is required.', 422, 'invalid_input'); } if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) { dbnToolsError('Forbidden.', 403, 'forbidden'); } $validRoles = ['viewer','editor','admin','owner']; if ($minRole !== '' && !in_array($minRole, $validRoles, true)) { dbnToolsError('Invalid min_role.', 422, 'invalid_role'); } if (($minRole === '' && $targetUid === null) || ($minRole !== '' && $targetUid !== null)) { dbnToolsError('Exactly one of min_role or user_id must be set.', 422, 'invalid_grantee'); } // UPSERT on the appropriate unique key. if ($minRole !== '') { $stmt = $db->prepare( 'INSERT INTO client_folder_permissions (folder_id, client_id, min_role, can_read, can_write, can_manage, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE can_read = VALUES(can_read), can_write = VALUES(can_write), can_manage = VALUES(can_manage)' ); $stmt->execute([$fid, $clientId, $minRole, $canRead, $canWrite, $canManage, $userId ?: null]); } else { $stmt = $db->prepare( 'INSERT INTO client_folder_permissions (folder_id, client_id, user_id, can_read, can_write, can_manage, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE can_read = VALUES(can_read), can_write = VALUES(can_write), can_manage = VALUES(can_manage)' ); $stmt->execute([$fid, $clientId, $targetUid, $canRead, $canWrite, $canManage, $userId ?: null]); } dbnDmsLogAudit($clientId, $userId ?: null, 'folder_acl_set', [ 'min_role' => $minRole ?: null, 'user_id' => $targetUid, 'can_read' => $canRead, 'can_write' => $canWrite, 'can_manage' => $canManage, ], null, $fid); dbnToolsRespond(['ok' => true]); } function respondRemovePermission(PDO $db, int $clientId, int $userId, string $tenantRole): void { $input = dbnToolsJsonInput(2_000); $pid = (int)($input['permission_id'] ?? 0); if ($pid <= 0) { dbnToolsError('permission_id is required.', 422, 'invalid_input'); } // Look up the folder to ACL-check. $row = $db->prepare('SELECT folder_id FROM client_folder_permissions WHERE id = ? AND client_id = ?'); $row->execute([$pid, $clientId]); $fid = (int)$row->fetchColumn(); if (!$fid) { dbnToolsError('Permission not found.', 404, 'not_found'); } if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) { dbnToolsError('Forbidden.', 403, 'forbidden'); } $del = $db->prepare('DELETE FROM client_folder_permissions WHERE id = ? AND client_id = ?'); $del->execute([$pid, $clientId]); dbnDmsLogAudit($clientId, $userId ?: null, 'folder_acl_remove', ['permission_id' => $pid], null, $fid); dbnToolsRespond(['ok' => true]); } function dbnDmsCollectSubtreeIds(PDO $db, int $rootId, int $clientId): array { $collected = []; $stack = [$rootId]; $guard = 0; while ($stack && $guard++ < 1000) { $batch = $stack; $stack = []; $placeholders = implode(',', array_fill(0, count($batch), '?')); $stmt = $db->prepare( "SELECT id FROM client_folders WHERE client_id = ? AND parent_id IN ({$placeholders}) AND deleted_at IS NULL" ); $stmt->execute(array_merge([$clientId], $batch)); foreach ($stmt->fetchAll() as $row) { $cid = (int)$row['id']; $collected[] = $cid; $stack[] = $cid; } } return $collected; } function dbnDmsSubtreeMaxDepth(PDO $db, int $rootId): int { // Depth of the subtree starting at rootId, where rootId itself counts as 1. $depth = 1; $current = [$rootId]; $guard = 0; while ($current && $guard++ < 20) { $placeholders = implode(',', array_fill(0, count($current), '?')); $stmt = $db->prepare( "SELECT id FROM client_folders WHERE parent_id IN ({$placeholders}) AND deleted_at IS NULL" ); $stmt->execute($current); $next = array_map(fn($r) => (int)$r['id'], $stmt->fetchAll()); if (!$next) { break; } $current = $next; $depth++; } return $depth; } function dbnDmsUniqueSlug(PDO $db, int $clientId, int $corpusId, string $name): string { $base = strtolower(trim($name)); $base = preg_replace('/[^a-z0-9]+/u', '-', $base) ?: 'folder'; $base = trim($base, '-'); if ($base === '') { $base = 'folder'; } $base = substr($base, 0, 180); $slug = $base; $check = $db->prepare('SELECT 1 FROM client_folders WHERE client_id = ? AND corpus_id = ? AND slug = ? LIMIT 1'); $n = 2; while (true) { $check->execute([$clientId, $corpusId, $slug]); if (!$check->fetchColumn()) { return $slug; } $slug = $base . '-' . $n++; if ($n > 999) { return $base . '-' . substr(bin2hex(random_bytes(3)), 0, 6); } } }