PluginProbe
User Access Manager / 2.3.18
User Access Manager v2.3.18
2.3.20 2.3.19 2.3.18 2.3.17 2.3.16 2.3.15 2.3.14 2.3.13 trunk 0.6 0.6.1 0.6.2 0.7 0.7 Beta 0.7.0.1 0.8 0.8.0.1 0.8.0.2 0.9 0.9.1 0.9.1.1 0.9.1.2 0.9.1.3 0.9.1.4 1.0 All 136 releases
user-access-manager / src / File / FileHandler.php

FileHandler.php in User Access Manager 2.3.18, at src/File/FileHandler.php

365 lines 11.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace UserAccessManager\File;
6
7 use JetBrains\PhpStorm\NoReturn;
8 use UserAccessManager\Config\MainConfig;
9 use UserAccessManager\Config\WordpressConfig;
10 use UserAccessManager\File\Protection\FileProtectionFactory;
11 use UserAccessManager\File\Protection\FileProtectionInterface;
12 use UserAccessManager\Wrapper\Php;
13 use UserAccessManager\Wrapper\Wordpress;
14
15 class FileHandler
16 {
17 public const X_SEND_FILE_TEST_FILE = 'xSendFileTestFile';
18
19 public function __construct(
20 private Php $php,
21 private Wordpress $wordpress,
22 private WordpressConfig $wordpressConfig,
23 private MainConfig $mainConfig,
24 private FileProtectionFactory $fileProtectionFactory
25 ) {
26 }
27
28 private function clearBuffer(): void
29 {
30 //prevent '\n' / '0A'
31 if ((int) $this->php->iniGet('output_buffering') === 0
32 && is_numeric(ob_get_length()) === true
33 ) {
34 ob_clean();
35 }
36
37 $this->php->flush();
38 }
39
40 private function getFileMimeType(string $file): string
41 {
42 $explodedFileName = explode('.', basename($file));
43 $fileExtension = strtolower(array_pop($explodedFileName));
44 $mimeTypes = $this->wordpressConfig->getMimeTypes();
45
46 // The deprecated mime_content_type() is only kept as a fallback for installations without fileinfo.
47 if ($this->php->functionExists('finfo_open') === true) {
48 $fileInfo = $this->php->fInfoOpen(FILEINFO_MIME);
49 $fileMimeType = $this->php->fInfoFile($fileInfo, $file);
50 $this->php->fInfoClose($fileInfo);
51 } elseif ($this->php->functionExists('mime_content_type')) {
52 $fileMimeType = $this->php->mimeContentType($file);
53 } else {
54 $fileMimeType = $mimeTypes[$fileExtension] ?? 'application/octet-stream';
55 }
56
57 return (string) $fileMimeType;
58 }
59
60 private function addDefaultHeader(string $file, bool $isInline): void
61 {
62 $fileMimeType = $this->getFileMimeType($file);
63 $contentDisposition = ($isInline === true) ? 'inline' : 'attachment';
64 $baseName = str_replace(' ', '_', basename($file));
65
66 $this->php->header('Content-Description: File Transfer');
67 $this->php->header('Content-Type: ' . $fileMimeType);
68 $this->php->header("Content-Disposition: $contentDisposition; filename=\"$baseName\"");
69 }
70
71 private function deliverFileViaFopen(string $file): void
72 {
73 $handler = fopen($file, 'r');
74
75 while (feof($handler) === false) {
76 if ($this->php->iniGet('safe_mode') !== '') {
77 $this->php->setTimeLimit(30);
78 }
79
80 echo $this->php->fread($handler, 1024);
81 }
82 }
83
84 private function addXSendFileHeader(string $file): bool
85 {
86 if ($this->wordpress->isNginx()) {
87 // The /uam-files prefix targets a dedicated internal location that bypasses
88 // UAM's rewrite rules, which would otherwise make the redirect loop.
89 $uri = '/uam-files' . str_replace(rtrim(ABSPATH, '/'), '', $file);
90 $this->php->header("X-Accel-Redirect: $uri");
91
92 return true;
93 }
94
95 if ($this->wordpress->isApacheModuleLoaded('mod_xsendfile')) {
96 $this->php->header("X-Sendfile: $file");
97
98 return true;
99 }
100
101 return false;
102 }
103
104 private function deliverFile(string $file, bool $isInline): void
105 {
106 $this->php->header("HTTP/1.1 200 OK");
107 $downloadType = $this->mainConfig->getDownloadType();
108
109 if ($downloadType === 'xsendfile' && $this->addXSendFileHeader($file) === false) {
110 // Without server-side sending support the file still has to be delivered by PHP.
111 $downloadType = 'fopen';
112 }
113
114 $this->addDefaultHeader($file, $isInline);
115
116 if ($downloadType === 'xsendfile') {
117 return;
118 }
119
120 $this->php->header('Content-Transfer-Encoding: binary');
121 $this->php->header('Content-Length: ' . filesize($file));
122 $this->clearBuffer();
123
124 if ($downloadType === 'fopen') {
125 $this->deliverFileViaFopen($file);
126 } else {
127 readfile($file);
128 }
129 }
130
131 /**
132 * Returns the [start, end] byte offsets of a single HTTP range, or null if the range is invalid.
133 */
134 private function getSeekStartEnd(string $range, int $fileSize): ?array
135 {
136 $seek = explode('-', $range);
137 $seekStart = ($seek[0] !== '') ? abs((int) $seek[0]) : null;
138 $seekEnd = (isset($seek[1]) === true && $seek[1] !== '') ? abs((int) $seek[1]) : null;
139 $maxSize = $fileSize - 1;
140
141 if ($seekStart === null) {
142 $seekStart = $fileSize - $seekEnd;
143 $seekEnd = $maxSize;
144 }
145
146 $seekEnd = min($seekEnd ?? $maxSize, $maxSize);
147
148 return ($seekStart < $seekEnd) ? [$seekStart, $seekEnd] : null;
149 }
150
151 private function readFilePartly($fileHandler, int $bytes): void
152 {
153 $bytesLeft = $bytes;
154 $bufferSize = 1024;
155
156 while ($bytesLeft > 0 && feof($fileHandler) === false) {
157 $bytesToRead = min($bytesLeft, $bufferSize);
158 $bytesLeft -= $bytesToRead;
159 echo $this->php->fread($fileHandler, $bytesToRead);
160 $this->clearBuffer();
161
162 if ($this->php->connectionStatus() !== 0) {
163 $this->php->fClose($fileHandler);
164 break;
165 }
166 }
167 }
168
169 private function getRanges(int $fileSize): array
170 {
171 $httpRange = explode('=', $_SERVER['HTTP_RANGE']);
172
173 if ($httpRange[0] !== 'bytes') {
174 return [];
175 }
176
177 $ranges = [];
178
179 foreach (explode(',', $httpRange[1] ?? '') as $originRange) {
180 $range = $this->getSeekStartEnd($originRange, $fileSize);
181
182 if ($range === null) {
183 return [];
184 }
185
186 $ranges[] = $range;
187 }
188
189 return $ranges;
190 }
191
192 private function getExtraContents(string $file, array $ranges, ?int &$contentLength, ?string &$boundary): array
193 {
194 $contentLength = 0;
195 $extraContents = [];
196
197 if (count($ranges) <= 1) {
198 return $extraContents;
199 }
200
201 $boundary = 'g45d64df96bmdf4sdgh45hf5';
202 $fullBoundary = "\r\n--$boundary--\r\n";
203 $fileSize = filesize($file);
204 $mimeType = $this->getFileMimeType($file);
205
206 foreach ($ranges as $index => $range) {
207 [$seekStart, $seekEnd] = $range;
208 $extraContent = $fullBoundary
209 . "Content-Type: $mimeType\r\n"
210 . "Content-Range: bytes $seekStart-$seekEnd/$fileSize\r\n\r\n";
211 $extraContents[$index] = $extraContent;
212 $contentLength += strlen($extraContent) + ($seekEnd - $seekStart + 1);
213 }
214
215 $contentLength += strlen($fullBoundary);
216 $extraContents[] = $fullBoundary;
217
218 return $extraContents;
219 }
220
221 private function deliverFilePartial(string $file, bool $isInline): void
222 {
223 $fileSize = filesize($file);
224 $ranges = $this->getRanges($fileSize);
225
226 if ($ranges === []) {
227 $this->php->header('HTTP/1.1 416 Requested Range Not Satisfiable');
228 $this->php->header("Content-Range: */$fileSize");
229
230 return;
231 }
232
233 $extraContents = $this->getExtraContents($file, $ranges, $contentLength, $boundary);
234
235 $this->php->header('HTTP/1.1 206 Partial Content');
236 $this->php->header('Content-Transfer-Encoding: binary');
237 $this->php->header('Accept-Ranges: bytes');
238
239 if ($extraContents === []) {
240 $this->addDefaultHeader($file, $isInline);
241 [$seekStart, $seekEnd] = $ranges[0];
242 $contentLength = ($seekEnd - $seekStart + 1);
243 $this->php->header("Content-Range: bytes $seekStart-$seekEnd/$fileSize");
244 } else {
245 $this->php->header("Content-Type: multipart/x-byteranges; boundary=$boundary");
246 }
247
248 $this->php->header("Content-Length: $contentLength");
249 $fileHandler = fopen($file, 'r');
250
251 foreach ($ranges as $index => $range) {
252 if (isset($extraContents[$index]) === true) {
253 echo $extraContents[$index];
254 }
255
256 [$seekStart, $seekEnd] = $range;
257 $this->php->fseek($fileHandler, $seekStart);
258 $this->readFilePartly($fileHandler, $seekEnd - $seekStart + 1);
259 }
260
261 if ($extraContents !== []) {
262 echo end($extraContents);
263 $this->clearBuffer();
264 }
265 }
266
267 private function isInlineFile(string $file): bool
268 {
269 $inlineFiles = array_map('trim', explode(',', (string) $this->mainConfig->getInlineFiles()));
270 $map = array_flip($inlineFiles);
271 $extension = pathinfo($file, PATHINFO_EXTENSION);
272
273 return isset($map[$extension]);
274 }
275
276 private function isRangeRequest(): bool
277 {
278 return isset($_SERVER['HTTP_RANGE']) === true
279 && isset($_SERVER['REQUEST_METHOD']) === true
280 && $_SERVER['REQUEST_METHOD'] === 'GET';
281 }
282
283 #[NoReturn]
284 public function getFile(string $file, bool $isImage): void
285 {
286 if (file_exists($file) === false) {
287 $this->wordpress->wpDie(
288 TXT_UAM_FILE_NOT_FOUND_ERROR_MESSAGE,
289 TXT_UAM_FILE_NOT_FOUND_ERROR_TITLE,
290 ['response' => 404]
291 );
292
293 return;
294 }
295
296 $isInline = $isImage === true || $this->isInlineFile($file) === true;
297
298 if ($this->isRangeRequest() === true) {
299 $this->deliverFilePartial($file, $isInline);
300 } else {
301 $this->deliverFile($file, $isInline);
302 }
303
304 $this->php->callExit();
305 }
306
307 private function getCurrentFileProtectionHandler(): FileProtectionInterface
308 {
309 if ($this->wordpress->isNginx() === true) {
310 return $this->fileProtectionFactory->createNginxFileProtection();
311 }
312
313 return $this->fileProtectionFactory->createApacheFileProtection();
314 }
315
316 public function getFileProtectionFileName(): string
317 {
318 return $this->getCurrentFileProtectionHandler()->getFileNameWithPath(
319 $this->wordpressConfig->getUploadDirectory()
320 );
321 }
322
323 public function createFileProtection(?string $dir = null, ?string $objectType = null): bool
324 {
325 $directory = $dir ?? $this->wordpressConfig->getUploadDirectory();
326
327 return $directory !== null
328 && $this->getCurrentFileProtectionHandler()->create($directory, $objectType);
329 }
330
331 public function deleteFileProtection(?string $dir = null): bool
332 {
333 $directory = $dir ?? $this->wordpressConfig->getUploadDirectory();
334
335 return $directory !== null
336 && $this->getCurrentFileProtectionHandler()->delete($directory);
337 }
338
339 private function getXSendFileTestFilePath(): string
340 {
341 return $this->wordpressConfig->getUploadDirectory() . DIRECTORY_SEPARATOR . self::X_SEND_FILE_TEST_FILE;
342 }
343
344 #[NoReturn]
345 public function deliverXSendFileTestFile(): void
346 {
347 $file = $this->getXSendFileTestFilePath();
348 file_put_contents($file, 'success');
349
350 $this->php->header("X-Sendfile: $file");
351 $this->php->header('Content-Type: application/octet-stream');
352 $this->php->header('Content-Disposition: attachment; filename="' . basename($file) . '"');
353 $this->php->callExit();
354 }
355
356 public function removeXSendFileTestFile(): void
357 {
358 $file = $this->getXSendFileTestFilePath();
359
360 if ($this->php->isFile($file) === true) {
361 $this->php->unlink($file);
362 }
363 }
364 }
365