Prototype for the delegation of attachments to the file API
[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 Laminas\Diactoros\Response\JsonResponse;
7 use Psr\Http\Message\ResponseInterface;
8 use Psr\Http\Message\ServerRequestInterface;
9 use Psr\Http\Server\RequestHandlerInterface;
10 use wcf\data\file\FileEditor;
11 use wcf\data\file\temporary\FileTemporary;
12 use wcf\data\file\temporary\FileTemporaryEditor;
13 use wcf\http\Helper;
14 use wcf\system\exception\IllegalLinkException;
15 use wcf\system\io\File as IoFile;
16
17 final class FileUploadAction implements RequestHandlerInterface
18 {
19 /**
20 * Read data in chunks to avoid hitting the memory limit.
21 * See https://stackoverflow.com/a/61997147
22 */
23 private const FREAD_BUFFER_SIZE = 10 * 1_024 * 1_024;
24
25 public function handle(ServerRequestInterface $request): ResponseInterface
26 {
27 // TODO: `sequenceNo` should be of type `non-negative-int`, but requires Valinor 1.7+
28 $parameters = Helper::mapQueryParameters(
29 $request->getQueryParams(),
30 <<<'EOT'
31 array {
32 checksum: non-empty-string,
33 identifier: non-empty-string,
34 sequenceNo: int,
35 }
36 EOT,
37 );
38
39 $fileTemporary = new FileTemporary($parameters['identifier']);
40 if (!$fileTemporary->identifier) {
41 // TODO: Proper error message
42 throw new IllegalLinkException();
43 }
44
45 // Check if this is a valid sequence no.
46 if ($parameters['sequenceNo'] >= $fileTemporary->getChunkCount()) {
47 // TODO: Proper error message
48 throw new IllegalLinkException();
49 }
50
51 // Check if this chunk has already been written.
52 if ($fileTemporary->hasChunk($parameters['sequenceNo'])) {
53 // 409 Conflict
54 return new EmptyResponse(409);
55 }
56
57 // Validate the chunk size.
58 $chunkSize = $fileTemporary->getChunkSize();
59 $stream = $request->getBody();
60 $receivedSize = $stream->getSize();
61 if ($receivedSize !== null && $receivedSize > $chunkSize) {
62 // 413 Content Too Large
63 return new EmptyResponse(413);
64 }
65
66 $tmpPath = $fileTemporary->getPath();
67 if (!\is_dir($tmpPath)) {
68 \mkdir($tmpPath, recursive: true);
69 }
70
71 $file = new IoFile($tmpPath . $fileTemporary->getFilename(), 'cb+');
72 $file->lock(\LOCK_EX);
73 $file->seek($parameters['sequenceNo'] * $chunkSize);
74
75 // Check if the checksum matches the received data.
76 $ctx = \hash_init('sha256');
77 $total = 0;
78 while (!$stream->eof()) {
79 // Write the chunk using a buffer to avoid blowing up the memory limit.
80 // See https://stackoverflow.com/a/61997147
81 $chunk = $stream->read(self::FREAD_BUFFER_SIZE);
82 $total += \strlen($chunk);
83
84 if ($total > $chunkSize) {
85 // 413 Content Too Large
86 return new EmptyResponse(413);
87 }
88
89 \hash_update($ctx, $chunk);
90 $file->write($chunk);
91 }
92 $file->sync();
93 $file->close();
94
95 $result = \hash_final($ctx);
96
97 if ($result !== $parameters['checksum']) {
98 // TODO: Proper error message
99 throw new IllegalLinkException();
100 }
101
102 // Mark the chunk as written.
103 $chunks = $fileTemporary->chunks;
104 $chunks[$parameters['sequenceNo']] = '1';
105 (new FileTemporaryEditor($fileTemporary))->update([
106 'chunks' => $chunks,
107 ]);
108
109 // Check if we have all chunks.
110 if ($chunks === \str_repeat('1', $fileTemporary->getChunkCount())) {
111 // Check if the final result matches the expected checksum.
112 $checksum = \hash_file('sha256', $tmpPath . $fileTemporary->getFilename());
113 if ($checksum !== $fileTemporary->fileHash) {
114 // TODO: Proper error message
115 throw new IllegalLinkException();
116 }
117
118 $file = FileEditor::createFromTemporary($fileTemporary);
119
120 $context = $fileTemporary->getContext();
121 (new FileTemporaryEditor($fileTemporary))->delete();
122 unset($fileTemporary);
123
124 $processor = $file->getProcessor();
125 if ($processor === null) {
126 // TODO: Mark the file as orphaned.
127 \assert($processor !== null);
128 }
129
130 $processor->adopt($file, $context);
131
132 // TODO: This is just debug code.
133 return new JsonResponse([
134 'file' => $file->getPath() . $file->getSourceFilename(),
135 ]);
136 }
137
138 return new EmptyResponse();
139 }
140 }