* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\FileManager\Controllers; use InvalidArgumentException; 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\Files\Node\INodeCollectionBase; use KTXF\Files\Node\INodeEntityBase; use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXM\FileManager\Manager; use KTXM\FileManager\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 Manager $fileManager, private readonly LoggerInterface $logger ) {} /** * Download a single file * * GET /download/entity/{provider}/{service}/{collection}/{identifier} */ #[AuthenticatedRoute( '/download/entity/{provider}/{service}/{collection}/{identifier}', name: 'filemanager.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 { // Fetch entity metadata $entities = $this->fileManager->entityFetch( $tenantId, $userId, $provider, $service, $collection, [$identifier] ); if (empty($entities) || !isset($entities[$identifier])) { return new JsonResponse([ 'status' => 'error', 'error' => ['code' => 404, 'message' => 'File not found'] ], Response::HTTP_NOT_FOUND); } /** @var INodeEntityBase $entity */ $entity = $entities[$identifier]; // Get the stream $stream = $this->fileManager->entityReadStream( $tenantId, $userId, $provider, $service, $collection, $identifier ); if ($stream === null) { return new JsonResponse([ 'status' => 'error', 'error' => ['code' => 404, 'message' => 'File content not available'] ], Response::HTTP_NOT_FOUND); } $filename = $entity->getLabel() ?? 'download'; $mime = $entity->getMime() ?? 'application/octet-stream'; $size = $entity->size(); // Create streamed response $response = new StreamedResponse(function () use ($stream) { while (!feof($stream)) { echo fread($stream, 65536); @ob_flush(); flush(); } fclose($stream); }); $response->headers->set('Content-Type', $mime); $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: 'filemanager.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->fileManager->entityReadStream( $tenantId, $userId, $provider, $service, $file['collection'], $file['id'] ); if ($stream !== null) { $zip->addFileFromStream( $file['path'], $stream, $file['modTime'] ?? null ); 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: 'filemanager.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 $collection = $this->fileManager->collectionFetch( $tenantId, $userId, $provider, $service, $identifier ); if ($collection === null) { return new JsonResponse([ 'status' => 'error', 'error' => ['code' => 404, 'message' => 'Folder not found'] ], Response::HTTP_NOT_FOUND); } $folderName = $collection->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->fileManager->entityReadStream( $tenantId, $userId, $provider, $service, $file['collection'], $file['id'] ); if ($stream !== null) { $zip->addFileFromStream( $file['path'], $stream, $file['modTime'] ?? null ); 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) { $entities = $this->fileManager->entityFetch( $tenantId, $userId, $provider, $service, $collection, [$id] ); if (!empty($entities) && isset($entities[$id])) { /** @var INodeEntityBase $entity */ $entity = $entities[$id]; $files[] = [ 'type' => 'file', 'id' => $id, 'collection' => $collection, 'path' => $entity->getLabel() ?? $id, 'modTime' => $entity->modifiedOn()?->getTimestamp(), ]; continue; } } // Try as collection (folder) $collectionNode = $this->fileManager->collectionFetch( $tenantId, $userId, $provider, $service, $id ); if ($collectionNode !== null) { $folderName = $collectionNode->getLabel() ?? $id; $subFiles = $this->resolveCollectionContents( $tenantId, $userId, $provider, $service, $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 ): array { $files = []; // Add directory entry if we have a path if ($basePath !== '') { $files[] = [ 'type' => 'directory', 'path' => $basePath, 'modTime' => null, ]; } // Get all nodes in this collection using nodeList with recursive=false // We handle recursion ourselves to build proper paths try { $nodes = $this->fileManager->nodeList( $tenantId, $userId, $provider, $service, $collectionId, false // Not recursive - we handle it ourselves ); } catch (Throwable $e) { $this->logger->warning('Failed to list collection contents', [ 'collection' => $collectionId, 'exception' => $e ]); return $files; } foreach ($nodes as $node) { $nodeName = $node->getLabel() ?? (string) $node->id(); $nodePath = $basePath !== '' ? $basePath . '/' . $nodeName : $nodeName; if ($node->isCollection()) { // Recursively get contents of sub-collection $subFiles = $this->resolveCollectionContents( $tenantId, $userId, $provider, $service, (string) $node->id(), $nodePath ); $files = array_merge($files, $subFiles); } else { // It's an entity (file) /** @var INodeEntityBase $node */ $files[] = [ 'type' => 'file', 'id' => (string) $node->id(), 'collection' => $collectionId, 'path' => $nodePath, 'modTime' => $node->modifiedOn()?->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); } }