ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
ChunkedUploadBuffer.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
22
27
40{
45 private const CHUNK_DIRECTORY = 'irss_chunked_uploads';
46
48 private \ilLogger $chunk_logger;
49 private \ilLanguage $chunk_language;
50 private int $chunk_user_id;
51
52 private bool $is_chunked = false;
53 private string $uuid = '';
54 private int $chunk_index = 0;
55 private int $amount_of_chunks = 0;
56 private int $chunk_byte_offset = 0;
57 private int $chunk_total_size = 0;
58
63 abstract public function getFileIdentifierParameterName(): string;
64
65 protected function initChunkedUploadBuffer(): void
66 {
67 global $DIC;
68 $this->chunk_filesystem = $DIC->filesystem()->temp();
69 $this->chunk_logger = \ilLoggerFactory::getLogger('irss');
70 $this->chunk_language = $DIC->language();
71 $this->chunk_user_id = $DIC->user()->getId();
72 }
73
78 protected function readChunkInformation(): void
79 {
80 global $DIC;
81 $body = $DIC->http()->request()->getParsedBody();
82
83 $uuid = (string) ($body['dzuuid'] ?? '');
84 $amount_of_chunks = (int) ($body['dztotalchunkcount'] ?? 0);
85
86 // the uuid is used as a directory name, therefore nothing but the format
87 // the dropzone generates is accepted
88 if ($amount_of_chunks < 1 || preg_match('/^[0-9a-f-]{36}$/i', $uuid) !== 1) {
89 return;
90 }
91
92 $this->is_chunked = true;
93 $this->uuid = $uuid;
94 $this->amount_of_chunks = $amount_of_chunks;
95 $this->chunk_index = (int) ($body['dzchunkindex'] ?? 0);
96 $this->chunk_byte_offset = (int) ($body['dzchunkbyteoffset'] ?? 0);
97 $this->chunk_total_size = (int) ($body['dztotalfilesize'] ?? 0);
98 }
99
100 protected function isChunkedUpload(): bool
101 {
102 return $this->is_chunked;
103 }
104
115 protected function assembleChunk(array $upload_results, callable $store): BasicHandlerResult
116 {
117 $result = end($upload_results);
118 if (!$result instanceof UploadResult) {
119 return $this->chunkFailed('the request carried no upload result');
120 }
121 if (!$result->isOK()) {
122 $this->logChunkFailure('rejected while processing: ' . $result->getStatus()->getMessage());
123 return $this->discardChunks($this->failedResult($result->getStatus()->getMessage()));
124 }
125
126 // A fixed name keeps the uploaded file name out of the path the chunks are
127 // written to. It has to end in the clean suffix: the temp filesystem is
128 // wrapped in a FilesystemWhitelistDecorator, which rewrites the suffix of
129 // anything written to it but leaves the path of a read untouched - a
130 // buffer under any other suffix could be written but never read back.
131 $part_file = $this->getChunkDirectory() . '/upload.' . FilenameSanitizer::CLEAN_FILE_SUFFIX;
132
133 try {
134 if (!$this->chunk_filesystem->has($part_file)) {
135 $this->chunk_filesystem->write($part_file, '');
136 }
137 $part_path = $this->getLocalPath($part_file);
138
139 // the dropzone sends the chunks of a file strictly in order, an
140 // offset that does not match what has been written so far means the
141 // assembled file would be garbage
142 clearstatcache(true, $part_path);
143 $buffered = filesize($part_path);
144 if ($buffered !== $this->chunk_byte_offset) {
145 return $this->chunkFailed(
146 "chunk starts at byte $this->chunk_byte_offset but $buffered bytes are buffered"
147 );
148 }
149
150 $this->appendToFile($result->getPath(), $part_path);
151
152 if (($this->chunk_index + 1) < $this->amount_of_chunks) {
153 return new BasicHandlerResult(
156 '',
157 'chunk upload OK'
158 );
159 }
160
161 clearstatcache(true, $part_path);
162 $assembled_size = filesize($part_path);
163 if ($assembled_size !== $this->chunk_total_size) {
164 return $this->chunkFailed(
165 "assembled $assembled_size bytes, the upload announced $this->chunk_total_size"
166 );
167 }
168
169 // The name decides what the file ends up being called, so the
170 // assembled file has to carry the uploaded one and be alone in its
171 // directory. Moving it is done on the local path: going through the
172 // temp filesystem would hand the name to the whitelist decorator,
173 // which would rewrite the suffix the user uploaded.
174 $file_name = basename($result->getName());
175 $assembly_directory = dirname($part_path) . '/file';
176 $assembled_path = $assembly_directory . '/' . $file_name;
177
178 if (!is_dir($assembly_directory) && !mkdir($assembly_directory, 0700, true) && !is_dir($assembly_directory)) {
179 return $this->chunkFailed("could not create the directory '$assembly_directory'");
180 }
181 if (!rename($part_path, $assembled_path)) {
182 return $this->chunkFailed("could not move the assembled file to '$assembled_path'");
183 }
184
185 $identifier = $store($assembled_path, $file_name);
186 if ($identifier === null) {
187 return $this->chunkFailed('the assembled file was not stored');
188 }
189
190 return $this->discardChunks(
194 $identifier,
195 'file upload OK'
196 )
197 );
198 } catch (\Throwable $exception) {
199 return $this->chunkFailed($exception::class . ': ' . $exception->getMessage());
200 }
201 }
202
203 protected function failedResult(string $message = ''): BasicHandlerResult
204 {
205 return new BasicHandlerResult(
208 '',
209 $message !== '' ? $message : $this->chunk_language->txt('rids_appended_failed')
210 );
211 }
212
217 private function getChunkDirectory(): string
218 {
219 return self::CHUNK_DIRECTORY . '/' . $this->chunk_user_id . '/' . $this->uuid;
220 }
221
222 private function getLocalPath(string $path_in_temp_filesystem): string
223 {
224 $stream = $this->chunk_filesystem->readStream($path_in_temp_filesystem);
225 $uri = $stream->getMetadata()['uri'];
226 $stream->close();
227
228 return (string) $uri;
229 }
230
231 private function appendToFile(string $source_path, string $target_path): void
232 {
233 $source = fopen($source_path, 'rb');
234 $target = fopen($target_path, 'ab');
235
236 try {
237 if ($source === false || $target === false) {
238 throw new \RuntimeException("Could not append '$source_path' to '$target_path'.");
239 }
240 stream_copy_to_stream($source, $target);
241 } finally {
242 if ($source !== false) {
243 fclose($source);
244 }
245 if ($target !== false) {
246 fclose($target);
247 }
248 }
249 }
250
257 {
258 try {
259 if ($this->chunk_filesystem->hasDir($this->getChunkDirectory())) {
260 $this->chunk_filesystem->deleteDir($this->getChunkDirectory());
261 }
262 } catch (\Throwable) {
263 // a leftover directory must not turn a successful upload into a failure
264 }
265
266 return $result;
267 }
268
273 private function chunkFailed(string $reason): BasicHandlerResult
274 {
275 $this->logChunkFailure($reason);
276
277 return $this->discardChunks($this->failedResult());
278 }
279
280 private function logChunkFailure(string $reason): void
281 {
282 $this->chunk_logger->warning(sprintf(
283 'chunked upload %s of user %d failed on chunk %d of %d: %s',
284 $this->uuid,
285 $this->chunk_user_id,
286 $this->chunk_index + 1,
287 $this->amount_of_chunks,
288 $reason
289 ));
290 }
291}
static getLogger(string $a_component_id)
Get component logger.
The filesystem interface provides the public interface for the Filesystem service API consumer.
Definition: Filesystem.php:37
The filename sanitizer verifies and fixes file name endings.
const CLEAN_FILE_SUFFIX
This file suffix will be used to sanitize not whitelisted file names.
$body
Definition: ltiauth.php:41
trait ChunkedUploadBuffer
Reassembles an upload the dropzone sent in chunks.
readChunkInformation()
The dropzone announces a chunked upload with these fields in the body of every single chunk request.
chunkFailed(string $reason)
Every way a chunked upload can fail ends up here.
getFileIdentifierParameterName()
Provided by the UploadHandler both users of this trait implement.
appendToFile(string $source_path, string $target_path)
assembleChunk(array $upload_results, callable $store)
Buffers the chunk of the current request and, once it was the last one, hands the reassembled file to...
getLocalPath(string $path_in_temp_filesystem)
getChunkDirectory()
Chunks are kept apart per user: the uuid identifying an upload is chosen by the client and must not a...
discardChunks(BasicHandlerResult $result)
Removes everything buffered for the current upload, whether it completed or failed.
global $DIC
Definition: shib_login.php:26