diff --git a/composer.json b/composer.json index fe9792d..427c5e9 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "knplabs/knp-menu-bundle": "^3.2", "knplabs/knp-paginator-bundle": "^6.2", "liip/imagine-bundle": "^2.11", + "maennchen/zipstream-php": "^3.1", "nelmio/security-bundle": "^3.0", "nesbot/carbon": "^2.70", "oneup/uploader-bundle": "^4.0", diff --git a/composer.lock b/composer.lock index 1b4845e..6c98e35 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5911cb3f7a5a11524bde5368b3fa7865", + "content-hash": "902495c1d53e43f8de93c4f162805e7f", "packages": [ { "name": "beberlei/doctrineextensions", @@ -2064,6 +2064,84 @@ }, "time": "2024-12-12T09:38:23+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.1.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.2" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^11.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-01-27T12:07:53+00:00" + }, { "name": "monolog/monolog", "version": "3.8.1", diff --git a/deploy.php b/deploy.php index 54ca658..bc18285 100644 --- a/deploy.php +++ b/deploy.php @@ -74,7 +74,7 @@ host('prod') ->setDeployPath('/kunden/tiefschnee.de/myep-team') ->set('writable_mode', 'chmod') ->set('http_user', 'ssh-53857-root') - ->set('bin/php', '/usr/bin/php81') + ->set('bin/php', '/usr/bin/php82') ->set('rsync_src', __DIR__) ->set('rsync', $rsyncOptions) ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://myep-team.ep-reisen.de') diff --git a/migrations/Version20250131164533.php b/migrations/Version20250131164533.php new file mode 100644 index 0000000..a4fcb6a --- /dev/null +++ b/migrations/Version20250131164533.php @@ -0,0 +1,33 @@ +addSql('DROP TABLE rememberme_token'); + $this->addSql('ALTER TABLE upload ADD downloaded_at DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', ADD downloaded_by VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE TABLE rememberme_token (series VARCHAR(88) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, value VARCHAR(88) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, lastUsed DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', class VARCHAR(100) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, username VARCHAR(200) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, PRIMARY KEY(series)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('ALTER TABLE upload DROP downloaded_at, DROP downloaded_by'); + } +} diff --git a/src/Controller/Administrative/Document/BatchDownloadController.php b/src/Controller/Administrative/Document/BatchDownloadController.php new file mode 100644 index 0000000..ba190b7 --- /dev/null +++ b/src/Controller/Administrative/Document/BatchDownloadController.php @@ -0,0 +1,83 @@ + Upload::TYPE_INVOICE] + )] + #[IsGranted('ROLE_ADMINISTRATIVE')] + public function index(string $type, Request $request): Response + { + $status = $request->query->get('status', Upload::STATUS_PAID); + + $uploads = $this + ->uploadRepository + ->getBatchDownloadList($type, $status) + ; + + if (0 === count($uploads)) { + $this->addFlash('error', 'Keine Dokumente vorhanden'); + + return $this->redirectToRoute('app_administrative_document_index'); + } + + $zipFilename = sprintf('Honorarnoten_%s.zip', date('YmdHi')); + + $response = new StreamedResponse(function () use ($uploads, $zipFilename) { + $zip = new ZipStream( + defaultEnableZeroHeader: true, + outputName: $zipFilename, + contentType: 'application/octet-stream' + ); + + foreach ($uploads as $upload) { + /** @var Upload $upload */ + $filename = $upload->getOriginalFilename(); + $filepath = $this->uploadHandler->getUploadFilepath($upload); + + try { + $zip->addFileFromPath(fileName: $filename, path: $filepath); + $upload + ->setDownloaded() + ->setDownloadedBy($this->getUser()->getUserIdentifier()) + ; + } catch (FileNotFoundException $e) { + } catch (FileNotReadableException $e) { + } + } + $zip->finish(); + $this->entityManager->flush(); + }); + + $disposition = HeaderUtils::makeDisposition('attachment', $zipFilename, md5($zipFilename)); + $response->headers->set('Content-Disposition', $disposition); + + return $response; + } +} \ No newline at end of file diff --git a/src/Controller/Common/DownloadController.php b/src/Controller/Common/DownloadController.php index c97276f..d682372 100644 --- a/src/Controller/Common/DownloadController.php +++ b/src/Controller/Common/DownloadController.php @@ -4,6 +4,7 @@ namespace App\Controller\Common; use App\Entity\Upload; use App\Service\Upload\UploadHandler; +use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; @@ -17,6 +18,7 @@ class DownloadController extends AbstractController { public function __construct( private readonly UploadHandler $uploadHandler, + private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger ) { } @@ -31,6 +33,13 @@ class DownloadController extends AbstractController $inline = (bool) $request->get('inline'); $disposition = $inline ? ResponseHeaderBag::DISPOSITION_INLINE : ResponseHeaderBag::DISPOSITION_ATTACHMENT; + // mark file downloaded + $upload + ->setDownloaded() + ->setDownloadedBy($this->getUser()->getUserIdentifier()) + ; + $this->entityManager->flush(); + $this->logger->info('Download file', [ 'file_id' => $upload->getId(), 'file_filename' => $upload->getOriginalFilename(), diff --git a/src/Entity/Upload.php b/src/Entity/Upload.php index f4b1810..95fd0f7 100644 --- a/src/Entity/Upload.php +++ b/src/Entity/Upload.php @@ -66,6 +66,12 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $comment = null; + #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $downloadedAt = null; + + #[ORM\Column(nullable: true)] + private ?string $downloadedBy = null; + public function __construct() { $this->uuid = Uuid::v4(); @@ -242,4 +248,40 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface return $this; } + + public function getDownloadedAt(): ?\DateTimeImmutable + { + return $this->downloadedAt; + } + + public function setDownloadedAt(?\DateTimeImmutable $downloadedAt): static + { + $this->downloadedAt = $downloadedAt; + + return $this; + } + + public function isDownloaded(): bool + { + return null !== $this->downloadedAt; + } + + public function setDownloaded(): static + { + $this->downloadedAt = new \DateTimeImmutable(); + + return $this; + } + + public function getDownloadedBy(): ?string + { + return $this->downloadedBy; + } + + public function setDownloadedBy(?string $downloadedBy): static + { + $this->downloadedBy = $downloadedBy; + + return $this; + } } diff --git a/src/Repository/UploadRepository.php b/src/Repository/UploadRepository.php index b611324..10eace9 100644 --- a/src/Repository/UploadRepository.php +++ b/src/Repository/UploadRepository.php @@ -8,6 +8,7 @@ use App\Repository\Traits\QueryHelperTrait; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\Query; use Doctrine\Persistence\ManagerRegistry; +use setasign\Fpdi\PdfParser\CrossReference\AbstractReader; /** * @extends ServiceEntityRepository @@ -79,6 +80,25 @@ class UploadRepository extends ServiceEntityRepository return $data; } + public function getBatchDownloadList( + string $type = Upload::TYPE_INVOICE, + string $status = Upload::STATUS_PAID + ): array { + $qb = $this->createQueryBuilder('upload'); + + return $qb->where( + $qb->expr()->andX( + $qb->expr()->eq('upload.type', ':type'), + $qb->expr()->eq('upload.status', ':status'), + $qb->expr()->isNull('upload.downloadedAt'), + )) + ->setParameter('type', $type) + ->setParameter('status', $status) + ->getQuery() + ->getResult() + ; + } + public function getUploadListQuery(DocumentFilterDto $filterDto): Query { $qb = $this->createQueryBuilder('upload'); diff --git a/templates/administrative/document/index.html.twig b/templates/administrative/document/index.html.twig index 554faa0..daf69ba 100644 --- a/templates/administrative/document/index.html.twig +++ b/templates/administrative/document/index.html.twig @@ -8,6 +8,10 @@ Dokumentenübersicht
+ + {{ icon('download', 'w-4 h-4 shrink-0') }} + best. Honorarnoten +