Add a helper attribute for object hydration
authorAlexander Ebert <ebert@woltlab.com>
Sun, 9 Jun 2024 19:25:20 +0000 (21:25 +0200)
committerAlexander Ebert <ebert@woltlab.com>
Sun, 9 Jun 2024 19:25:20 +0000 (21:25 +0200)
wcfsetup/install/files/lib/action/ApiAction.class.php
wcfsetup/install/files/lib/system/endpoint/HydrateFromRequestParameter.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/PostChunk.class.php

index 8d3a57b6c4e3f15fd1f56af7dea583771f66bf37..8335a8a53318a274ddbcc1771d3fac36699d95e2 100644 (file)
@@ -10,9 +10,11 @@ use Laminas\Diactoros\Response\JsonResponse;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Server\RequestHandlerInterface;
+use wcf\data\DatabaseObject;
 use wcf\event\endpoint\ControllerCollecting;
 use wcf\http\attribute\AllowHttpMethod;
 use wcf\system\cache\builder\ApiEndpointCacheBuilder;
+use wcf\system\endpoint\HydrateFromRequestParameter;
 use wcf\system\endpoint\IController;
 use wcf\system\endpoint\RequestFailure;
 use wcf\system\endpoint\RequestType;
@@ -88,6 +90,8 @@ final class ApiAction implements RequestHandlerInterface
         $controller = $result->handler;
 
         try {
+            $this->hydrateFromRequestParameters($controller, $result->variables);
+
             return $controller($request, $result->variables);
         } catch (MappingError $e) {
             return $this->toErrorResponse(RequestFailure::ValidationFailed, 'mapping_error', $e->getMessage());
@@ -118,6 +122,72 @@ final class ApiAction implements RequestHandlerInterface
         return $endpoint;
     }
 
