diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7948c0..d5f38fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,15 @@ x.x.x - TODO 1.5.x - 2024xxxx Maintenance release for 1.x (PHP >= 7.4) * ... -2.7.x - 2024xxxx +2.8.x - 2024xxxx * ... +2.8.0 - 20240901 Support 'kepubify' tool for Kobo + * Add FileRenderer class to send files + use sendHeaders + * Fix Zipper to allow unicode chars in file names + * Refactor FetchHandler and getUpdatedEpub to support kepubify + * Use optional kepubify tool to convert EPUB files for Kobo - see #77 by @SenorSmartyPants + 2.7.5 - 20240831 Show extra data files in book detail + start GraphQL * Changes in config_default.php file: - new $config['cops_kepubify_path'] diff --git a/lib/Calibre/Book.php b/lib/Calibre/Book.php index 6cb6af81..96b7b0d4 100644 --- a/lib/Calibre/Book.php +++ b/lib/Calibre/Book.php @@ -13,6 +13,7 @@ use SebLucas\Cops\Model\EntryBook; use SebLucas\Cops\Model\LinkEntry; use SebLucas\Cops\Model\LinkFeed; +use SebLucas\Cops\Output\FileRenderer; use SebLucas\Cops\Output\Format; use SebLucas\Cops\Pages\PageId; use SebLucas\EPubMeta\EPub; @@ -567,18 +568,16 @@ public function getCoverFilePath($extension) /** * Summary of getUpdatedEpub * @param int $idData - * @param bool $sendHeaders * @return void */ - public function getUpdatedEpub($idData, $sendHeaders = true) + public function getUpdatedEpub($idData) { $data = $this->getDataById($idData); // if we want to update metadata and then use kepubify, we need to save the updated Epub first if ($this->updateForKepub && !empty(Config::get('kepubify_path'))) { // make a temp copy for the updated Epub file - $tmpdir = sys_get_temp_dir(); - $tmpfile = tempnam($tmpdir, 'COPS') . '.epub'; + $tmpfile = FileRenderer::getTempFile('epub'); if (!copy($data->getLocalPath(), $tmpfile)) { echo 'Error: unable to copy epub file'; return; @@ -626,6 +625,7 @@ public function getUpdatedEpub($idData, $sendHeaders = true) } $epub->updateForKepub(); } + $sendHeaders = headers_sent() ? false : true; $epub->download($filename, $sendHeaders); } catch (Exception $e) { echo 'Exception : ' . $e->getMessage(); @@ -635,26 +635,28 @@ public function getUpdatedEpub($idData, $sendHeaders = true) /** * Summary of runKepubify * @param string $filepath - * @param ?string $sendfile + * @param ?string $sendFile * @return string|null */ - public function runKepubify($filepath, $sendfile = null) + public function runKepubify($filepath, $sendFile = null) { if (empty(Config::get('kepubify_path'))) { return null; } - $tmpdir = sys_get_temp_dir(); - $tmpfile = tempnam($tmpdir, 'COPS') . '.kepub.epub'; + $tmpfile = FileRenderer::getTempFile('kepub.epub'); $cmd = escapeshellarg(Config::get('kepubify_path')); $cmd .= ' -o ' . escapeshellarg($tmpfile); $cmd .= ' ' . escapeshellarg($filepath); exec($cmd, $output, $return); if ($return == 0 && file_exists($tmpfile)) { - if (!empty($sendfile)) { - header('Content-Type: ' . EPub::MIME_TYPE); - header('Content-Disposition: attachment; filename="' . basename($sendfile) . '"'); + if (!empty($sendFile)) { // don't use x_accel_redirect since we deal with a tmpfile here - header('Content-Length: ' . filesize($tmpfile)); + $sendHeaders = headers_sent() ? false : true; + if ($sendHeaders) { + header('Content-Type: ' . EPub::MIME_TYPE); + header('Content-Disposition: attachment; filename="' . basename($sendFile) . '"'); + header('Content-Length: ' . filesize($tmpfile)); + } readfile($tmpfile); } return $tmpfile; diff --git a/lib/Calibre/Cover.php b/lib/Calibre/Cover.php index 26adef80..bdba0ebe 100644 --- a/lib/Calibre/Cover.php +++ b/lib/Calibre/Cover.php @@ -207,10 +207,9 @@ public function getThumbnail($width, $height, $outputfile = null, $inType = 'jpg /** * Summary of sendThumbnail * @param Request $request - * @param bool $sendHeaders * @return void */ - public function sendThumbnail($request, $sendHeaders = true) + public function sendThumbnail($request) { $type = $request->get('type', 'jpg'); $width = $request->get('width'); @@ -239,6 +238,7 @@ public function sendThumbnail($request, $sendHeaders = true) $mime = ($type == 'jpg') ? 'image/jpeg' : 'image/png'; $file = $this->coverFileName; + $sendHeaders = headers_sent() ? false : true; if ($sendHeaders) { $expires = 60 * 60 * 24 * 14; header('Pragma: public'); diff --git a/lib/Handlers/EpubFsHandler.php b/lib/Handlers/EpubFsHandler.php index 3084bdbd..d8250d21 100644 --- a/lib/Handlers/EpubFsHandler.php +++ b/lib/Handlers/EpubFsHandler.php @@ -49,10 +49,13 @@ public function handle($request) try { $data = EPubReader::getContent($idData, $component, $request); - $expires = 60 * 60 * 24 * 14; - header('Pragma: public'); - header('Cache-Control: maxage=' . $expires); - header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT'); + $sendHeaders = headers_sent() ? false : true; + if ($sendHeaders) { + $expires = 60 * 60 * 24 * 14; + header('Pragma: public'); + header('Cache-Control: maxage=' . $expires); + header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT'); + } echo $data; } catch (Exception $e) { diff --git a/lib/Handlers/FetchHandler.php b/lib/Handlers/FetchHandler.php index 2b9f0496..3698b2d0 100644 --- a/lib/Handlers/FetchHandler.php +++ b/lib/Handlers/FetchHandler.php @@ -83,11 +83,6 @@ public function handle($request) } if (!empty($file)) { - if ($file == 'zipped') { - // zip all extra files and send back - $this->zipExtraFiles($request, $book); - return; - } $this->sendExtraFile($request, $book, $file); return; } @@ -113,40 +108,13 @@ public function handle($request) } if ($type == 'epub' && Config::get('provide_kepub') == '1' && preg_match('/Kobo/', $request->agent())) { - // run kepubify on original Epub file and send converted tmpfile - if (!empty(Config::get('kepubify_path'))) { - $kepubFile = $book->runKepubify($file, $data->getUpdatedFilenameKepub()); - if (empty($kepubFile)) { - echo 'Error: failed to convert epub file'; - } - return; - } - // provide kepub in name only (without update of opf properties for cover-image in Epub) - FileRenderer::sendFile($file, basename($data->getUpdatedFilenameKepub()), $data->getMimeType()); + $this->sendConvertedKepub($book, $file, $data); + return; } FileRenderer::sendFile($file, basename($file), $data->getMimeType()); } - /** - * Summary of zipExtraFiles - * @param Request $request - * @param Book $book - * @return void - */ - public function zipExtraFiles($request, $book) - { - $zipper = new Zipper($request); - - if ($zipper->isValidForExtraFiles($book)) { - // disable nginx buffering by default - header('X-Accel-Buffering: no'); - $zipper->download(); - } else { - echo "Invalid zipped: " . $zipper->getMessage(); - } - } - /** * Summary of sendExtraFile * @param Request $request @@ -156,6 +124,11 @@ public function zipExtraFiles($request, $book) */ public function sendExtraFile($request, $book, $file) { + if ($file == 'zipped') { + // zip all extra files and send back + $this->zipExtraFiles($request, $book); + return; + } $extraFiles = $book->getExtraFiles(); if (!in_array($file, $extraFiles)) { // this will call exit() @@ -170,6 +143,28 @@ public function sendExtraFile($request, $book, $file) FileRenderer::sendFile($filepath, basename($filepath)); } + /** + * Summary of zipExtraFiles + * @param Request $request + * @param Book $book + * @return void + */ + public function zipExtraFiles($request, $book) + { + $zipper = new Zipper($request); + + if ($zipper->isValidForExtraFiles($book)) { + $sendHeaders = headers_sent() ? false : true; + // disable nginx buffering by default + if ($sendHeaders) { + header('X-Accel-Buffering: no'); + } + $zipper->download(null, $sendHeaders); + } else { + echo "Invalid zipped: " . $zipper->getMessage(); + } + } + /** * Summary of sendThumbnail * @param Request $request @@ -204,4 +199,25 @@ public function sendUpdatedEpub($request, $book, $idData) // this will also use kepubify_path internally if defined $book->getUpdatedEpub($idData); } + + /** + * Summary of sendConvertedKepub + * @param Book $book + * @param string $file + * @param Data $data + * @return void + */ + public function sendConvertedKepub($book, $file, $data) + { + // run kepubify on original Epub file and send converted tmpfile + if (!empty(Config::get('kepubify_path'))) { + $kepubFile = $book->runKepubify($file, $data->getUpdatedFilenameKepub()); + if (empty($kepubFile)) { + echo 'Error: failed to convert epub file'; + } + return; + } + // provide kepub in name only (without update of opf properties for cover-image in Epub) + FileRenderer::sendFile($file, basename($data->getUpdatedFilenameKepub()), $data->getMimeType()); + } } diff --git a/lib/Handlers/ZipFsHandler.php b/lib/Handlers/ZipFsHandler.php index cda2fe3e..8d168331 100644 --- a/lib/Handlers/ZipFsHandler.php +++ b/lib/Handlers/ZipFsHandler.php @@ -57,10 +57,14 @@ public function handle($request) if ($res === false) { throw new Exception('Unknown component ' . $component); } - $expires = 60 * 60 * 24 * 14; - header('Pragma: public'); - header('Cache-Control: maxage=' . $expires); - header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT'); + + $sendHeaders = headers_sent() ? false : true; + if ($sendHeaders) { + $expires = 60 * 60 * 24 * 14; + header('Pragma: public'); + header('Cache-Control: maxage=' . $expires); + header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT'); + } echo $zip->getFromName($component); $zip->close(); diff --git a/lib/Handlers/ZipperHandler.php b/lib/Handlers/ZipperHandler.php index 457d3721..46e06cf9 100644 --- a/lib/Handlers/ZipperHandler.php +++ b/lib/Handlers/ZipperHandler.php @@ -50,9 +50,12 @@ public function handle($request) $zipper = new Zipper($request); if ($zipper->isValidForDownload()) { + $sendHeaders = headers_sent() ? false : true; // disable nginx buffering by default - header('X-Accel-Buffering: no'); - $zipper->download(); + if ($sendHeaders) { + header('X-Accel-Buffering: no'); + } + $zipper->download(null, $sendHeaders); } else { echo "Invalid download: " . $zipper->getMessage(); } diff --git a/lib/Input/Config.php b/lib/Input/Config.php index 3f3b2ad6..9f13ccc6 100644 --- a/lib/Input/Config.php +++ b/lib/Input/Config.php @@ -16,7 +16,7 @@ */ class Config { - public const VERSION = '2.7.5'; + public const VERSION = '2.8.0'; public const ENDPOINT = [ "index" => "index.php", "feed" => "feed.php", diff --git a/lib/Output/FileRenderer.php b/lib/Output/FileRenderer.php index 1c84a9e2..01817141 100644 --- a/lib/Output/FileRenderer.php +++ b/lib/Output/FileRenderer.php @@ -33,6 +33,22 @@ public static function getMimeType($filepath) return $mimetype; } + /** + * Summary of getTempFile + * @param string $extension + * @return string + */ + public static function getTempFile($extension = '') + { + $tmpdir = sys_get_temp_dir(); + $tmpfile = tempnam($tmpdir, 'COPS'); + if (empty($extension)) { + return $tmpfile; + } + rename($tmpfile, $tmpfile . '.' . $extension); + return $tmpfile . '.' . $extension; + } + /** * Summary of sendFile * @param string $filepath actual filepath diff --git a/test/BookTest.php b/test/BookTest.php index 1d994cda..594399a7 100644 --- a/test/BookTest.php +++ b/test/BookTest.php @@ -374,7 +374,7 @@ public function testSendThumbnailOriginal(): void // no thumbnail resizing ob_start(); - $cover->sendThumbnail($request, false); + $cover->sendThumbnail($request); $headers = headers_list(); $output = ob_get_clean(); @@ -392,7 +392,7 @@ public function testSendThumbnailResize(): void // no thumbnail cache ob_start(); - $cover->sendThumbnail($request, false); + $cover->sendThumbnail($request); $headers = headers_list(); $output = ob_get_clean(); @@ -422,7 +422,7 @@ public function testSendThumbnailCacheMiss(): void // 1. cache miss ob_start(); - $cover->sendThumbnail($request, false); + $cover->sendThumbnail($request); $headers = headers_list(); $output = ob_get_clean(); @@ -448,7 +448,7 @@ public function testSendThumbnailCacheHit(): void // 2. cache hit ob_start(); - $cover->sendThumbnail($request, false); + $cover->sendThumbnail($request); $headers = headers_list(); $output = ob_get_clean(); @@ -576,7 +576,7 @@ public function testGetUpdatedEpub(): void $book = Book::getBookById(17); ob_start(); - $book->getUpdatedEpub(20, false); + $book->getUpdatedEpub(20); $headers = headers_list(); $output = ob_get_clean(); diff --git a/test/EpubFsTest.php b/test/EpubFsTest.php index 88d98b24..d41888b9 100644 --- a/test/EpubFsTest.php +++ b/test/EpubFsTest.php @@ -132,11 +132,6 @@ public function testEncodeDecode(): void $this->assertEquals($decoded, EPubReader::decode($encoded)); } - /** - * Summary of testEpubFsHandler - * @runInSeparateProcess - * @return void - */ public function testEpubFsHandler(): void { // set request handler to 'phpunit' to override cli check in handler @@ -152,11 +147,6 @@ public function testEpubFsHandler(): void $this->assertStringContainsString($expected, $output); } - /** - * Summary of testZipFsHandler - * @runInSeparateProcess - * @return void - */ public function testZipFsHandler(): void { // set request handler to 'phpunit' to override cli check in handler diff --git a/test/ZipperTest.php b/test/ZipperTest.php index f6088f31..007e3791 100644 --- a/test/ZipperTest.php +++ b/test/ZipperTest.php @@ -17,13 +17,14 @@ use SebLucas\Cops\Framework; use SebLucas\Cops\Input\Config; use SebLucas\Cops\Input\Request; +use SebLucas\Cops\Output\FileRenderer; use SebLucas\Cops\Pages\PageId; class ZipperTest extends TestCase { /** @var array */ protected static $expectedSize = [ - 'recent' => 1596525, + 'recent' => 1596561, 'author' => 1594886, 'zipped' => 344, ]; @@ -37,11 +38,6 @@ public static function setUpBeforeClass(): void Database::clearDb(); } - /** - * Summary of testDownloadPageRecent - * @runInSeparateProcess - * @return void - */ public function testDownloadPageRecent(): void { $page = PageId::ALL_RECENT_BOOKS; @@ -57,7 +53,7 @@ public function testDownloadPageRecent(): void $this->assertTrue($valid); ob_start(); - $zipper->download(); + $zipper->download(null, false); $headers = headers_list(); $output = ob_get_clean(); @@ -68,11 +64,6 @@ public function testDownloadPageRecent(): void Config::set('download_page', ['']); } - /** - * Summary of testDownloadAuthor - * @runInSeparateProcess - * @return void - */ public function testDownloadAuthor(): void { $authorId = 3; @@ -88,10 +79,9 @@ public function testDownloadAuthor(): void $this->assertTrue($valid); ob_start(); - $zipper->download(); + $zipper->download(null, false); $headers = headers_list(); $output = ob_get_clean(); - $headers = headers_list(); $expected = self::$expectedSize['author']; $this->assertEquals(0, count($headers)); @@ -138,11 +128,6 @@ public function testDownloadWrongFormat(): void Config::set('download_page', ['']); } - /** - * Summary of testZipExtraFiles - * @runInSeparateProcess - * @return void - */ public function testZipExtraFiles(): void { $request = new Request(); @@ -153,20 +138,32 @@ public function testZipExtraFiles(): void $this->assertTrue($valid); ob_start(); - $zipper->download(); + $zipper->download(null, false); $headers = headers_list(); $output = ob_get_clean(); $expected = self::$expectedSize['zipped']; $this->assertEquals(0, count($headers)); $this->assertEquals($expected, strlen($output)); + + $expected = [ + 'hello.txt', + 'sub/copied.txt', + ]; + // make a temp file to analyze the zip file + $tmpfile = FileRenderer::getTempFile('zip'); + file_put_contents($tmpfile, $output); + $zip = new \ZipArchive(); + $result = $zip->open($tmpfile, \ZipArchive::RDONLY); + $this->assertTrue($result); + $result = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $result[] = $zip->getNameIndex($i); + } + $this->assertEquals($expected, $result); + $zip->close(); } - /** - * Summary of testZipperHandler - * @runInSeparateProcess - * @return void - */ public function testZipperHandler(): void { $page = PageId::ALL_RECENT_BOOKS;