diff --git a/composer.lock b/composer.lock index 0883a28..d97ff3d 100644 --- a/composer.lock +++ b/composer.lock @@ -47,11 +47,11 @@ }, { "name": "flashwave/index", - "version": "v0.2410.211811", + "version": "v0.2410.630140", "source": { "type": "git", "url": "https://patchii.net/flash/index.git", - "reference": "40cbd35ba3855056987d2f7647f669e66f938979" + "reference": "469391f9b601bf30553252470f175588744d4c18" }, "require": { "ext-mbstring": "*", @@ -60,8 +60,8 @@ "twig/twig": "^3.14" }, "require-dev": { - "phpstan/phpstan": "^1.11", - "phpunit/phpunit": "^11.2" + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.4" }, "suggest": { "ext-memcache": "Support for the Index\\Cache\\Memcached namespace (only if you can't use ext-memcached for some reason).", @@ -98,7 +98,7 @@ ], "description": "Composer package for the common library for my projects.", "homepage": "https://railgun.sh/index", - "time": "2024-10-21T18:15:09+00:00" + "time": "2024-12-02T01:41:44+00:00" }, { "name": "guzzlehttp/psr7", diff --git a/phpstan.neon b/phpstan.neon index 3261a0d..b518652 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 5 + level: 9 treatPhpDocTypesAsCertain: false paths: - src diff --git a/src/AuthzContext.php b/src/AuthzContext.php index 8638201..c7919c3 100644 --- a/src/AuthzContext.php +++ b/src/AuthzContext.php @@ -3,6 +3,7 @@ namespace Syokuhou; use RuntimeException; use RPCii\Client\RpcClient; +use Index\Http\{HttpRequest,HttpResponseBuilder}; class AuthzContext { public private(set) bool $authed = false; @@ -11,8 +12,9 @@ class AuthzContext { public private(set) string $type = ''; public private(set) string $userId = ''; public private(set) string $appId = ''; + /** @var ?mixed[] */ private ?array $scopeRaw = null; - private ?int $expiresAtRaw = null; + private ?int $expiresAtRaw = null; // @phpstan-ignore-line: property hook skill issue public function __construct( private RpcClient $rpcClient @@ -68,13 +70,13 @@ class AuthzContext { get => $this->scopeRaw === null ? '' : implode(' ', $this->scopeRaw); } + /** @var string[] */ public array $scope { get => $this->scopeRaw ?? []; } public function hasScope(string $scope): bool { - return $this->scopeRaw === null - || in_array($scope, $this->scopeRaw); + return $this->isUser && ($this->hasWildcardScope || in_array($scope, $this->scopeRaw)); } public int $expiresAt { @@ -88,7 +90,9 @@ class AuthzContext { private function handleRpcResponse(mixed $info): void { if(!is_array($info)) { - $this->error = json_encode($info); + $data = json_encode($info); + if(!$data) $data = 'data'; + $this->error = $data; return; } @@ -133,7 +137,7 @@ class AuthzContext { $this->attemptBasicAppAuthInternal($remoteAddr, $clientId, $clientSecret); } - public function basicAppAuthMiddleware($response, $request): void { + public function basicAppAuthMiddleware(HttpResponseBuilder $response, HttpRequest $request): void { if($this->authed) return; @@ -177,7 +181,7 @@ class AuthzContext { $this->attemptBearerTokenAuthInternal($remoteAddr, $bearerToken); } - public function bearerTokenAuthMiddleware($response, $request): void { + public function bearerTokenAuthMiddleware(HttpResponseBuilder $response, HttpRequest $request): void { if($this->authed) return; @@ -219,7 +223,7 @@ class AuthzContext { $this->attemptMisuzuTokenAuthInternal($remoteAddr, $misuzuToken); } - public function misuzuTokenAuthMiddleware($response, $request) { + public function misuzuTokenAuthMiddleware(HttpResponseBuilder $response, HttpRequest $request): void { if($this->authed) return; @@ -241,4 +245,14 @@ class AuthzContext { $misuzuToken ); } + + public function misuzuCookieAuthMiddleware(HttpResponseBuilder $response, HttpRequest $request): void { + if($this->authed || !$request->hasCookie('msz_auth')) + return; + + $this->attemptMisuzuTokenAuthInternal( + (string)filter_input(INPUT_SERVER, 'REMOTE_ADDR'), + $request->getCookie('msz_auth') + ); + } } diff --git a/src/HttpAcceptHeader.php b/src/HttpAcceptHeader.php index 2f60af3..9e3269e 100644 --- a/src/HttpAcceptHeader.php +++ b/src/HttpAcceptHeader.php @@ -4,6 +4,7 @@ namespace Syokuhou; use Index\{MediaType,XString}; class HttpAcceptHeader { + /** @param MediaType[] $accept */ public function __construct( private array $accept, ) { @@ -22,6 +23,7 @@ class HttpAcceptHeader { return false; } + /** @param array $options */ public function getMostPreferred(array $options = []): ?string { if(empty($options)) return null; diff --git a/src/OAuth2/OAuth2Routes.php b/src/OAuth2/OAuth2Routes.php index b258dce..a54fb66 100644 --- a/src/OAuth2/OAuth2Routes.php +++ b/src/OAuth2/OAuth2Routes.php @@ -2,9 +2,11 @@ namespace Syokuhou\OAuth2; use RuntimeException; +use Stringable; use Syokuhou\SyokuhouContext; use Syokuhou\RpcModels\Hanyuu\OAuth2RfcModel; use Index\Config\Config; +use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder}; use Index\Http\Routing\{HandlerAttribute,HttpGet,HttpOptions,HttpPost,Router,RouteHandler,RouteHandlerTrait}; class OAuth2Routes implements RouteHandler { @@ -25,12 +27,17 @@ class OAuth2Routes implements RouteHandler { HandlerAttribute::register($router, $this); } - private static function filter($response, array $result, bool $authzHeader = false): array { + /** + * @param array $result + * @return array + */ + private static function filter(HttpResponseBuilder $response, array $result, bool $authzHeader = false): array { if(array_key_exists('error', $result)) { if($authzHeader) { - $wwwAuth = sprintf('Basic realm="%s"', $_SERVER['HTTP_HOST']); + $wwwAuth = sprintf('Basic realm="%s"', (string)filter_input(INPUT_SERVER, 'HTTP_HOST')); foreach($result as $name => $value) - $wwwAuth .= sprintf(', %s="%s"', $name, rawurlencode($value)); + if(is_scalar($value) || $value instanceof Stringable) + $wwwAuth .= sprintf(', %s="%s"', $name, rawurlencode((string)$value)); $response->setStatusCode(401); $response->setHeader('WWW-Authenticate', $wwwAuth); @@ -41,9 +48,10 @@ class OAuth2Routes implements RouteHandler { return $result; } + /** @return void|int */ #[HttpGet('/authorise')] #[HttpGet('/authorize')] - public function getAuthorise($response, $request) { + public function getAuthorise(HttpResponseBuilder $response, HttpRequest $request) { $url = trim($this->config->getString('authorise')); if($url === '') return 404; @@ -57,9 +65,12 @@ class OAuth2Routes implements RouteHandler { $response->redirect($url); } + /** + * @return array + */ #[HttpPost('/request-authorise')] #[HttpPost('/request-authorize')] - public function postRequestAuthorise($response, $request) { + public function postRequestAuthorise(HttpResponseBuilder $response, HttpRequest $request): array { $response->setHeader('Cache-Control', 'no-store'); if($this->ctx->authz->error === 'secret') @@ -68,20 +79,21 @@ class OAuth2Routes implements RouteHandler { 'error_description' => 'Provided client secret is not correct for this application.', ], authzHeader: true); - if(!$request->isFormContent()) + $content = $request->getContent(); + if(!($content instanceof FormHttpContent)) return self::filter($response, [ 'error' => 'invalid_request', 'error_description' => 'Your request must use content type application/x-www-form-urlencoded.', ]); $rpc = $this->ctx->rpc; - $content = $request->getContent(); - if(!$this->ctx->authz->authed) + if(!$this->ctx->authz->authed) { $this->ctx->authz->attemptBasicAppAuth( (string)filter_input(INPUT_SERVER, 'REMOTE_ADDR'), (string)$content->getParam('client_id') ); + } if(!$this->ctx->authz->isApp) return self::filter($response, [ @@ -90,10 +102,17 @@ class OAuth2Routes implements RouteHandler { ]); try { - $reqInfo = new OAuth2RfcModel($rpc->action('hanyuu:oauth2:createAuthoriseRequest', [ + $reqInfo = $rpc->action('hanyuu:oauth2:createAuthoriseRequest', [ 'appId' => $this->ctx->authz->appId, 'scope' => (string)$content->getParam('scope'), - ])); + ]); + if(!is_array($reqInfo)) + return self::filter($response, [ + 'error' => 'server_error', + 'error_description' => 'Unable to create authorisation request.', + ]); + + $reqInfo = new OAuth2RfcModel($reqInfo); } catch(RuntimeException $ex) { $reqInfo = null; } @@ -107,9 +126,12 @@ class OAuth2Routes implements RouteHandler { return self::filter($response, $reqInfo->raw); } + /** + * @return int|array + */ #[HttpOptions('/token')] #[HttpPost('/token')] - public function postToken($response, $request) { + public function postToken(HttpResponseBuilder $response, HttpRequest $request): int|array { $response->setHeader('Cache-Control', 'no-store'); $originHeaders = ['Origin', 'X-Origin', 'Referer']; @@ -125,7 +147,7 @@ class OAuth2Routes implements RouteHandler { // different origins being specified for each header should probably also be considered suspect... $response->setHeader('Access-Control-Allow-Origin', $origins[0]); - $response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, POST'); + $response->setHeader('Access-Control-Allow-Methods', 'POST'); $response->setHeader('Access-Control-Allow-Headers', 'Authorization'); $response->setHeader('Access-Control-Expose-Headers', 'Vary'); foreach($originHeaders as $originHeader) @@ -135,14 +157,14 @@ class OAuth2Routes implements RouteHandler { if($request->getMethod() === 'OPTIONS') return 204; - if(!$request->isFormContent()) + $content = $request->getContent(); + if(!($content instanceof FormHttpContent)) return self::filter($response, [ 'error' => 'invalid_request', 'error_description' => 'Your request must use content type application/x-www-form-urlencoded.', ]); $rpc = $this->ctx->rpc; - $content = $request->getContent(); $authzHeader = true; if(!$this->ctx->authz->authed) { diff --git a/src/RpcClientWrapper.php b/src/RpcClientWrapper.php index 0c2dfab..e10692c 100644 --- a/src/RpcClientWrapper.php +++ b/src/RpcClientWrapper.php @@ -6,6 +6,7 @@ use Index\Config\Config; use RPCii\Client\{RpcClient,HttpRpcClient,ScopedRpcClient}; class RpcClientWrapper implements RpcClient { + /** @var array */ private array $clients = []; public function createHmacConfig(Config $config, string $prefix): void { @@ -17,6 +18,7 @@ class RpcClientWrapper implements RpcClient { ); } + /** @param callable(): string $getSecretKey */ public function createHmac(string $prefix, string $url, $getSecretKey): void { $this->register($prefix, HttpRpcClient::createHmac($url, $getSecretKey)); } diff --git a/src/RpcModels/RpcModel.php b/src/RpcModels/RpcModel.php index 5a01b7f..0ed9f46 100644 --- a/src/RpcModels/RpcModel.php +++ b/src/RpcModels/RpcModel.php @@ -2,11 +2,10 @@ namespace Syokuhou\RpcModels; abstract class RpcModel { - protected array $info; - - public function __construct(array $info) { - $this->info = $info; - } + /** @param array $raw */ + public function __construct( + public private(set) array $raw + ) {} public bool $hasError { get => $this->hasValue('error'); @@ -16,40 +15,37 @@ abstract class RpcModel { get => $this->getString('error'); } - public array $raw { - get => $this->info; - } - protected function hasValue(string $name): bool { - return array_key_exists($name, $this->info); + return array_key_exists($name, $this->raw); } protected function getValue(string $name, mixed $fallback = null): mixed { - return array_key_exists($name, $this->info) ? $this->info[$name] : $fallback; + return array_key_exists($name, $this->raw) ? $this->raw[$name] : $fallback; } + /** @return mixed[] */ protected function getArray(string $name): array { - return array_key_exists($name, $this->info) && is_array($this->info[$name]) - ? $this->info[$name] : []; + return array_key_exists($name, $this->raw) && is_array($this->raw[$name]) + ? $this->raw[$name] : []; } protected function getBoolean(string $name): bool { - return array_key_exists($name, $this->info) && is_bool($this->info[$name]) - && $this->info[$name]; + return array_key_exists($name, $this->raw) && is_bool($this->raw[$name]) + && $this->raw[$name]; } protected function getInteger(string $name): int { - return array_key_exists($name, $this->info) && is_int($this->info[$name]) - ? $this->info[$name] : 0; + return array_key_exists($name, $this->raw) && is_int($this->raw[$name]) + ? $this->raw[$name] : 0; } protected function getFloat(string $name): float { - return array_key_exists($name, $this->info) && is_float($this->info[$name]) - ? $this->info[$name] : 0.0; + return array_key_exists($name, $this->raw) && is_float($this->raw[$name]) + ? $this->raw[$name] : 0.0; } protected function getString(string $name): string { - return array_key_exists($name, $this->info) && is_string($this->info[$name]) - ? $this->info[$name] : ''; + return array_key_exists($name, $this->raw) && is_string($this->raw[$name]) + ? $this->raw[$name] : ''; } } diff --git a/src/SyokuhouContext.php b/src/SyokuhouContext.php index c96d720..8e26715 100644 --- a/src/SyokuhouContext.php +++ b/src/SyokuhouContext.php @@ -3,6 +3,7 @@ namespace Syokuhou; use RuntimeException; use Index\Config\Config; +use Index\Http\{HttpRequest,HttpResponseBuilder}; use Index\Http\Routing\{HttpRouter,Router}; class SyokuhouContext { @@ -14,6 +15,7 @@ class SyokuhouContext { $this->rpc = new RpcClientWrapper; $this->rpc->createHmacConfig($config, 'hanyuu'); $this->rpc->createHmacConfig($config, 'misuzu'); + $this->rpc->createHmacConfig($config, 'eeprom'); $this->authz = new AuthzContext($this->rpc); @@ -26,10 +28,11 @@ class SyokuhouContext { $this->router->get('/', fn() => 'Hello! Someday this page will probably redirect to documentation, but none exists yet.'); $this->router->scopeTo('/oauth2')->register(new OAuth2\OAuth2Routes($this, $config->scopeTo('oauth2'))); - $this->router->scopeTo('/v1')->register(new V1\V1Routes(new V1\V1Context($this))); + $this->router->scopeTo('/v1')->register(new V1\V1Routes(new V1\V1Context($this, $config->scopeTo('v1')))); } - public function handleAcceptHeader($response, $request) { + /** @return void|int */ + public function handleAcceptHeader(HttpResponseBuilder $response, HttpRequest $request) { $accept = HttpAcceptHeader::parse($request->getHeaderLine('Accept')); $contentHandler = new SyokuhouContentHandler($accept); diff --git a/src/SyokuhouErrorHandler.php b/src/SyokuhouErrorHandler.php index 7473163..71b163d 100644 --- a/src/SyokuhouErrorHandler.php +++ b/src/SyokuhouErrorHandler.php @@ -10,7 +10,7 @@ class SyokuhouErrorHandler implements HttpErrorHandler { public function handle(HttpResponseBuilder $response, HttpRequest $request, int $code, string $message): void { $this->contentHandler->handle($response, [ - 'code' => sprintf('http:%s', $code), + 'error' => sprintf('http:%s', $code), 'message' => $message, ]); } diff --git a/src/V1/V1Context.php b/src/V1/V1Context.php index 0959cce..4001142 100644 --- a/src/V1/V1Context.php +++ b/src/V1/V1Context.php @@ -1,12 +1,14 @@ $this->ctx->authz; } + + public array $allowCookieOrigins { + get => $this->config->getArray('allowCookie', []); + } } diff --git a/src/V1/V1EmotesRoutes.php b/src/V1/V1EmotesRoutes.php index 83c6502..628cef6 100644 --- a/src/V1/V1EmotesRoutes.php +++ b/src/V1/V1EmotesRoutes.php @@ -1,6 +1,7 @@ setHeader('Access-Control-Allow-Origin', '*'); - $response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET'); - $response->setHeader('Access-Control-Allow-Headers', 'Cache-Control'); + public function getEmotes(HttpResponseBuilder $response, HttpRequest $request): int|array { + $response->setHeader('Access-Control-Allow-Methods', 'GET'); + $response->addHeader('Access-Control-Allow-Headers', 'Cache-Control'); $response->setHeader('Cache-Control', 'public, max-age=3600'); if($request->getMethod() === 'OPTIONS') return 204; - return $this->rpc->query('all', [ + $result = $this->rpc->query('all', [ 'includeId' => !empty($request->getParam('include_id')), 'includeOrder' => !empty($request->getParam('include_order')), ]); + if(!is_array($result)) + return 500; + + return $result; } } diff --git a/src/V1/V1Routes.php b/src/V1/V1Routes.php index c1b25d7..ee04b49 100644 --- a/src/V1/V1Routes.php +++ b/src/V1/V1Routes.php @@ -1,6 +1,7 @@ use('/', function(HttpResponseBuilder $response, HttpRequest $request) { + $response->setHeader('Access-Control-Allow-Origin', '*'); + $response->setHeader('Access-Control-Allow-Headers', 'Authorization'); + + if($request->hasHeader('Origin')) { + $host = parse_url($request->getHeaderLine('Origin'), PHP_URL_HOST); + if(is_string($host)) { + $host = '.' . $host; + $allowCookieOrigins = $this->ctx->allowCookieOrigins; + foreach($allowCookieOrigins as $allowCookieOrigin) + if(str_ends_with($host, $allowCookieOrigin)) { + $response->setHeader('Access-Control-Allow-Credentials', 'true'); + break; + } + } + } + }); + $router->use('/', $this->ctx->authz->bearerTokenAuthMiddleware(...)); $router->use('/', $this->ctx->authz->misuzuTokenAuthMiddleware(...)); + $router->use('/', $this->ctx->authz->misuzuCookieAuthMiddleware(...)); $router->get('/', fn() => ['status' => 'operational']); @@ -18,6 +38,10 @@ class V1Routes implements RouteHandler { new V1EmotesRoutes($this->ctx->rpc->scopeTo('misuzu:emotes:')) ); + $router->scopeTo('/storage')->register( + new V1StorageRoutes($this->ctx, $this->ctx->rpc->scopeTo('eeprom:')) + ); + $usersRoutes = new V1UsersRoutes($this->ctx, $this->ctx->rpc->scopeTo('misuzu:users:')); $router->options('/me', $usersRoutes->getMe(...)); $router->get('/me', $usersRoutes->getMe(...)); diff --git a/src/V1/V1StorageRoutes.php b/src/V1/V1StorageRoutes.php new file mode 100644 index 0000000..4a9cf1e --- /dev/null +++ b/src/V1/V1StorageRoutes.php @@ -0,0 +1,481 @@ +setHeader('Access-Control-Allow-Methods', 'GET'); + $response->setHeader('Cache-Control', 'no-store'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + return [ + 'meow' => 'return stats n junk here ig', + ]; + } + + private function handleUploadsAllResponse(HttpResponseBuilder $response, array|string $result): array { + if(is_array($result) && array_key_exists('result', $result)) + return $result['result']; + + $result = match($result) { + 'pip' => [403, 'storage:pool_protected'], + 'pnf' => [404, 'storage:pool_not_found'], + 'biv' => [400, 'storage:uploads_limit'], + default => [500, 'storage:unknown'], + }; + + $response->setStatusCode($result[0]); + return [ + 'error' => $result[1], + ]; + } + + #[HttpOptions('/uploads')] + #[HttpGet('/uploads')] + public function getUploads(HttpResponseBuilder $response, HttpRequest $request) { + $response->setHeader('Access-Control-Allow-Methods', 'GET'); + $response->setHeader('Cache-Control', 'no-store'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + if(!$this->ctx->authz->hasScope('storage') + && !$this->ctx->authz->hasScope('beans')) + return 403; + + $limit = null; + if($request->hasParam('take')) { + $limit = (int)$request->getParam('take'); + if($limit < 1 || $limit > 100) { + $response->setStatusCode(400); + return [ + 'error' => 'storage:limit', + ]; + } + } + + $beforeId = null; + if($request->hasParam('before')) + $beforeId = $request->getParam('before'); + + return $this->handleUploadsAllResponse($response, $this->rpc->query('uploads:all', [ + 'userId' => $this->ctx->authz->userId, // null for mod? + 'limit' => $limit, + 'beforeId' => $beforeId, + 'revealProtectedPool' => false, // reveal for mod? + ])); + } + + #[HttpOptions('/uploads/([A-Za-z0-9]+)')] + #[HttpGet('/uploads/([A-Za-z0-9]+)')] + public function getUpload(HttpResponseBuilder $response, HttpRequest $request, string $uploadId) { + $response->setHeader('Access-Control-Allow-Methods', 'GET, DELETE'); + $response->setHeader('Cache-Control', 'no-store'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + if(!$this->ctx->authz->hasScope('storage') + && !$this->ctx->authz->hasScope('beans')) + return 403; + + $result = $this->rpc->query('uploads:get', [ + 'uploadId' => $uploadId, + ]); + if(is_array($result) && array_key_exists('result', $result)) + return $result['result']; + + $result = match($result) { + 'unf' => [404, 'storage:upload_not_found'], + default => [500, 'storage:unknown'], + }; + + $response->setStatusCode($result[0]); + return [ + 'error' => $result[1], + ]; + } + + #[HttpDelete('/uploads/([A-Za-z0-9]+)')] + public function deleteUpload(HttpResponseBuilder $response, HttpRequest $request, string $uploadId) { + $response->setHeader('Access-Control-Allow-Methods', 'GET, DELETE'); + $response->setHeader('Cache-Control', 'no-store'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + if(!$this->ctx->authz->hasScope('storage:delete') + && !$this->ctx->authz->hasScope('beans')) + return 403; + + $result = $this->rpc->action('uploads:delete', [ + 'uploadId' => $uploadId, + 'userId' => $this->ctx->authz->userId, // null for mod? + ]); + if($result === '') + return 204; + + $result = match($result) { + 'unf' => [404, 'storage:upload_not_found'], + 'dna' => [403, 'storage:delete_not_allowed'], + 'duf' => [500, 'storage:delete_failed'], + default => [500, 'storage:unknown'], + }; + + $response->setStatusCode($result[0]); + return [ + 'error' => $result[1], + ]; + } + + #[HttpOptions('/uploads/([A-Za-z0-9]+)/variants')] + #[HttpGet('/uploads/([A-Za-z0-9]+)/variants')] + public function getUploadVariants(HttpResponseBuilder $response, HttpRequest $request, string $uploadId) { + $response->setHeader('Access-Control-Allow-Methods', 'GET'); + $response->setHeader('Cache-Control', 'no-store'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + if(!$this->ctx->authz->hasScope('storage') + && !$this->ctx->authz->hasScope('beans')) + return 403; + + $result = $this->rpc->query('uploads:variants:all', [ + 'uploadId' => $uploadId, + ]); + if(is_array($result) && array_key_exists('result', $result)) + return $result['result']; + + $result = match($result) { + 'unf' => [404, 'storage:upload_not_found'], + default => [500, 'storage:unknown'], + }; + + $response->setStatusCode($result[0]); + return [ + 'error' => $result[1], + ]; + } + + #[HttpOptions('/uploads/([A-Za-z0-9]+)/variants/([a-z0-9]+)')] + #[HttpGet('/uploads/([A-Za-z0-9]+)/variants/([a-z0-9]+)')] + public function getUploadVariant(HttpResponseBuilder $response, HttpRequest $request, string $uploadId, string $variant) { + $response->setHeader('Access-Control-Allow-Methods', 'GET'); + $response->setHeader('Cache-Control', 'no-store'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + if(!$this->ctx->authz->hasScope('storage') + && !$this->ctx->authz->hasScope('beans')) + return 403; + + $result = $this->rpc->query('uploads:variants:get', [ + 'uploadId' => $uploadId, + 'variant' => $variant + ]); + if(is_array($result) && array_key_exists('result', $result)) + return $result['result']; + + $result = match($result) { + 'unf' => [404, 'storage:upload_not_found'], + 'vnf' => [404, 'storage:variant_not_found'], + 'fnf' => [500, 'storage:file_not_found'], + default => [500, 'storage:unknown'], + }; + + $response->setStatusCode($result[0]); + return [ + 'error' => $result[1], + ]; + } + + #[HttpOptions('/pools')] + #[HttpGet('/pools')] + public function getPools(HttpResponseBuilder $response, HttpRequest $request) { + $response->setHeader('Access-Control-Allow-Methods', 'GET'); + $response->setHeader('Cache-Control', 'no-store'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + if(!$this->ctx->authz->hasScope('storage') + && !$this->ctx->authz->hasScope('beans')) + return 403; + + $result = $this->rpc->query('pools:all', [ + 'protected' => !empty($request->getParam('with_protected')), + 'deprecated' => !empty($request->getParam('with_deprecated')), + ]); + if(is_array($result) && array_key_exists('result', $result)) + return $result['result']; + + $response->setStatusCode(500); + return [ + 'error' => 'storage:unknown', + ]; + } + + #[HttpOptions('/pools/([a-z0-9\-]+)')] + #[HttpGet('/pools/([a-z0-9\-]+)')] + public function getPool(HttpResponseBuilder $response, HttpRequest $request, string $poolName) { + $response->setHeader('Access-Control-Allow-Methods', 'GET'); + $response->setHeader('Cache-Control', 'no-store'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + if(!$this->ctx->authz->hasScope('storage') + && !$this->ctx->authz->hasScope('beans')) + return 403; + + $result = $this->rpc->query('pools:get', [ + 'name' => $poolName, + ]); + if(is_array($result) && array_key_exists('result', $result)) + return $result['result']; + + $result = match($result) { + 'pnf' => [404, 'storage:pool_not_found'], + default => [500, 'storage:unknown'], + }; + + $response->setStatusCode($result[0]); + return [ + 'error' => $result[1], + ]; + } + + #[HttpOptions('/pools/([a-z0-9\-]+)/uploads')] + #[HttpGet('/pools/([a-z0-9\-]+)/uploads')] + public function getPoolUploads(HttpResponseBuilder $response, HttpRequest $request, string $poolName) { + $response->setHeader('Access-Control-Allow-Methods', 'GET, POST'); + $response->setHeader('Cache-Control', 'no-store'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + if(!$this->ctx->authz->hasScope('storage') + && !$this->ctx->authz->hasScope('beans')) + return 403; + + $limit = null; + if($request->hasParam('take')) { + $limit = (int)$request->getParam('take'); + if($limit < 1 || $limit > 100) { + $response->setStatusCode(400); + return [ + 'error' => 'storage:uploads_limit', + ]; + } + } + + $beforeId = null; + if($request->hasParam('before')) + $beforeId = $request->getParam('before'); + + return $this->handleUploadsAllResponse($response, $this->rpc->query('uploads:all', [ + 'poolName' => $poolName, + 'userId' => $this->ctx->authz->userId, // null for mod? + 'limit' => $limit, + 'beforeId' => $beforeId, + 'revealProtectedPool' => false, // reveal for mod? + ])); + } + + #[HttpPost('/pools/([a-z0-9\-]+)/uploads')] + public function postUpload(HttpResponseBuilder $response, HttpRequest $request, string $poolName) { + $response->setHeader('Access-Control-Allow-Methods', 'GET, POST'); + $response->setHeader('Cache-Control', 'no-store'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + $content = $request->getContent(); + if(!($content instanceof FormHttpContent)) + return 400; + + if(!$this->ctx->authz->hasScope('storage:upload') + && !$this->ctx->authz->hasScope('beans')) + return 403; + + if(!$content->hasParam('hash')) { + $response->setStatusCode(400); + return [ + 'error' => 'missing_param', + 'name' => 'hash', + ]; + } + + if(!$content->hasParam('size')) { + $response->setStatusCode(400); + return [ + 'error' => 'missing_param', + 'name' => 'size', + ]; + } + + if(!$content->hasParam('type')) { + $response->setStatusCode(400); + return [ + 'error' => 'missing_param', + 'name' => 'type', + ]; + } + + $args = [ + 'remoteAddr' => $request->getRemoteAddress(), + 'userId' => $this->ctx->authz->userId, + 'poolName' => $poolName, + 'fileName' => (string)$content->getParam('name'), + 'fileSize' => (int)$content->getParam('size'), + 'fileType' => (string)$content->getParam('type'), + 'fileHash' => (string)$content->getParam('hash'), + ]; + + if($content->hasParam('signature')) + $args['signature'] = (string)$content->getParam('signature'); + + $result = $this->rpc->action('tasks:create', $args); + if(is_array($result)) { + if(array_key_exists('result', $result)) { + $response->setStatusCode( + array_key_exists('state', $result['result']) && $result['result']['state'] === 'pending' + ? 201 : 200 + ); + return $result['result']; + } + + if(array_key_exists('error', $result)) + switch($result['error']) { + case 'fhdl': + $response->setStatusCode(451); + return [ + 'error' => 'storage:task_denied', + 'reason' => $result['reason'] ?? 'other', + ]; + + case 'prcf': + $response->setStatusCode(400); + // this needs to do better error conversion + return [ + 'error' => 'storage:task_rule_failed', + 'rule' => $result['rule'] ?? '', + 'args' => $result['args'] ?? '', + ]; + + default: + $result = $result['error']; + break; + } + } + + $result = match($result) { + 'fsts' => [400, 'storage:task_size_too_small'], + 'fttl' => [400, 'storage:task_type_too_long'], + 'ftiv' => [400, 'storage:task_type_not_valid'], + 'fntl' => [400, 'storage:task_name_too_long'], + 'uinn' => [500, 'storage:task_user_id_not_valid'], + 'raiv' => [500, 'storage:task_remote_address_not_valid'], + 'fhiv' => [400, 'storage:task_hash_not_valid'], + 'pnnf' => [404, 'storage:pool_not_found'], + 'srpp' => [403, 'storage:task_signature_required'], + 'sans' => [400, 'storage:task_signature_algo_missing'], + 'shns' => [400, 'storage:task_signature_hash_missing'], + 'stns' => [400, 'storage:task_signature_time_missing'], + 'ssns' => [400, 'storage:task_signature_salt_missing'], + 'sanv' => [400, 'storage:task_signature_algo_not_supported'], + 'stnv' => [403, 'storage:task_signature_time_not_valid'], + 'ssts' => [400, 'storage:task_signature_salt_too_short'], + 'shnv' => [400, 'storage:task_signature_hash_not_valid'], + 'shvf' => [403, 'storage:task_signature_verification_failed'], + 'smbn' => [400, 'storage:task_signature_not_required'], + 'fsnm' => [400, 'storage:task_file_size_hash_not_match'], + 'wfcf' => [500, 'storage:task_data_create_failed'], + 'wfrf' => [500, 'storage:task_data_reserve_failed'], + 'wfff' => [500, 'storage:task_data_flush_failed'], + 'utcf' => [500, 'storage:task_create_failed'], + default => [500, 'storage:unknown'], + }; + + $response->setStatusCode($result[0]); + return [ + 'error' => $result[1], + ]; + } + + #[HttpOptions('/tasks/([A-Za-z0-9]+)')] + #[HttpGet('/tasks/([A-Za-z0-9]+)')] + public function getTask(HttpResponseBuilder $response, HttpRequest $request, string $taskId) { + $response->setHeader('Access-Control-Allow-Methods', 'GET, POST'); + $response->setHeader('Cache-Control', 'no-store'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + $result = $this->rpc->query('tasks:get', [ + 'taskId' => $taskId, + ]); + if(is_array($result) && array_key_exists('result', $result)) + return $result['result']; + + $result = match($result) { + 'tnf' => [404, 'storage:task_not_found'], + 'svf' => [404, 'storage:task_not_found'], + default => [500, 'storage:unknown'], + }; + + $response->setStatusCode($result[0]); + return [ + 'error' => $result[1], + ]; + } + + #[HttpPost('/tasks/([A-Za-z0-9]+)')] + public function postTask(HttpResponseBuilder $response, HttpRequest $request, string $taskId) { + $response->setHeader('Access-Control-Allow-Methods', 'GET, POST'); + $response->setHeader('Cache-Control', 'no-store'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + $result = $this->rpc->action('tasks:finish', [ + 'taskId' => $taskId, + ]); + if(is_array($result) && array_key_exists('result', $result)) { + $response->setStatusCode(201); + return $result['result']; + } + + $result = match($result) { + 'tnf' => [404, 'storage:task_not_found'], + 'svf' => [404, 'storage:task_not_found'], + 'fnf' => [404, 'storage:file_not_found'], + 'fne' => [500, 'storage:task_data_create_failed'], + 'hnm' => [400, 'storage:task_hash_not_match'], + 'tcf' => [400, 'storage:task_unknown'], + 'tnp' => [403, 'storage:task_not_pending'], + default => [500, $result], + }; + + $response->setStatusCode($result[0]); + return [ + 'error' => $result[1], + ]; + } +} diff --git a/src/V1/V1UsersRoutes.php b/src/V1/V1UsersRoutes.php index 9753f53..1a3ddac 100644 --- a/src/V1/V1UsersRoutes.php +++ b/src/V1/V1UsersRoutes.php @@ -1,6 +1,7 @@ setHeader('Access-Control-Allow-Origin', '*'); - $response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET'); + /** @return int|mixed[] */ + public function getMe(HttpResponseBuilder $response): array|int { + $response->setHeader('Access-Control-Allow-Methods', 'GET'); $response->setHeader('Cache-Control', 'no-store'); if(!$this->ctx->authz->hasScope('identify')