3ca436178807369eccd56384b5c8a9f5702448fa
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / action / ApiAction.class.php
1 <?php
2
3 namespace wcf\action;
4
5 use CuyZ\Valinor\Mapper\MappingError;
6 use FastRoute\Dispatcher\Result\MethodNotAllowed;
7 use FastRoute\Dispatcher\Result\NotMatched;
8 use FastRoute\RouteCollector;
9 use Laminas\Diactoros\Response\JsonResponse;
10 use Psr\Http\Message\ServerRequestInterface;
11 use Psr\Http\Message\ResponseInterface;
12 use Psr\Http\Server\RequestHandlerInterface;
13 use wcf\http\attribute\AllowHttpMethod;
14 use wcf\system\endpoint\event\ControllerCollecting;
15 use wcf\system\endpoint\IController;
16 use wcf\system\endpoint\RequestFailure;
17 use wcf\system\endpoint\RequestType;
18 use wcf\system\event\EventHandler;
19 use wcf\system\exception\PermissionDeniedException;
20 use wcf\system\exception\UserInputException;
21 use wcf\system\request\RouteHandler;
22
23 use function FastRoute\simpleDispatcher;
24
25 #[AllowHttpMethod('DELETE')]
26 final class ApiAction implements RequestHandlerInterface
27 {
28 #[\Override]
29 public function handle(ServerRequestInterface $request): ResponseInterface
30 {
31 $isSupportedVerb = match ($request->getMethod()) {
32 'DELETE', 'GET', 'POST' => true,
33 default => false,
34 };
35
36 if (!$isSupportedVerb) {
37 return $this->toErrorResponse(RequestFailure::MethodNotAllowed, 'unacceptable_method');
38 }
39
40 $endpoint = $this->getEndpointFromPathInfo(RouteHandler::getPathInfo());
41 if ($endpoint === null) {
42 return $this->toErrorResponse(RequestFailure::UnknownEndpoint, 'missing_endpoint');
43 }
44
45 // TODO: This is currently very inefficient and should be cached in some
46 // way, maybe even use a combined cache for both?
47 $event = new ControllerCollecting();
48 EventHandler::getInstance()->fire($event);
49
50 $dispatcher = simpleDispatcher(
51 static function (RouteCollector $r) use ($event) {
52 foreach ($event->getControllers() as $controller) {
53 $reflectionClass = new \ReflectionClass($controller);
54 $attribute = current($reflectionClass->getAttributes(RequestType::class, \ReflectionAttribute::IS_INSTANCEOF));
55 \assert($attribute !== false);
56
57 $apiController = $attribute->newInstance();
58
59 $r->addRoute($apiController->method->toString(), $apiController->uri, $controller);
60 }
61 },
62 [
63 // TODO: debug only
64 'cacheDisabled' => true,
65 ]
66 );
67
68 $result = $dispatcher->dispatch($request->getMethod(), $endpoint);
69
70 if ($result instanceof NotMatched) {
71 return $this->toErrorResponse(RequestFailure::UnknownEndpoint, 'unknown_endpoint');
72 }
73
74 if ($result instanceof MethodNotAllowed) {
75 return $this->toErrorResponse(RequestFailure::MethodNotAllowed, 'endpoint_does_not_allow_method');
76 }
77
78 /** @var IController */
79 $controller = $result->handler;
80
81 try {
82 return $controller($request, $result->variables);
83 } catch (MappingError $e) {
84 return $this->toErrorResponse(RequestFailure::ValidationFailed, 'mapping_error', $e->getMessage());
85 } catch (PermissionDeniedException) {
86 return $this->toErrorResponse(RequestFailure::PermissionDenied, 'permission_denied');
87 } catch (UserInputException $e) {
88 return $this->toErrorResponse(RequestFailure::ValidationFailed, $e->getType(), $e->getMessage(), $e->getField());
89 } catch (\Throwable $e) {
90 return $this->toErrorResponse(RequestFailure::InternalError, 'unknown_exception', $e->getMessage());
91 }
92 }
93
94 private function getEndpointFromPathInfo(string $pathInfo): ?string
95 {
96 if (!\str_starts_with($pathInfo, 'api/rpc/')) {
97 return null;
98 }
99
100 $endpoint = \mb_substr($pathInfo, \strlen('api/rpc/') - 1);
101
102 // The namespace and the primary object are always required.
103 if (\substr_count($endpoint, '/') < 2 || \str_ends_with($endpoint, '/')) {
104 return null;
105 }
106
107 return $endpoint;
108 }
109
110 private function toErrorResponse(
111 RequestFailure $reason,
112 string $code,
113 string $message = '',
114 string $param = ''
115 ): ResponseInterface {
116 return new JsonResponse([
117 'type' => $reason->toString(),
118 'code' => $code,
119 'message' => $message,
120 'param' => $param,
121 ], $reason->toStatusCode());
122 }
123 }