diff --git a/.editorconfig b/.editorconfig index b175ed5..b7dc3e9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,3 +8,6 @@ indent_style = space insert_final_newline = true max_line_length = 120 tab_width = 4 + +[openapi.yaml] +indent_size = 2 \ No newline at end of file diff --git a/src/controller/ResourceController.php b/src/controller/ResourceController.php index 5a30d2e..5248b67 100644 --- a/src/controller/ResourceController.php +++ b/src/controller/ResourceController.php @@ -1,11 +1,18 @@ _getOwnedPremiumResource(); + $purchases = $this->database->getResourcePurchases($resource->resource_id, Req::page()); + if (!is_null($purchases)) { + $out = []; + foreach ($purchases as $purchase) { + $out[] = new ResourcePurchase($purchase); + } + return $out; + } + return NULL; + } + + public function getResourcePurchaseByUser() + { + if (!Req::checkUserIdParam()) { + return NULL; + } + $resource = $this->_getOwnedPremiumResource(); + $purchase = $this->database->getResourcePurchaseByUser($resource->resource_id, Req::userId()); + if (!is_null($purchase)) { + return new ResourcePurchase($purchase); + } + return NULL; + } + + private function _getOwnedPremiumResource() + { + if (Req::checkIdParam() && Req::checkApiKeyParam()) { + $apiKeyOwnerId = ApiKeyUtil::validateApiKey($this->database, Req::apiKey()); + $resource = $this->database->getResource(Req::id()); + if (is_null($resource) || $resource === false) { + return NULL; + } + if (empty($resource->currency)) { + echo new Error(400, "Not a premium resource."); + exit(); + } + if ($resource->user_id !== $apiKeyOwnerId) { + echo new Error(403, "No access to requested resource."); + exit(); + } + return $resource; + } + return NULL; + } + } diff --git a/src/imports.php b/src/imports.php index 1703d44..a32b412 100644 --- a/src/imports.php +++ b/src/imports.php @@ -16,6 +16,7 @@ require_once(__DIR__ . '/util/IconUtil.php'); require_once(__DIR__ . '/util/RequestUtil.php'); +require_once(__DIR__ . '/util/ApiKeyUtil.php'); require_once(__DIR__ . '/controller/AuthorController.php'); require_once(__DIR__ . '/controller/ResourceController.php'); diff --git a/src/object/ResourcePurchase.php b/src/object/ResourcePurchase.php new file mode 100644 index 0000000..2a995d6 --- /dev/null +++ b/src/object/ResourcePurchase.php @@ -0,0 +1,19 @@ +resource_id = $database_entry->resource_id; + $this->user_id = $database_entry->user_id; + } + + +} diff --git a/src/openapi.yaml b/src/openapi.yaml index c22c122..57eee64 100644 --- a/src/openapi.yaml +++ b/src/openapi.yaml @@ -132,6 +132,70 @@ paths: type: array items: $ref: '#/components/schemas/ResourceUpdate' + /index.php?action=getResourcePurchases: + parameters: + - name: id + description: The resource ID + in: query + required: true + schema: + type: integer + - name: page + description: The page of results to get + in: query + required: false + schema: + type: integer + get: + operationId: getResourcePurchases + summary: Obtain all purchases of a (premium) resource + security: + - apiKeyAuthorization: [ ] + responses: + '200': + description: An object containing an array with all purchases / purchasers of the resources + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ResourcePurchase' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + /index.php?action=getResourcePurchaseByUser: + parameters: + - name: id + description: The resource ID + in: query + required: true + schema: + type: integer + - name: user_id + description: The id of the purchaser + in: query + required: true + schema: + type: integer + get: + operationId: getResourcePurchaseByUser + summary: Obtain the details of a purchase made by a specific user for a (premium) resource + security: + - apiKeyAuthorization: [ ] + responses: + '200': + description: An object containing the details of the purchases of the resources + content: + application/json: + schema: + $ref: '#/components/schemas/ResourcePurchase' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + description: The specified user did not buy the resource. /index.php?action=getAuthor: parameters: - name: id @@ -169,6 +233,16 @@ paths: schema: $ref: '#/components/schemas/Author' components: + securitySchemes: + apiKeyAuthorization: + type: apiKey + in: header + name: X-API-Key + responses: + UnauthorizedError: + description: Invalid API Key + ForbiddenError: + description: No permission to access the resource (API Key does not permit access to given resource). schemas: Resource: type: object @@ -272,6 +346,15 @@ components: type: string description: type: string + ResourcePurchase: + type: object + properties: + resource_id: + type: integer + description: The ID of the purchases resource + user_id: + type: integer + description: The ID of the user that purchased the resource Author: type: object properties: diff --git a/src/support/Database.php b/src/support/Database.php index a2135fe..14ba293 100644 --- a/src/support/Database.php +++ b/src/support/Database.php @@ -30,7 +30,7 @@ public function __construct($username, $password, $hostname, $port, $database) } } - public static function initializeViaConfig() + public static function initializeViaConfig(): Database { return new Database( Config::$data['MYSQL_USERNAME'], @@ -157,6 +157,46 @@ public function getResourceUpdates($resource_id, $page) return NULL; } + public function getResourcePurchases($resource_id, $page) + { + $page = $page == 1 ? 0 : 10 * ($page - 1); + if (is_null($this->conn)) { + return NULL; + } + // TODO: I have no clue how the tables look like + $statement = $this->conn->prepare( + "SELECT whatever + FROM xf_resource_XXXXX + WHERE resource_id = :resource_id + LIMIT 10 OFFSET :offset" + ); + $statement->bindParam(':resource_id', $resource_id); + $statement->bindParam(':offset', $page, \PDO::PARAM_INT); + if ($statement->execute()) { + return $statement->fetchAll(); + } + return NULL; + } + + public function getResourcePurchaseByUser($resource_id, $user_id) + { + if (is_null($this->conn)) { + return NULL; + } + // TODO: I (still) have no clue how the tables look like + $statement = $this->conn->prepare( + "SELECT whatever, something_else + FROM xf_resource_XXXXX + WHERE resource_id = :resource_id AND purchaser_id = :user_id" + ); + $statement->bindParam(':resource_id', $resource_id); + $statement->bindParam(':user_id', $user_id); + if ($statement->execute()) { + return $statement->fetch(); + } + return NULL; + } + public function getUser($user_id) { if (!is_null($this->conn)) { @@ -214,6 +254,26 @@ public function findUser($username) return NULL; } + /** + * @param string $apiKey + * @return integer|null + */ + public function getApiKeyOwnerId(string $apiKey) + { + if (is_null($this->conn)) { + return NULL; + } + $statement = $this->conn->prepare("SELECT user_id FROM xf_api_keys WHERE key = :key LIMIT 1"); + $statement->bindParam(':key', $apiKey); + if ($statement->execute()) { + $fetched = $statement->fetch(); + if (!is_null($fetched) && $fetched !== false) { + return $fetched['user_id']; + } + } + return NULL; + } + private function _resource($additional_where_clauses, $limit = 1, $offset = null) { $offsetClause = is_null($offset) ? '' : 'OFFSET :offset'; diff --git a/src/support/Router.php b/src/support/Router.php index bc96609..22dd758 100644 --- a/src/support/Router.php +++ b/src/support/Router.php @@ -25,6 +25,8 @@ public function __construct() "listResourceCategories", "getResourceUpdate", "getResourceUpdates", + "getResourcePurchases", + "getResourcePurchaseByUser", "getAuthor", "findAuthor" ]; @@ -80,6 +82,16 @@ private function getResourceUpdates() return $this->resourceUpdateController->getResourceUpdates(); } + private function getResourcePurchases() + { + return $this->resourceController->getResourcePurchases(); + } + + private function getResourcePurchaseByUser() + { + return $this->resourceController->getResourcePurchaseByUser(); + } + private function getAuthor() { return $this->authorController->getAuthor(); diff --git a/src/util/ApiKeyUtil.php b/src/util/ApiKeyUtil.php new file mode 100644 index 0000000..6d9f655 --- /dev/null +++ b/src/util/ApiKeyUtil.php @@ -0,0 +1,25 @@ +getApiKeyOwnerId($apiKey); + if (!is_null($userId)) { + return $userId; + } + echo new Error(401, "Invalid API Key."); + exit(); + } + +} diff --git a/src/util/RequestUtil.php b/src/util/RequestUtil.php index 735133e..a7fb03b 100644 --- a/src/util/RequestUtil.php +++ b/src/util/RequestUtil.php @@ -70,6 +70,57 @@ public static function checkNameParam() return true; } + public static function userId() + { + $userId = $_GET['user_id'] ?? NULL; + if (is_numeric($userId) && $userId > 0) { + return $userId; + } + return NULL; + } + + public static function checkUserIdParam() { + if (is_null(self::userId())) { + echo new Error(400, "User ID not specified. Please specify."); + exit(); + } + return true; + } + + public static function apiKey() + { + // getallheaders (apache_request_headers) is supported by the majority of runtimes now (FPM, CGI, nginx, etc.) + // given headers are by spec case-insensitive, the array keys will be made lowercase to easily access the value by name. + $header = array_change_key_case(getallheaders())['x-api-key']; + if (!empty($header)) { + return $header; + } + return NULL; + } + + /** + * Validates the existence and format of the API-Key without validating its validity. + * Making sure that the API Key is valid should be done additionally to retrieve the actual owner of the key. + * @return true|void + */ + public static function checkApiKeyParam() + { + $apiKey = self::apiKey(); + + if (is_null($apiKey)) { + echo new Error(400, "API Key not specified. Must be provided as X-API-Key header."); + exit(); + } + + // regex matching current API key format (sha256 hash of 32 random bytes in hexadecimal format) as specified in + // https://github.com/SpigotMC/XenforoApiKeys/blob/main/upload/library/XenforoApiKeys/Model/ApiKey.php#L71 + if (!preg_match("/^[0-9a-fA-F]{64}$/", $apiKey)) { + echo new Error(401, "Invalid API Key."); + exit(); + } + return true; + } + public static function page() { $value = $_GET['page'] ?? null;