Use a single source of truth for temporary filenames
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / action / FileUploadAction.class.php
1 <?php
2
3 namespace wcf\action;
4
5 use Laminas\Diactoros\Response\EmptyResponse;
6 use Psr\Http\Message\ResponseInterface;
7 use Psr\Http\Message\ServerRequestInterface;
8 use Psr\Http\Server\RequestHandlerInterface;
9 use wcf\data\file\temporary\FileTemporary;
10 use wcf\http\Helper;
11 use wcf\system\exception\IllegalLinkException;
12 use wcf\system\io\AtomicWriter;
13 use wcf\system\io\File;
14
15 final class FileUploadAction implements RequestHandlerInterface
16 {
17 /**
18 * Read data in chunks to avoid hitting the memory limit.
19 * See https://stackoverflow.com/a/61997147
20 */
21 private const FREAD_BUFFER_SIZE = 10 * 1_024 * 1_024;
22
23 public function handle(ServerRequestInterface $request): ResponseInterface
24 {
25 // TODO: `sequenceNo` should be of type `non-negative-int`, but requires Valinor 1.7+
26 $parameters = Helper::mapQueryParameters(
27 $request->getQueryParams(),
28 <<<'EOT'
29 array {
30 checksum: non-empty-string,
31 identifier: non-empty-string,
32 sequenceNo: int,
33 }
34 EOT,
35 );
36
37 $fileTemporary = new FileTemporary($parameters['identifier']);
38 if (!$fileTemporary->identifier) {
39 // TODO: Proper error message
40 throw new IllegalLinkException();
41 }
42
43 // Check if this is a valid sequence no.
44 $numberOfChunks = $fileTemporary->getNumberOfChunks();
45 if ($parameters['sequenceNo'] >= $numberOfChunks) {
46 // TODO: Proper error message
47 throw new IllegalLinkException();
48 }
49
50 // Check if the checksum matches the received data.
51 $ctx = \hash_init('sha256');
52 $stream = $request->getBody();
53 while (!$stream->eof()) {
54 \hash_update($ctx, $stream->read(self::FREAD_BUFFER_SIZE));
55 }
56 $result = \hash_final($ctx);
57 $stream->rewind();
58
59 if ($result !== $parameters['checksum']) {
60 // TODO: Proper error message
61 throw new IllegalLinkException();
62 }
63
64 $folderA = \substr($fileTemporary->identifier, 0, 2);
65 $folderB = \substr($fileTemporary->identifier, 2, 2);
66
67 $tmpPath = \sprintf(
68 \WCF_DIR . '_data/private/fileUpload/%s/%s/',
69 $folderA,
70 $folderB,
71 );
72 if (!\is_dir($tmpPath)) {
73 \mkdir($tmpPath, recursive: true);
74 }
75
76 // Write the chunk using a buffer to avoid blowing up the memory limit.
77 // See https://stackoverflow.com/a/61997147
78 $result = new AtomicWriter($tmpPath . $fileTemporary->getChunkFilename($parameters['sequenceNo']));
79
80 while (!$stream->eof()) {
81 $result->write($stream->read(self::FREAD_BUFFER_SIZE));
82 }
83
84 $result->flush();
85
86 // Check if we have all chunks.
87 $data = [];
88 for ($i = 0; $i < $numberOfChunks; $i++) {
89 $chunkFilename = $fileTemporary->getChunkFilename($i);
90
91 if (\file_exists($tmpPath . $chunkFilename)) {
92 $data[] = $tmpPath . $chunkFilename;
93 }
94 }
95
96 if (\count($data) === $numberOfChunks) {
97 // Concatenate the files by reading only a limited buffer at a time
98 // to avoid blowing up the memory limit.
99 // See https://stackoverflow.com/a/61997147
100
101 $resultFilename = $fileTemporary->getResultFilename();
102 $result = new AtomicWriter($tmpPath . $resultFilename);
103 foreach ($data as $fileChunk) {
104 $source = new File($fileChunk, 'rb');
105 try {
106 while (!$source->eof()) {
107 $result->write($source->read(self::FREAD_BUFFER_SIZE));
108 }
109 } finally {
110 $source->close();
111 }
112 }
113
114 $result->flush();
115
116 // Check if the final result matches the expected checksum.
117 $checksum = \hash_file('sha256', $tmpPath . $resultFilename);
118 if ($checksum !== $fileTemporary->fileHash) {
119 // TODO: Proper error message
120 throw new IllegalLinkException();
121 }
122
123 // Remove the temporary chunks.
124 foreach ($data as $fileChunk) {
125 \unlink($fileChunk);
126 }
127
128 // TODO: Move the data from the temporary file to the actual "file".
129 }
130
131 return new EmptyResponse();
132 }
133 }