2e2b0b45fa
Rebuild the dashboard as a Drive-style document management system on top of the existing CaveauAI hybrid RAG pipeline. Backend: - 5 migrations (versions, trash soft-delete, saved searches, categories, audit) - DMS helpers (folder ACL walker, disk storage, audit, version snapshot, XLSX/PPTX/HTML/CSV/MD extractors) - New APIs: folders, document-versions, trash, bulk, preview, saved-searches, categories, diagnostics - Extended APIs: documents (folder_id, soft-delete, ACL filter, sort), upload (9 file types, version-collision detection with replace/new/keep-both, disk persistence), chat-stream (folder scoping + graph related-documents) - 30-day trash purge cron with Qdrant + disk + graph cleanup Frontend: - Drive-style two-pane browser with folder tree, drag-drop, bulk-action bar, right-click context menu, multi-select - New pages: folders (tree + per-folder ACL editor), trash (restore/purge) - Extended pages: upload (folder picker, version-collision modal, 9 file type chips), document (Preview/Versions/Permissions tabs with PDF.js + mammoth.js + audio), index (DMS KPIs + activity feed), settings (live diagnostics ping MariaDB/Qdrant/LiteLLM/FalkorDB/disk), chat (folder scope chips + related-authorities chips) - New CSS (dms.css) + JS bundle (dms.js) exposing window.DBN_DMS - Sidebar nav adds Folders + Trash items All routes return HTTP 200 in local smoke test; all 32 files lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
119 lines
4.0 KiB
PHP
119 lines
4.0 KiB
PHP
<?php
|
|
/**
|
|
* /api/dashboard/preview.php?id=123[&version_id=5][&download=1]
|
|
*
|
|
* Streams the on-disk original file for a document, with the correct
|
|
* Content-Type. Used by PDF.js, the <audio> player, image previews, and
|
|
* "Download original" links.
|
|
*
|
|
* ACL: enforces folder read permission on the document.
|
|
*/
|
|
|
|
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'];
|
|
$userId = (int)($tenant['client_user_id'] ?? 0);
|
|
$tenantRole = (string)($tenant['role'] ?? 'editor');
|
|
|
|
$id = (int)($_GET['id'] ?? 0);
|
|
$versionId = (int)($_GET['version_id'] ?? 0);
|
|
$download = !empty($_GET['download']);
|
|
|
|
if ($id <= 0) {
|
|
dbnToolsError('id is required.', 400, 'missing_id');
|
|
}
|
|
|
|
$db = dbnToolsDb();
|
|
|
|
$stmt = $db->prepare('SELECT id, folder_id, storage_path, original_filename, source_type FROM client_documents WHERE id = ? AND client_id = ?');
|
|
$stmt->execute([$id, $clientId]);
|
|
$doc = $stmt->fetch();
|
|
if (!$doc) {
|
|
dbnToolsError('Document not found.', 404, 'not_found');
|
|
}
|
|
$fid = $doc['folder_id'] ? (int)$doc['folder_id'] : 0;
|
|
if (!dbnDmsUserCanAccessFolder($fid ?: null, 'read', $clientId, $userId, $tenantRole)) {
|
|
dbnToolsError('Forbidden.', 403, 'forbidden');
|
|
}
|
|
|
|
$path = (string)($doc['storage_path'] ?? '');
|
|
$filename = (string)($doc['original_filename'] ?? '');
|
|
|
|
if ($versionId > 0) {
|
|
$vs = $db->prepare('SELECT storage_path, original_filename FROM client_document_versions WHERE id = ? AND document_id = ? AND client_id = ?');
|
|
$vs->execute([$versionId, $id, $clientId]);
|
|
$ver = $vs->fetch();
|
|
if (!$ver) {
|
|
dbnToolsError('Version not found.', 404, 'version_not_found');
|
|
}
|
|
$path = (string)($ver['storage_path'] ?? '');
|
|
$filename = (string)($ver['original_filename'] ?? $filename);
|
|
}
|
|
|
|
if ($path === '' || !is_file($path) || !is_readable($path)) {
|
|
dbnToolsError('Original file is not available for this document.', 404, 'file_missing',
|
|
['hint' => 'Document predates disk storage, or file was purged.']);
|
|
}
|
|
|
|
$ext = dbnDmsExtensionFromFilename($filename);
|
|
if ($ext === '' && $path !== '') {
|
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
|
}
|
|
$contentType = dbnDmsContentTypeForExt($ext);
|
|
|
|
dbnDmsLogAudit($clientId, $userId ?: null, $download ? 'download' : 'preview',
|
|
['version_id' => $versionId ?: null, 'ext' => $ext], $id, $fid ?: null);
|
|
|
|
// Suppress any earlier output (defensive).
|
|
if (ob_get_level() > 0) { @ob_end_clean(); }
|
|
|
|
$size = filesize($path) ?: 0;
|
|
header('Content-Type: ' . $contentType);
|
|
header('Content-Length: ' . $size);
|
|
header('X-Content-Type-Options: nosniff');
|
|
header('Cache-Control: private, max-age=300');
|
|
$disposition = $download ? 'attachment' : 'inline';
|
|
$safeName = $filename !== '' ? $filename : ('document-' . $id . '.' . ($ext ?: 'bin'));
|
|
$safeName = preg_replace('/[\r\n"]/', '_', $safeName) ?? $safeName;
|
|
header(sprintf('Content-Disposition: %s; filename="%s"', $disposition, $safeName));
|
|
|
|
// Range requests (basic) — useful for PDF.js + audio scrubbing.
|
|
$range = $_SERVER['HTTP_RANGE'] ?? '';
|
|
if ($range && preg_match('/bytes=(\d+)-(\d+)?/', $range, $m)) {
|
|
$start = (int)$m[1];
|
|
$end = isset($m[2]) && $m[2] !== '' ? (int)$m[2] : ($size - 1);
|
|
if ($end >= $size) $end = $size - 1;
|
|
$length = $end - $start + 1;
|
|
http_response_code(206);
|
|
header('Accept-Ranges: bytes');
|
|
header("Content-Range: bytes {$start}-{$end}/{$size}");
|
|
header('Content-Length: ' . $length);
|
|
$fh = fopen($path, 'rb');
|
|
if ($fh) {
|
|
fseek($fh, $start);
|
|
$remaining = $length;
|
|
while (!feof($fh) && $remaining > 0) {
|
|
$chunk = fread($fh, min(8192, $remaining));
|
|
if ($chunk === false) break;
|
|
echo $chunk;
|
|
$remaining -= strlen($chunk);
|
|
@ob_flush(); @flush();
|
|
}
|
|
fclose($fh);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
header('Accept-Ranges: bytes');
|
|
readfile($path);
|
|
exit;
|