+    private function hydrateFromRequestParameters(
+        IController $controller,
+        /** @var array<string, string> */
+        array $variables
+    ): void {
+        $reflectionClass = new \ReflectionClass($controller);
+        $properties = $reflectionClass->getProperties(\ReflectionProperty::IS_PUBLIC);
+        foreach ($properties as $property) {
+            $attribute = $property->getAttributes(HydrateFromRequestParameter::class)[0] ?? false;
+            if ($attribute === false) {
+                continue;
+            }
+
+            $propertyName = \sprintf(
+                '%s::$%s',
+                $reflectionClass->getName(),
+                $property->getName(),
+            );
+
+            $propertyType = $property->getType();
+            if ($propertyType === null) {
+                throw new \RuntimeException("Cannot determine the type of {$propertyName}.");
+            }
+
+            if (
+                !($propertyType instanceof \ReflectionNamedType)
+                || !\is_subclass_of($propertyType->getName(), DatabaseObject::class)
+            ) {
+                throw new \RuntimeException(
+                    \sprintf(
+                        "Only types deriving from %s are permitted for %s.",
+                        DatabaseObject::class,
+                        $propertyName,
+                    ),
+                );
+            }
+
+            $variableName = $attribute->newInstance()->parameterName;
+            if (!isset($variables[$variableName])) {
+                throw new \RuntimeException(
+                    \sprintf(
+                        "The variable '%s' for %s does not appear in the request variables, please check its spelling and if it appears in the route definition.",
+                        $variableName,
+                        $propertyName,
+                    ),
+                );
+            }
+
+            if ($property->isReadOnly()) {
+                throw new \RuntimeException("{$propertyName} must not be declared as readonly.");
+            }
+
+            $className = $propertyType->getName();
+            $dbo = new $className($variables[$variableName]);
+            \assert($dbo instanceof DatabaseObject);
+
+            if (!$dbo->getObjectID()) {
+                throw new UserInputException(
+                    $dbo->getDatabaseTableIndexName(),
+                );
+            }
+
+            $controller->{$property->getName()} = $dbo;
+        }
+    }
+
     private function toErrorResponse(
         RequestFailure $reason,
         string $code,
diff --git a/wcfsetup/install/files/lib/system/endpoint/HydrateFromRequestParameter.class.php b/wcfsetup/install/files/lib/system/endpoint/HydrateFromRequestParameter.class.php
new file mode 100644 (file)
index 0000000..1576584
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace wcf\system\endpoint;
+
+/**
+ * Hydrates an object based on the parameter from the request URI. Performs a
+ * check if the resulting object id is truthy.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.1
+ */
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
+final class HydrateFromRequestParameter
+{
+    public function __construct(
+        public readonly string $parameterName
+    ) {
+    }
+}
index 5e8a5ef4db9b6f5d5f3622ab9706d354aa5a5add..339a3f5f14ec7cf8cb09bb225a3f27d99bb67958 100644 (file)
@@ -8,6 +8,7 @@ use Psr\Http\Message\ResponseInterface;
 use wcf\data\file\FileEditor;
 use wcf\data\file\temporary\FileTemporary;
 use wcf\data\file\temporary\FileTemporaryEditor;
+use wcf\system\endpoint\HydrateFromRequestParameter;
 use wcf\system\endpoint\IController;
 use wcf\system\endpoint\PostRequest;
 use wcf\system\exception\UserInputException;
@@ -16,6 +17,9 @@ use wcf\system\io\File;
 #[PostRequest('/core/files/upload/{identifier}/chunk/{sequenceNo:\d+}')]
 final class PostChunk implements IController
 {
+    #[HydrateFromRequestParameter('identifier')]
+    public FileTemporary $fileTemporary;
+
     /**
      * Read data in chunks to avoid hitting the memory limit.
      * See https://stackoverflow.com/a/61997147
@@ -30,38 +34,32 @@ final class PostChunk implements IController
             throw new UserInputException('chunk-checksum-sha256');
         }
 
-        $identifier = $variables['identifier'];
         $sequenceNo = $variables['sequenceNo'];
 
-        $fileTemporary = new FileTemporary($identifier);
-        if (!$fileTemporary->identifier) {
-            throw new UserInputException('identifier');
-        }
-
         // Check if this is a valid sequence no.
-        if ($sequenceNo >= $fileTemporary->getChunkCount()) {
+        if ($sequenceNo >= $this->fileTemporary->getChunkCount()) {
             throw new UserInputException('sequenceNo', 'outOfRange');
         }
 
         // Check if this chunk has already been written.
-        if ($fileTemporary->hasChunk($sequenceNo)) {
+        if ($this->fileTemporary->hasChunk($sequenceNo)) {
             throw new UserInputException('sequenceNo', 'alreadyExists');
         }
 
         // Validate the chunk size.
-        $chunkSize = $fileTemporary->getChunkSize();
+        $chunkSize = $this->fileTemporary->getChunkSize();
         $stream = $request->getBody();
         $receivedSize = $stream->getSize();
         if ($receivedSize !== null && $receivedSize > $chunkSize) {
             throw new UserInputException('payload', 'tooLarge');
         }
 
-        $tmpPath = $fileTemporary->getPath();
+        $tmpPath = $this->fileTemporary->getPath();
         if (!\is_dir($tmpPath)) {
             \mkdir($tmpPath, recursive: true);
         }
 
-        $file = new File($tmpPath . $fileTemporary->getFilename(), 'cb+');
+        $file = new File($tmpPath . $this->fileTemporary->getFilename(), 'cb+');
         $file->lock(\LOCK_EX);
         $file->seek($sequenceNo * $chunkSize);
 
@@ -91,25 +89,25 @@ final class PostChunk implements IController
         }
 
         // Mark the chunk as written.
-        $chunks = $fileTemporary->chunks;
+        $chunks = $this->fileTemporary->chunks;
         $chunks[$sequenceNo] = '1';
-        (new FileTemporaryEditor($fileTemporary))->update([
+        (new FileTemporaryEditor($this->fileTemporary))->update([
             'chunks' => $chunks,
         ]);
 
         // Check if we have all chunks.
-        if ($chunks === \str_repeat('1', $fileTemporary->getChunkCount())) {
+        if ($chunks === \str_repeat('1', $this->fileTemporary->getChunkCount())) {
             // Check if the final result matches the expected checksum.
-            $checksum = \hash_file('sha256', $tmpPath . $fileTemporary->getFilename());
-            if ($checksum !== $fileTemporary->fileHash) {
+            $checksum = \hash_file('sha256', $tmpPath . $this->fileTemporary->getFilename());
+            if ($checksum !== $this->fileTemporary->fileHash) {
                 throw new UserInputException('file', 'checksum');
             }
 
-            $file = FileEditor::createFromTemporary($fileTemporary);
+            $file = FileEditor::createFromTemporary($this->fileTemporary);
 
-            $context = $fileTemporary->getContext();
-            (new FileTemporaryEditor($fileTemporary))->delete();
-            unset($fileTemporary);
+            $context = $this->fileTemporary->getContext();
+            (new FileTemporaryEditor($this->fileTemporary))->delete();
+            unset($this->fileTemporary);
 
             $processor = $file->getProcessor();
             $processor?->adopt($file, $context);