Passed
Push — master ( c13757...19c3c0 )
by Borislav
04:44
created

CachedOutputFileStore::set()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 2
rs 10
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