Files
dobetternorge-tools/api/dashboard/preview.php
T
daveadmin 2e2b0b45fa Full DMS: folders + ACLs, versioning, trash, bulk ops, preview, smart folders
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>
2026-05-26 22:24:56 +02:00

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;