* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\DocumentsManager\Controllers; use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\Response; use KTXC\Http\Response\StreamedResponse; use KTXC\SessionIdentity; use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; use KTXF\Documents\Collection\CollectionBaseInterface; use KTXF\Documents\Entity\EntityBaseInterface; use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\EntityIdentifier; use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXM\DocumentsManager\Manager; use KTXM\DocumentsManager\Transfer\StreamingZip; use Psr\Log\LoggerInterface; use Throwable; /** * Controller for file transfers (downloads and uploads) * * Handles binary file transfers that don't fit the JSON API pattern: * - Single file downloads (streamed) * - Multi-file downloads as ZIP (streamed) * - Folder downloads as ZIP with structure preserved (streamed) * - Large file uploads (future) * - ZIP file uploads with extraction (future) */ class TransferController extends ControllerAbstract { public function __construct( private readonly SessionTenant $tenantIdentity, private readonly SessionIdentity $userIdentity, private readonly Manager $manager, private readonly LoggerInterface $logger ) {} /** * Download a single file * * GET /download/entity/{provider}/{service}/{collection}/{identifier} */ #[AuthenticatedRoute( '/download/entity/{provider}/{service}/{collection}/{identifier}', name: 'document_manager.download.entity', methods: ['GET'] )] public function downloadEntity(string $provider, string $service, string $collection, string $identifier): Response { $tenantId = $this->tenantIdentity->identifier(); $userId = $this->userIdentity->identifier(); try { $target = new EntityIdentifier($provider, $service, $collection, $identifier); // Fetch entity metadata /** @var EntityBaseInterface[] $entities */ $entities = $this->manager->entityFetchBulk($tenantId, $userId, $target); if (empty($entities) || !isset($entities[$identifier])) { return new JsonResponse([ 'status' => 'error', 'error' => ['code' => 404, 'message' => 'File not found'] ], Response::HTTP_NOT_FOUND); } $entity = $entities[$identifier]; // Get the stream $stream = $this->manager->entityReadStream($tenantId, $userId, $target); if ($stream === null) { return new JsonResponse([ 'status' => 'error', 'error' => ['code' => 404, 'message' => 'File content not available'] ], Response::HTTP_NOT_FOUND); } $filename = $entity->getProperties()->getLabel() ?? 'download'; $mime = $entity->getProperties()->getMime() ?? 'application/octet-stream'; $size = $entity->getProperties()->size(); // Create streamed response $response = new StreamedResponse(function () use ($stream) { try { while (!feof($stream)) { echo fread($stream, 65536); @ob_flush(); flush(); } } finally { fclose($stream); } }); $response->headers->set('Content-Type', $mime); // Only advertise Content-Length when metadata is non-zero; a zero value // would cause clients to believe the file is empty and discard the body. if ($size > 0) { $response->headers->set('Content-Length', (string) $size); } $response->headers->set('Content-Disposition', $response->headers->makeDisposition('attachment', $filename, $this->asciiFallback($filename)) ); $response->headers->set('Cache-Control', 'private, no-cache'); return $response; } catch (Throwable $t) { $this->logger->error('Download failed', ['exception' => $t]); return new JsonResponse([ 'status' => 'error', 'error' => ['code' => $t->getCode(), 'message' => $t->getMessage()] ], Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Download multiple files as a ZIP archive * * GET /download/archive?provider=...&service=...&ids[]=...&ids[]=... */ #[AuthenticatedRoute( '/download/archive', name: 'manager.download.archive', methods: ['GET'] )] public function downloadArchive(string $provider, string $service, array $ids = [], ?string $collection = null, string $name = 'download'): Response { $tenantId = $this->tenantIdentity->identifier(); $userId = $this->userIdentity->identifier(); if (empty($ids)) { return new JsonResponse([ 'status' => 'error', 'error' => ['code' => 400, 'message' => 'No file IDs provided'] ], Response::HTTP_BAD_REQUEST); } try { // Build list of files to include $files = $this->resolveFilesForArchive( $tenantId, $userId, $provider, $service, $collection, $ids ); if (empty($files)) { return new JsonResponse([ 'status' => 'error', 'error' => ['code' => 404, 'message' => 'No files found'] ], Response::HTTP_NOT_FOUND); } $archiveName = $this->sanitizeFilename($name) . '.zip'; // Create streamed ZIP response $response = new StreamedResponse(function () use ($files, $tenantId, $userId, $provider, $service) { $zip = new StreamingZip(null, false); // No compression for speed foreach ($files as $file) { $stream = $this->manager->entityReadStream( $tenantId, $userId, new EntityIdentifier($provider, $service, (string)$file['collection'], (string)$file['id']) ); if ($stream !== null) { try { $zip->addFileFromStream( $file['path'], $stream, $file['modTime'] ?? null ); } finally { fclose($stream); } } } $zip->finish(); }); $response->headers->set('Content-Type', 'application/zip'); $response->headers->set('Content-Disposition', $response->headers->makeDisposition('attachment', $archiveName, $this->asciiFallback($archiveName)) ); $response->headers->set('Cache-Control', 'private, no-cache'); // No Content-Length since we're streaming return $response; } catch (Throwable $t) { $this->logger->error('Archive download failed', ['exception' => $t]); return new JsonResponse([ 'status' => 'error', 'error' => ['code' => $t->getCode(), 'message' => $t->getMessage()] ], Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Download a collection (folder) as a ZIP archive with structure preserved * * GET /download/collection/{provider}/{service}/{identifier} */ #[AuthenticatedRoute( '/download/collection/{provider}/{service}/{identifier}', name: 'manager.download.collection', methods: ['GET'] )] public function downloadCollection(string $provider, string $service, string $identifier): Response { $tenantId = $this->tenantIdentity->identifier(); $userId = $this->userIdentity->identifier(); try { // Fetch collection metadata /** @var CollectionBaseInterface|null $collection */ $collection = $this->manager->collectionFetch( $tenantId, $userId, new CollectionIdentifier($provider, $service, $identifier) ); if ($collection === null) { return new JsonResponse([ 'status' => 'error', 'error' => ['code' => 404, 'message' => 'Folder not found'] ], Response::HTTP_NOT_FOUND); } $folderName = $collection->getProperties()->getLabel() ?? 'folder'; $archiveName = $this->sanitizeFilename($folderName) . '.zip'; // Build recursive file list $files = $this->resolveCollectionContents( $tenantId, $userId, $provider, $service, $identifier, '' // Base path (root of archive) ); // Create streamed ZIP response $response = new StreamedResponse(function () use ($files, $tenantId, $userId, $provider, $service) { $zip = new StreamingZip(null, false); foreach ($files as $file) { if ($file['type'] === 'directory') { $zip->addDirectory($file['path'], $file['modTime'] ?? null); } else { $stream = $this->manager->entityReadStream( $tenantId, $userId, new EntityIdentifier($provider, $service, (string)$file['collection'], (string)$file['id']) ); if ($stream !== null) { try { $zip->addFileFromStream( $file['path'], $stream, $file['modTime'] ?? null ); } finally { fclose($stream); } } } } $zip->finish(); }); $response->headers->set('Content-Type', 'application/zip'); $response->headers->set('Content-Disposition', $response->headers->makeDisposition('attachment', $archiveName, $this->asciiFallback($archiveName)) ); $response->headers->set('Cache-Control', 'private, no-cache'); return $response; } catch (Throwable $t) { $this->logger->error('Collection download failed', ['exception' => $t]); return new JsonResponse([ 'status' => 'error', 'error' => ['code' => $t->getCode(), 'message' => $t->getMessage()] ], Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Resolve a list of entity/collection IDs into a flat file list for archiving */ private function resolveFilesForArchive(string $tenantId, string $userId, string $provider, string $service, ?string $collection, array $ids): array { $files = []; foreach ($ids as $id) { // Try as entity first if ($collection !== null) { /** @var EntityBaseInterface[] $entities */ $entities = $this->manager->entityFetchBulk( $tenantId, $userId, new EntityIdentifier($provider, $service, $collection, (string)$id) ); if (!empty($entities) && isset($entities[$id])) { $entity = $entities[$id]; $files[] = [ 'type' => 'file', 'id' => $id, 'collection' => $collection, 'path' => $entity->getProperties()->getLabel() ?? $id, 'modTime' => $entity->modified()?->getTimestamp(), ]; continue; } } // Try as collection (folder) /** @var CollectionBaseInterface|null $collectionNode */ $collectionNode = $this->manager->collectionFetch( $tenantId, $userId, new CollectionIdentifier($provider, $service, (string)$id) ); if ($collectionNode !== null) { $folderName = $collectionNode->getProperties()->getLabel() ?? $id; $subFiles = $this->resolveCollectionContents( $tenantId, $userId, $provider, $service, (string)$id, $folderName ); $files = array_merge($files, $subFiles); } } return $files; } /** * Recursively resolve all contents of a collection into a flat file list */ private function resolveCollectionContents( string $tenantId, string $userId, string $provider, string $service, string $collectionId, string $basePath, int $depth = 0, int $maxDepth = 20 ): array { $files = []; // Guard against runaway recursion on pathologically deep trees if ($depth > $maxDepth) { $this->logger->warning('Max recursion depth reached, skipping deeper contents', [ 'collection' => $collectionId, 'depth' => $depth, ]); return $files; } // Add directory entry if we have a path if ($basePath !== '') { $files[] = [ 'type' => 'directory', 'path' => $basePath, 'modTime' => null, ]; } // List immediate child collections and entities of this collection; // recursion is handled here to build proper archive paths $sources = new ResourceIdentifiers(); $sources->add(new CollectionIdentifier($provider, $service, $collectionId)); try { $collections = $this->manager->collectionList($tenantId, $userId, $sources)[$provider][$service] ?? []; $entities = $this->manager->entityListBulk($tenantId, $userId, $sources)[$provider][$service][$collectionId] ?? []; } catch (Throwable $e) { $this->logger->warning('Failed to list collection contents', [ 'collection' => $collectionId, 'exception' => $e ]); return $files; } /** @var CollectionBaseInterface $node */ foreach ($collections as $node) { $nodeName = $node->getProperties()->getLabel() ?? (string) $node->identifier(); $nodePath = $basePath !== '' ? $basePath . '/' . $nodeName : $nodeName; // Recursively get contents of sub-collection $subFiles = $this->resolveCollectionContents( $tenantId, $userId, $provider, $service, (string) $node->identifier(), $nodePath, $depth + 1, $maxDepth ); $files = array_merge($files, $subFiles); } /** @var EntityBaseInterface $node */ foreach ($entities as $node) { $nodeName = $node->getProperties()->getLabel() ?? (string) $node->identifier(); $nodePath = $basePath !== '' ? $basePath . '/' . $nodeName : $nodeName; $files[] = [ 'type' => 'file', 'id' => (string) $node->identifier(), 'collection' => $collectionId, 'path' => $nodePath, 'modTime' => $node->modified()?->getTimestamp(), ]; } return $files; } /** * Sanitize a filename for use in Content-Disposition header */ private function sanitizeFilename(string $filename): string { // Remove or replace problematic characters $filename = preg_replace('/[<>:"\/\\|?*\x00-\x1F]/', '_', $filename); $filename = trim($filename, '. '); if ($filename === '') { $filename = 'download'; } return $filename; } /** * Create an ASCII-safe fallback filename */ private function asciiFallback(string $filename): string { // Transliterate to ASCII $fallback = transliterator_transliterate('Any-Latin; Latin-ASCII', $filename); if ($fallback === false) { $fallback = preg_replace('/[^\x20-\x7E]/', '_', $filename); } return $this->sanitizeFilename($fallback); } }