1
|
|
|
<?php namespace App\Generator; |
2
|
|
|
|
3
|
|
|
use App\Entity\BaseWork; |
4
|
|
|
use InvalidArgumentException; |
5
|
|
|
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; |
6
|
|
|
use Symfony\Component\Filesystem\Filesystem; |
7
|
|
|
|
8
|
|
|
class EpubConverter { |
9
|
|
|
|
10
|
|
|
private const SUPPORTED_FORMATS = [ |
11
|
|
|
BaseWork::FORMAT_MOBI, |
12
|
|
|
BaseWork::FORMAT_PDF, |
13
|
|
|
]; |
14
|
|
|
|
15
|
|
|
/** @var ParameterBag */ |
16
|
|
|
private $parameters; |
17
|
|
|
/** @var string */ |
18
|
|
|
private $cacheDir; |
19
|
|
|
|
20
|
|
|
public function __construct(ParameterBag $parameters, string $cacheDir) { |
21
|
|
|
$this->parameters = $parameters; |
22
|
|
|
$this->cacheDir = $cacheDir; |
23
|
|
|
} |
24
|
|
|
|
25
|
|
|
public function convert(string $epubUrl, string $targetFormat): string { |
26
|
|
|
$this->assertUrl($epubUrl); |
27
|
|
|
$this->assertSupportedTargetFormat($targetFormat); |
28
|
|
|
$this->assertEnabledTargetFormat($targetFormat); |
29
|
|
|
|
30
|
|
|
$commandTemplate = $this->parameters->get("{$targetFormat}_converter_command"); |
31
|
|
|
if (empty($commandTemplate)) { |
32
|
|
|
throw new InvalidArgumentException("The target format '{$targetFormat}' does not have a shell converter command."); |
33
|
|
|
} |
34
|
|
|
|
35
|
|
|
$cachedOutputFileStore = new CachedOutputFileStore($this->cacheDir, $epubUrl, $targetFormat); |
36
|
|
|
$cachedOutputFile = $cachedOutputFileStore->get(); |
37
|
|
|
if ($cachedOutputFile) { |
38
|
|
|
return $cachedOutputFile; |
39
|
|
|
} |
40
|
|
|
|
41
|
|
|
$epubFile = $this->downloadEpub($epubUrl); |
42
|
|
|
$epubFile->saveAt($this->cacheDir); |
43
|
|
|
|
44
|
|
|
$outputFile = $this->convertFile($commandTemplate, $epubFile->path, $targetFormat); |
45
|
|
|
$cachedOutputFileStore->set($outputFile); |
46
|
|
|
return $outputFile; |
47
|
|
|
} |
48
|
|
|
|
49
|
|
|
private function convertFile(string $commandTemplate, string $inputFile, string $outputFormat): string { |
50
|
|
|
$outputFile = str_replace('.epub', ".$outputFormat", $inputFile); |
51
|
|
|
$command = strtr($commandTemplate, [ |
52
|
|
|
'INPUT_FILE' => escapeshellarg($inputFile), |
53
|
|
|
'OUTPUT_FILE' => escapeshellarg($outputFile), |
54
|
|
|
'OUTPUT_FILE_BASENAME' => escapeshellarg(basename($outputFile)), |
55
|
|
|
]); |
56
|
|
|
$binDir = realpath(__DIR__.'/../../bin'); |
57
|
|
|
chdir($binDir);// go to local bin directory to allow execution of locally stored binaries |
58
|
|
|
$execPath = getenv('PATH'); |
59
|
|
|
$extendPath = $execPath ? 'PATH=.:$PATH' : ''; |
60
|
|
|
$commandWithCustomPath = trim("$extendPath $command"); |
61
|
|
|
shell_exec($commandWithCustomPath); |
62
|
|
|
return $outputFile; |
63
|
|
|
} |
64
|
|
|
|
65
|
|
|
private function downloadEpub(string $epubUrl) { |
66
|
|
|
$stream = fopen($this->sanitizeSource($epubUrl), 'r'); |
67
|
|
|
$headers = stream_get_meta_data($stream)['wrapper_data']; |
68
|
|
|
$contents = stream_get_contents($stream); |
69
|
|
|
fclose($stream); |
70
|
|
|
|
71
|
|
|
$epubFile = $this->getFileNameFromHeaders($headers) ?: basename($epubUrl); |
72
|
|
|
return new DownloadedFile($epubFile, $contents); |
73
|
|
|
} |
74
|
|
|
|
75
|
|
|
/* |
76
|
|
|
* Example headers: |
77
|
|
|
* - Location: /cache/dl/file.epub |
78
|
|
|
* - Content-Disposition: attachment; filename="file.epub" |
79
|
|
|
*/ |
80
|
|
|
private function getFileNameFromHeaders(array $headers): string { |
81
|
|
|
foreach (array_reverse($headers) as $header) { |
82
|
|
|
$parts = explode(':', $header); |
83
|
|
|
$name = strtolower(trim($parts[0])); |
84
|
|
|
switch ($name) { |
85
|
|
|
case 'content-disposition': |
86
|
|
|
$normalizedValue = strtr($parts[1], [' ' => '', '"' => '', "'" => '']) . ';'; |
87
|
|
|
if (preg_match('#filename=([^;]+)#', $normalizedValue, $matches)) { |
88
|
|
|
return basename($matches[1]); |
89
|
|
|
} |
90
|
|
|
return ''; |
91
|
|
|
case 'location': |
92
|
|
|
return basename(trim($parts[1])); |
93
|
|
|
} |
94
|
|
|
} |
95
|
|
|
return ''; |
96
|
|
|
} |
97
|
|
|
|
98
|
|
|
private function assertUrl(string $urlToAssert) { |
99
|
|
|
if ( ! preg_match('#^https?://#', $urlToAssert)) { |
100
|
|
|
throw new InvalidArgumentException("Not a valid URL: '{$urlToAssert}'"); |
101
|
|
|
} |
102
|
|
|
} |
103
|
|
|
|
104
|
|
|
private function assertSupportedTargetFormat(string $targetFormat) { |
105
|
|
|
if ( ! in_array($targetFormat, self::SUPPORTED_FORMATS)) { |
106
|
|
|
throw new InvalidArgumentException("Unsupported target format: '{$targetFormat}'"); |
107
|
|
|
} |
108
|
|
|
} |
109
|
|
|
|
110
|
|
|
private function assertEnabledTargetFormat(string $targetFormat) { |
111
|
|
|
$key = "{$targetFormat}_download_enabled"; |
112
|
|
|
if ( ! $this->parameters->get($key)) { |
113
|
|
|
throw new InvalidArgumentException("Target format is not enabled: '{$targetFormat}'"); |
114
|
|
|
} |
115
|
|
|
} |
116
|
|
|
|
117
|
|
|
private function sanitizeSource(string $source): string { |
118
|
|
|
return preg_replace('#[^a-zA-Z\d:/.,_-]#', '', $source); |
119
|
|
|
} |
120
|
|
|
} |
121
|
|
|
|
122
|
|
|
class DownloadedFile { |
123
|
|
|
public $name; |
124
|
|
|
public $contents; |
125
|
|
|
public $path; |
126
|
|
|
|
127
|
|
|
public function __construct($name, $contents) { |
128
|
|
|
$this->name = $name; |
129
|
|
|
$this->contents = $contents; |
130
|
|
|
} |
131
|
|
|
|
132
|
|
|
public function saveAt(string $directory) { |
133
|
|
|
$this->path = "$directory/$this->name"; |
134
|
|
|
$fs = new Filesystem(); |
135
|
|
|
$fs->dumpFile($this->path, $this->contents); |
136
|
|
|
} |
137
|
|
|
} |
138
|
|
|
|
139
|
|
|
class CachedOutputFileStore { |
140
|
|
|
private $store; |
141
|
|
|
private $fs; |
142
|
|
|
|
143
|
|
|
public function __construct(string $cacheDir, string $sourceUrl, string $outputFormat) { |
144
|
|
|
$this->store = "$cacheDir/$outputFormat-".md5($sourceUrl).'.file'; |
145
|
|
|
$this->fs = new Filesystem(); |
146
|
|
|
} |
147
|
|
|
|
148
|
|
|
public function get(): ?string { |
149
|
|
|
if ( ! $this->fs->exists($this->store)) { |
150
|
|
|
return null; |
151
|
|
|
} |
152
|
|
|
$cachedOutputFile = trim(file_get_contents($this->store)); |
153
|
|
|
if ( ! $this->fs->exists($cachedOutputFile)) { |
154
|
|
|
return null; |
155
|
|
|
} |
156
|
|
|
$this->fs->touch($cachedOutputFile); |
157
|
|
|
return $cachedOutputFile; |
158
|
|
|
} |
159
|
|
|
|
160
|
|
|
public function set(string $outputFile) { |
161
|
|
|
$this->fs->dumpFile($this->store, $outputFile); |
162
|
|
|
} |
163
|
|
|
} |
164
|
|
|
|