Files
dobetternorge-tools/api/dashboard/folders.php
T

494 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* /api/dashboard/folders.php — folder tree CRUD + per-folder ACLs.
*
* GET ?action=list_tree → { ok, tree: [...] }
* GET ?action=get_breadcrumb&folder_id=X → { ok, breadcrumb: [...] }
* POST ?action=create body: { parent_id?, name, color?, description? }
* POST ?action=rename body: { folder_id, name }
* POST ?action=recolor body: { folder_id, color }
* POST ?action=move body: { folder_id, parent_id|null }
* POST ?action=delete body: { folder_id } (soft delete — docs become Unassigned via SET NULL)
* POST ?action=set_permission body: { folder_id, min_role?, user_id?, can_read?, can_write?, can_manage? }
* POST ?action=remove_permission body: { permission_id }
* GET ?action=list_permissions&folder_id=X → { ok, permissions: [...] }
*
* Tenant-scoped via dbnToolsEnsureDashboardTenant().
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->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 (1200 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 (1200) 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);
}
}
}