Compare commits

...

31 commits
1.0.5 ... main

Author SHA1 Message Date
Alexandre Froger
4847ed1ed7 Merge remote-tracking branch 'origin/dev' into dev 2025-06-06 10:38:37 +08:00
Alexandre Froger
84da98bc6a Fix activation issue - WP_Filesystem call 2025-06-06 10:38:22 +08:00
Alexandre Froger
8356ac6ca2 fix nonce cleanup 2025-06-04 21:40:26 +08:00
Alexandre Froger
8efebb1774 wl to readme links 2025-04-28 10:41:48 +08:00
Alexandre Froger
575c7c3cdb readme update 2025-04-28 10:38:14 +08:00
Alexandre Froger
e1fc90780e typo fix 2025-04-28 10:37:47 +08:00
Alexandre Froger
deda3090c3 readme Companion Plugins 2025-04-28 10:36:13 +08:00
Alexandre Froger
15ce059512 email fix 2025-04-28 10:35:17 +08:00
Alexandre Froger
c6155be1fa Introduce constant PUC_FORCE_BRANCH to bypass tags & releases in VCS detection strategies 2025-04-22 11:33:02 +08:00
Alexandre Froger
ec8856175b Introduce constant PUC_FORCE_BRANCH to bypass tags & releases in VCS detection strategies 2025-04-22 10:18:53 +08:00
Alexandre Froger
d4a206ab1f donate link 2025-04-14 07:47:20 +08:00
Alexandre Froger
49adf7f6b4 remove package metadata files when deleting packages ; make sure to reinitialise the update checker to avoid slug conflicts 2025-04-13 13:44:15 +08:00
Alexandre Froger
2589b5071b Fix VCS candidates with webhook mode 2025-04-10 17:04:36 +08:00
Alexandre Froger
4bb83b4837 Fix scheduled mode package overrides 2025-04-10 13:14:29 +08:00
Alexandre Froger
0c3c1ab306 Full documentation MVP 2025-03-20 22:45:55 +08:00
Alexandre Froger
58131d74f7 Full documentation WIP - this file needs actions doc 2025-03-20 17:51:47 +08:00
Alexandre Froger
15a3ffdaaa Full documentation WIP 2025-03-20 17:31:25 +08:00
Alexandre Froger
87aec68366 changelog update 2025-03-20 11:37:46 +08:00
Alexandre Froger
ae4cc6e0e2 Use a VCS candidates system in case of unspecified branch in the webhook event to support all events, not just push
https://github.com/Anyape/updatepulse-server/issues/4
2025-03-20 11:28:55 +08:00
Alexandre Froger
452184d062 Fix https://github.com/Anyape/updatepulse-server/issues/4#issuecomment-2738822390 2025-03-20 11:27:15 +08:00
Alexandre Froger
791ca0559e See https://github.com/Anyape/updatepulse-server/issues/4 2025-03-20 08:24:59 +08:00
Alexandre Froger
9cd489575e fix doc 2025-03-20 08:22:49 +08:00
Alexandre Froger
3b7f404f7f fix doc 2025-03-20 08:20:28 +08:00
Alexandre Froger
8020d655e3 See https://github.com/Anyape/updatepulse-server/issues/4 2025-03-20 08:11:16 +08:00
Alexandre Froger
475b89c0f3 Full documentation WIP 2025-03-18 17:46:51 +08:00
Alexandre Froger
db07e75b7d Full documentation WIP 2025-03-18 08:28:01 +08:00
Alexandre Froger
76de3434c5 Full documentation WIP 2025-03-17 17:58:31 +08:00
Alexandre Froger
28c10e9e45 Full documentation WIP 2025-03-17 17:51:12 +08:00
Alexandre Froger
8f02af5c90 Readme Troubleshooting and FAQ sections ; Update API documentation 2025-03-16 12:11:44 +08:00
Alexandre Froger
03398f03a2 Fix webhook payload handling & version bump 2025-03-15 12:04:01 +08:00
Alexandre Froger
df4ca42486 enhanced deploy script 2025-03-11 20:15:58 +08:00
37 changed files with 6460 additions and 524 deletions

View file

@ -151,7 +151,7 @@ Name | Type | Description
Enable VCS | checkbox | Enables this server to download packages from a Version Control System before delivering updates.<br/>Supports Bitbucket, Github and Gitlab.<br/>If left unchecked, zip packages need to be manually uploaded to `wp-content/plugins/updatepulse-server/packages`.
VCS URL | text | The URL of the Version Control System where packages are hosted.<br/>Must follow the following pattern: `https://version-control-system.tld/username` where `https://version-control-system.tld` may be a self-hosted instance of Gitlab.<br/>Each package repository URL must follow the following pattern: `https://version-control-system.tld/username/package-slug/`; the package files must be located at the root of the repository, and in the case of WordPress plugins the main plugin file must follow the pattern `package-slug.php`.
Self-hosted VCS | checkbox | Check this only if the Version Control System is a self-hosted instance of Gitlab.
Packages branch name | text | The branch to download when getting remote packages from the Version Control System.
Packages branch name | text | The branch to download when getting remote packages from the Version Control System.<br/>If the VCS supports releases or tags, they will be prioritised over the branch name (release first, then tag, then branch).<br/>To bypass this behaviour, set the `PUC_FORCE_BRANCH` constant to `true` in `wp-config.php`.
VCS credentials | text | Credentials for non-publicly accessible repositories.<br/>In the case of Github and Gitlab, a Personal Access Token; in the case of Bitckucket, an App Password.<br/>**WARNING: Keep these credentials secret, do not share them, and take care of renewing them before they expire!**
Use Webhooks | checkbox | Check so that each repository of the Version Control System calls a Webhook when updates are pushed.<br>When checked, UpdatePulse Server will not regularly poll repositories for package version changes, but relies on events sent by the repositories to schedule a package download.<br>Webhook URL: `https://domain.tld/updatepulse-server-webhook/package-type/package-slug` - where `package-type` is the package type (`plugin`, `theme`, or `generic`) and `package-slug` is the slug of the package that needs updates.<br>Note that UpdatePulse Server does not rely on the content of the payload to schedule a package download, so any type of event can be used to trigger the Webhook.
Remote Download Delay | number | Delay in minutes after which UpdatePulse Server will poll the Version Control System for package updates when the Webhook has been called.<br>Leave at `0` to schedule a package update during the cron run happening immediately after the Webhook notification was received.

View file

@ -343,7 +343,7 @@ fi
execute_or_echo git checkout-index -a -f --prefix="$SVNPATH"/trunk/

# Ignore files
execute_or_echo svn propset svn:ignore "deploy.sh
execute_or_echo svn propset svn:ignore "*.sh
.DS_Store
.vscode
.git

View file

@ -66,7 +66,8 @@ UpdatePulse Server provides an API and offers a series of functions, actions and
* [upserv\_license\_api\_request\_authorized](#upserv_license_api_request_authorized)
* [upserv\_license\_bypass\_signature](#upserv_license_bypass_signature)
* [upserv\_api\_license\_actions](#upserv_api_license_actions)
* [upserv\_api\_license\_actions](#upserv_api_license_actions-1)
* [upserv\_license\_update\_server\_prepare\_license\_for\_output](#upserv_license_update_server_prepare_license_for_output)
* [upserv\_schedule\_license\_frequency](#upserv_schedule_license_frequency)

___
## The License Query
@ -1622,7 +1623,7 @@ Filter the License API actions available for API access control.
> (array) the API actions

___
### upserv_api_license_actions
### upserv_license_update_server_prepare_license_for_output

```php
apply_filters( 'upserv_license_update_server_prepare_license_for_output', array $output, object $license );
@ -1639,3 +1640,15 @@ Filter the license data to send to the remote client.
> (array) the original license object

___
### upserv_schedule_license_frequency

```php
apply_filters( 'upserv_schedule_license_frequency', string $frequency );
```
**Description**
Filter the frequency at which the license maintenance task runs.

**Parameters**
`$frequency`
> (string) the frequency at which the license maintenance task runs. Default is `hourly` (see [WP_Cron](https://developer.wordpress.org/reference/classes/wp_cron/) for more information on the available frequencies)
___

View file

@ -8,6 +8,9 @@ UpdatePulse Server provides an API and offers a series of functions, actions and
* [Acquiring a reusable token or a true nonce - payload](#acquiring-a-reusable-token-or-a-true-nonce---payload)
* [Responses](#responses)
* [Building API credentials and API signature](#building-api-credentials-and-api-signature)
* [Update API](#update-api)
* [The `get_metadata` action](#the-get_metadata-action)
* [The `download` action](#the-download-action)
* [WP CLI](#wp-cli)
* [Consuming Webhooks](#consuming-webhooks)
* [Functions](#functions)
@ -248,6 +251,174 @@ echo '<div>The credentials are: ' . esc_html( $values['credentials'] ) . '</div>
echo '<div>The signature is: ' . esc_html( $values['signature'] ) . '</div>';
```

## Update API

The Update API is accessible via `GET` requests on the `/updatepulse-server-update-api/` endpoint.
It has two actions: `get_metadata` and `download`.

### The `get_metadata` action

The `get_metadata` action is used to check for updates. It accepts the following parameters:

| Parameter | Description | Required |
| --- | --- | --- |
| `action` | The action to perform. Must be `get_metadata`. | Yes |
| `package_id` | The ID of the package to check for updates. | Yes |
| `installed_version` | The version of the package currently installed. | No |
| `php` | The PHP version of the client. | No |
| `locale` | The locale of the client. | No |
| `checking_for_updates` | A flag indicating whether the client is checking for updates. | No |
| `license_key` | The license key of the package | Yes (if the package requires a license) |
| `license_signature` | The license signature of the package | Yes (if the package requires a license) |
| `update_type` | The type of update. Must be one of `Plugin`, `Theme`, or `Generic`. | Yes |

Example of a request to the Update API with:
- `get_metadata` action
- `package_id` set to `dummy-plugin`
- `installed_version` set to `1.0`
- `php` set to `8.3`
- `locale` set to `en_US`
- `checking_for_updates` set to `1`
- `license_key` set to `abcdef1234567890`
- `license_signature` set to `signabcdef1234567890`
- `update_type` set to `Plugin`

```bash
curl -X GET "https://server.domain.tld/updatepulse-server-update-api/?action=get_metadata&package_id=dummy-plugin&installed_version=1.0&php=8.3&locale=en_US&checking_for_updates=1&license_key=abcdef1234567890&license_signature=signabcdef1234567890&update_type=Plugin"
```

Example of a response (success):
```json
{
"name": "Dummy Plugin",
"version": "1.5.0",
"homepage": "https:\/\/domain.tld\/",
"author": "A Developer",
"author_homepage": "https:\/\/domain.tld\/",
"description": "Updated Empty plugin to demonstrate the UpdatePulse Updater.",
"details_url": "https:\/\/domain.tld\/",
"requires": "4.9.8",
"tested": "4.9.8",
"requires_php": "7.0",
"sections": {
"description": "<div class=\"readme-section\" data-name=\"Description\"><p>Update Plugin description. <strong>Basic HTML<\/strong> can be used in all sections.<\/p><\/div>",
"dummy_section": "<div class=\"readme-section\" data-name=\"Dummy Section\"><p>An extra, dummy section.<\/p><\/div>",
"installation": "<div class=\"readme-section\" data-name=\"Installation\"><p>Installation instructions.<\/p><\/div>",
"changelog": "<div class=\"readme-section\" data-name=\"Changelog\"><p>This section will be displayed by default when the user clicks 'View version x.y.z details'.<\/p><\/div>",
"frequently_asked_questions": "<div class=\"readme-section\" data-name=\"Frequently Asked Questions\"><h4>Question<\/h4><p>Answer<\/p><\/div>",
},
"icons": {
"1x": "https:\/\/domain.tld\/path\/to\/icon-128x128.png",
"2x": "https:\/\/domain.tld\/path\/to\/icon-256x256.png",
},
"banners": {
"low": "https:\/\/domain.tld\/path\/to\/banner-772x250.png",
"high": "https:\/\/domain.tld\/path\/to\/banner-1544x500.png",
},
"require_license": "1",
"slug": "dummy-plugin",
"type": "plugin",
"download_url": "https:\/\/server.domain.tld\/updatepulse-server-update-api\/?action=download&package_id=dummy-plugin&token=tokenabcdef1234567890&license_key=abcdef1234567890&license_signature=signabcdef1234567890&update_type=Plugin",
"license": {
"license_key": "abcdef1234567890",
"max_allowed_domains": 2,
"allowed_domains": [
"domain.tld",
"domain2.tld"
],
"status": "activated",
"txn_id": "",
"date_created": "2025-02-04",
"date_renewed": "0000-00-00",
"date_expiry": "2027-02-04",
"package_slug": "dummy-plugin",
"package_type": "plugin",
"result": "success",
"message": "License key details retrieved."
},
"time_elapsed": "0.139s"
}
```

Examples of a response (failure - invalid package):
```json
{
"error": "no_server",
"message": "No server found for this package."
}
```

Examples of a response (failure - invalid license):
```json
{
"name": "Dummy Plugin",
"version": "1.5.0",
"homepage": "https:\/\/domain.tld\/",
"author": "A Developer",
"author_homepage": "https:\/\/domain.tld\/",
"description": "Updated Empty plugin to demonstrate the UpdatePulse Updater.",
"details_url": "https:\/\/domain.tld\/",
"requires": "4.9.8",
"tested": "4.9.8",
"requires_php": "7.0",
"sections": {
"description": "<div class=\"readme-section\" data-name=\"Description\"><p>Update Plugin description. <strong>Basic HTML<\/strong> can be used in all sections.<\/p><\/div>",
"dummy_section": "<div class=\"readme-section\" data-name=\"Dummy Section\"><p>An extra, dummy section.<\/p><\/div>",
"installation": "<div class=\"readme-section\" data-name=\"Installation\"><p>Installation instructions.<\/p><\/div>",
"changelog": "<div class=\"readme-section\" data-name=\"Changelog\"><p>This section will be displayed by default when the user clicks 'View version x.y.z details'.<\/p><\/div>",
"frequently_asked_questions": "<div class=\"readme-section\" data-name=\"Frequently Asked Questions\"><h4>Question<\/h4><p>Answer<\/p><\/div>",
},
"icons": {
"1x": "https:\/\/domain.tld\/path\/to\/icon-128x128.png",
"2x": "https:\/\/domain.tld\/path\/to\/icon-256x256.png",
},
"banners": {
"low": "https:\/\/domain.tld\/path\/to\/banner-772x250.png",
"high": "https:\/\/domain.tld\/path\/to\/banner-1544x500.png",
},
"require_license": "1",
"slug": "dummy-plugin",
"type": "plugin",
"license_error": {
"code": "invalid_license",
"message": "The license key or signature is invalid.",
"data": {
"license": false
}
},
"time_elapsed": "0.139s"
}
```

### The `download` action

The `download` action is used to download the package. It accepts the following parameters:

| Parameter | Description | Required |
| --- | --- | --- |
| `action` | The action to perform. Must be `download`. | Yes |
| `package_id` | The ID of the package to download. | Yes |
| `token` | The cryptographic token to use to download the package. Generated by the Nonce API. | Yes |
| `license_key` | The license key of the package | Yes (if the package requires a license) |
| `license_signature` | The license signature of the package | Yes (if the package requires a license) |
| `update_type` | The type of update. Must be one of `Plugin`, `Theme`, or `Generic`. | Yes |

Generally, the URL to request this API endpoint would not be put together manually, but rather taken from the field `download_url` in the response of `get_metadata` action.

Example of a request to the Update API with:
- `download` action
- `package_id` set to `dummy-plugin`
- `token` set to `tokenabcdef1234567890`
- `license_key` set to `abcdef1234567890`
- `license_signature` set to `signabcdef1234567890`
- `update_type` set to `Plugin`

```bash
curl -X GET "https://server.domain.tld/updatepulse-server-update-api/?action=download&package_id=dummy-plugin&token=tokenabcdef1234567890&license_key=abcdef1234567890&license_signature=signabcdef1234567890&update_type=Plugin"
```

The response is a `zip` file containing the package.

## WP CLI

UpdatePulse Server provides a series of commands to interact with the plugin:

View file

@ -22,18 +22,21 @@ class License_API {
* Is doing API request
*
* @var boolean|null
* @since 1.0.0
*/
protected static $doing_api_request = null;
/**
* Instance
*
* @var License_API|null
* @since 1.0.0
*/
protected static $instance;
/**
* Config
*
* @var array|null
* @since 1.0.0
*/
protected static $config;

@ -41,34 +44,37 @@ class License_API {
* License server
*
* @var License_Server
* @since 1.0.0
*/
protected $license_server;
/**
* HTTP response code
*
* @var int|null
* @since 1.0.0
*/
protected $http_response_code = null;
/**
* API key ID
*
* @var string|null
* @since 1.0.0
*/
protected $api_key_id;
/**
* API access
*
* @var array|null
* @since 1.0.0
*/
protected $api_access;

/**
* Constructor
*
* @since 1.0.0
*
* @param boolean $init_hooks
* @param boolean $local_request
* @since 1.0.0
*/
public function __construct( $init_hooks = false, $local_request = true ) {

@ -107,10 +113,9 @@ class License_API {
/**
* Browse licenses
*
* @since 1.0.0
*
* @param string $query
* @return object Result of the browse operation
* @since 1.0.0
*/
public function browse( $query ) {
$payload = json_decode( wp_unslash( $query ), true );
@ -203,10 +208,9 @@ class License_API {
/**
* Read license
*
* @since 1.0.0
*
* @param array $license_data
* @return object Result of the read operation
* @since 1.0.0
*/
public function read( $license_data ) {
$result = wp_cache_get(
@ -255,10 +259,9 @@ class License_API {
/**
* Edit license
*
* @since 1.0.0
*
* @param array $license_data
* @return object Result of the edit operation
* @since 1.0.0
*/
public function edit( $license_data ) {

@ -321,10 +324,9 @@ class License_API {
/**
* Add license
*
* @since 1.0.0
*
* @param array $license_data
* @return object Result of the add operation
* @since 1.0.0
*/
public function add( $license_data ) {

@ -367,10 +369,9 @@ class License_API {
/**
* Delete license
*
* @since 1.0.0
*
* @param array $license_data
* @return object Result of the delete operation
* @since 1.0.0
*/
public function delete( $license_data ) {
$result = $this->license_server->delete_license( $license_data );
@ -404,12 +405,17 @@ class License_API {
/**
* Check license
*
* @since 1.0.0
*
* @param array $license_data
* @return object Result of the check operation
* @since 1.0.0
*/
public function check( $license_data ) {
/**
* Filter the license data payload before checking a license.
*
* @param array $license_data The license data payload.
* @since 1.0.0
*/
$license_data = apply_filters( 'upserv_check_license_dirty_payload', $license_data );
$license = $this->license_server->read_license( $license_data );
$raw_result = array();
@ -423,8 +429,21 @@ class License_API {
$result = null;
}

/**
* Filter the result of the license check operation.
*
* @param object|null $license The license object or null if not found.
* @param array $license_data The license data payload.
* @since 1.0.0
*/
$result = apply_filters( 'upserv_check_license_result', $license, $license_data );

/**
* Fired after checking a license.
*
* @param mixed $raw_result The raw result of the license check.
* @since 1.0.0
*/
do_action( 'upserv_did_check_license', $raw_result );

if ( ! is_object( $result ) ) {
@ -437,12 +456,17 @@ class License_API {
/**
* Activate license
*
* @since 1.0.0
*
* @param array $license_data
* @return object Result of the activate operation
* @since 1.0.0
*/
public function activate( $license_data ) {
/**
* Filter the license data payload before activating a license.
*
* @param array $license_data The license data payload.
* @since 1.0.0
*/
$license_data = apply_filters( 'upserv_activate_license_dirty_payload', $license_data );

$this->normalize_allowed_domains( $license_data );
@ -451,6 +475,12 @@ class License_API {
$license = $this->license_server->read_license( $license_data );
$domain = $this->extract_domain_from_license_data( $license_data );

/**
* Fired before activating a license.
*
* @param object $license The license object.
* @since 1.0.0
*/
do_action( 'upserv_pre_activate_license', $license );

if ( $this->is_valid_license_for_state_transition( $license, $request_slug, $domain ) ) {
@ -461,8 +491,24 @@ class License_API {

$raw_result = isset( $result['raw_result'] ) ? $result['raw_result'] : $result;
$result = isset( $result['result'] ) ? $result['result'] : $result;
$result = apply_filters( 'upserv_activate_license_result', $result, $license_data, $license );

/**
* Filter the result of the license activation operation.
*
* @param object $result The result of the license activation.
* @param array $license_data The license data payload.
* @param object $license The license object.
* @since 1.0.0
*/
$result = apply_filters( 'upserv_activate_license_result', $result, $license_data, $license );

/**
* Fired after activating a license.
*
* @param mixed $raw_result The raw result of the license activation.
* @param array $license_data The license data payload.
* @since 1.0.0
*/
do_action( 'upserv_did_activate_license', $raw_result, $license_data );

return (object) $result;
@ -471,12 +517,17 @@ class License_API {
/**
* Deactivate license
*
* @since 1.0.0
*
* @param array $license_data
* @return object Result of the deactivate operation
* @since 1.0.0
*/
public function deactivate( $license_data ) {
/**
* Filter the license data payload before deactivating a license.
*
* @param array $license_data The license data payload.
* @since 1.0.0
*/
$license_data = apply_filters( 'upserv_deactivate_license_dirty_payload', $license_data );

$this->normalize_allowed_domains( $license_data );
@ -485,6 +536,12 @@ class License_API {
$license = $this->license_server->read_license( $license_data );
$domain = $this->extract_domain_from_license_data( $license_data );

/**
* Fired before deactivating a license.
*
* @param object $license The license object.
* @since 1.0.0
*/
do_action( 'upserv_pre_deactivate_license', $license );

if ( $this->is_valid_license_for_state_transition( $license, $request_slug, $domain ) ) {
@ -495,8 +552,24 @@ class License_API {

$raw_result = isset( $result['raw_result'] ) ? $result['raw_result'] : $result;
$result = isset( $result['result'] ) ? $result['result'] : $result;
$result = apply_filters( 'upserv_deactivate_license_result', $result, $license_data, $license );

/**
* Filter the result of the license deactivation operation.
*
* @param object $result The result of the license deactivation.
* @param array $license_data The license data payload.
* @param object $license The license object.
* @since 1.0.0
*/
$result = apply_filters( 'upserv_deactivate_license_result', $result, $license_data, $license );

/**
* Fired after deactivating a license.
*
* @param mixed $raw_result The raw result of the license deactivation.
* @param array $license_data The license data payload.
* @since 1.0.0
*/
do_action( 'upserv_did_deactivate_license', $raw_result, $license_data );

return (object) $result;
@ -540,10 +613,9 @@ class License_API {
/**
* Query vars filter
*
* @since 1.0.0
*
* @param array $query_vars
* @return array The filtered query vars
* @since 1.0.0
*/
public function query_vars( $query_vars ) {
$query_vars = array_merge(
@ -567,10 +639,9 @@ class License_API {
/**
* Filter update request params
*
* @since 1.0.0
*
* @param array $params
* @return array The filtered params
* @since 1.0.0
*/
public function upserv_handle_update_request_params( $params ) {
global $wp;
@ -589,10 +660,9 @@ class License_API {
/**
* Filter license actions
*
* @since 1.0.0
*
* @param array $actions
* @return array The filtered actions
* @since 1.0.0
*/
public function upserv_api_license_actions( $actions ) {
$actions['browse'] = __( 'Browse multiple license records', 'updatepulse-server' );
@ -607,10 +677,9 @@ class License_API {
/**
* Filter webhook events
*
* @since 1.0.0
*
* @param array $webhook_events
* @return array The filtered webhook events
* @since 1.0.0
*/
public function upserv_api_webhook_events( $webhook_events ) {

@ -637,11 +706,10 @@ class License_API {
/**
* License action
*
* @since 1.0.0
*
* @param object $result
* @param array $payload
* @param object $original
* @since 1.0.0
*/
public function upserv_did_license_action( $result, $payload, $original = null ) {
$format = '';
@ -717,13 +785,12 @@ class License_API {
/**
* Webhook fire filter
*
* @since 1.0.0
*
* @param boolean $fire
* @param array $payload
* @param string $url
* @param array $info
* @return boolean The filtered fire value
* @since 1.0.0
*/
public function upserv_webhook_fire( $fire, $payload, $url, $info ) {

@ -790,13 +857,12 @@ class License_API {
/**
* Fetch nonce filter
*
* @since 1.0.0
*
* @param string $nonce
* @param string $true_nonce
* @param int $expiry
* @param array $data
* @return string|null The filterd nonce or null if invalid
* @since 1.0.0
*/
public function upserv_fetch_nonce_private( $nonce, $true_nonce, $expiry, $data ) {
$config = self::get_config();
@ -840,10 +906,9 @@ class License_API {
/**
* Nonce API payload filter
*
* @since 1.0.0
*
* @param array $payload
* @return array The filtered payload
* @since 1.0.0
*/
public function upserv_nonce_api_payload( $payload ) {
global $wp;
@ -893,9 +958,8 @@ class License_API {
/**
* Is doing API request
*
* @since 1.0.0
*
* @return boolean True if doing API request, false otherwise
* @since 1.0.0
*/
public static function is_doing_api_request() {

@ -909,9 +973,8 @@ class License_API {
/**
* Get config
*
* @since 1.0.0
*
* @return array The config
* @since 1.0.0
*/
public static function get_config() {

@ -924,15 +987,20 @@ class License_API {
self::$config = $config;
}

/**
* Filter the License API configuration.
*
* @param array $config The License API configuration.
* @since 1.0.0
*/
return apply_filters( 'upserv_license_api_config', self::$config );
}

/**
* Get instance
*
* @since 1.0.0
*
* @return License_API The instance
* @since 1.0.0
*/
public static function get_instance() {

@ -946,10 +1014,9 @@ class License_API {
/**
* Is package require license
*
* @since 1.0.0
*
* @param int $package_id
* @return boolean True if package requires license, false otherwise
* @since 1.0.0
*/
public static function is_package_require_license( $package_id ) {
$require_license = wp_cache_get( 'upserv_package_require_license_' . $package_id, 'updatepulse-server', false, $found );
@ -975,9 +1042,9 @@ class License_API {
/**
* Sanitize license result
*
* @since 1.0.0
* @param object $result - by reference
* @return void
* @since 1.0.0
*/
protected function sanitize_license_result( &$result ) {
$num_allowed_domains = (
@ -1008,6 +1075,7 @@ class License_API {
* @param string $message
* @param array $data
* @return array The response
* @since 1.0.0
*/
protected function prepare_error_response( $code, $message, $data = array() ) {
return array(
@ -1022,6 +1090,7 @@ class License_API {
*
* @param array $license_data - by reference
* @return void
* @since 1.0.0
*/
protected function normalize_allowed_domains( &$license_data ) {

@ -1035,6 +1104,7 @@ class License_API {
*
* @param array $license_data
* @return string|false The first domain found or false if not found
* @since 1.0.0
*/
protected function extract_domain_from_license_data( $license_data ) {

@ -1056,6 +1126,7 @@ class License_API {
* @param string $request_slug
* @param string $domain
* @return boolean True if valid, false otherwise
* @since 1.0.0
*/
protected function is_valid_license_for_state_transition( $license, $request_slug, $domain ) {
return (
@ -1072,6 +1143,7 @@ class License_API {
* @param object $license
* @param string $domain
* @return array|null The result or null if not found
* @since 1.0.0
*/
protected function handle_license_activation( $license, $domain ) {
$domain_count = count( $license->allowed_domains ) + 1;
@ -1098,6 +1170,7 @@ class License_API {
*
* @param object $license
* @return array The response
* @since 1.0.0
*/
protected function prepare_illegal_status_response( $license ) {
$response = array(
@ -1120,6 +1193,7 @@ class License_API {
*
* @param object $license
* @return array The response
* @since 1.0.0
*/
protected function prepare_max_domains_response( $license ) {
return array(
@ -1136,6 +1210,7 @@ class License_API {
*
* @param string $domain
* @return array The response
* @since 1.0.0
*/
protected function prepare_already_activated_response( $domain ) {
return array(
@ -1153,11 +1228,19 @@ class License_API {
* @param object $license
* @param string $domain
* @return array|null The result or null if not found
* @since 1.0.0
*/
protected function process_license_activation( $license, $domain ) {
$data = isset( $license->data ) ? $license->data : array();

if ( ! isset( $data['next_deactivate'] ) || time() > $data['next_deactivate'] ) {
/**
* Filter the timestamp for the next allowed deactivation after activation.
*
* @param int $timestamp The timestamp for the next allowed deactivation.
* @param object $license The license object.
* @since 1.0.0
*/
$data['next_deactivate'] = apply_filters( 'upserv_activate_license_next_deactivate', time(), $license );
}

@ -1170,6 +1253,12 @@ class License_API {

try {
$result = $this->license_server->edit_license(
/**
* Filter the payload for license activation.
*
* @param array $payload The license activation payload.
* @since 1.0.0
*/
apply_filters( 'upserv_activate_license_payload', $payload )
);
} catch ( Exception $e ) {
@ -1198,6 +1287,7 @@ class License_API {
* @param object $license
* @param string $domain
* @return array|null The result or null if not found
* @since 1.0.0
*/
protected function handle_license_deactivation( $license, $domain ) {

@ -1226,6 +1316,7 @@ class License_API {
*
* @param string $domain
* @return array The response
* @since 1.0.0
*/
protected function prepare_already_deactivated_response( $domain ) {
return array(
@ -1242,6 +1333,7 @@ class License_API {
*
* @param object $license
* @return array The response
* @since 1.0.0
*/
protected function prepare_too_early_deactivation_response( $license ) {
return array(
@ -1259,9 +1351,17 @@ class License_API {
* @param object $license
* @param string $domain
* @return array|null The result or null if not found
* @since 1.0.0
*/
protected function process_license_deactivation( $license, $domain ) {
$data = isset( $license->data ) ? $license->data : array();
$data = isset( $license->data ) ? $license->data : array();
/**
* Filter the timestamp for the next allowed deactivation.
*
* @param int $timestamp The timestamp for the next allowed deactivation.
* @param object $license The license object.
* @since 1.0.0
*/
$data['next_deactivate'] = apply_filters(
'upserv_deactivate_license_next_deactivate',
(bool) ( constant( 'WP_DEBUG' ) ) ? time() + ( MINUTE_IN_SECONDS / 4 ) : time() + MONTH_IN_SECONDS,
@ -1278,6 +1378,12 @@ class License_API {

try {
$result = $this->license_server->edit_license(
/**
* Filter the payload for license deactivation.
*
* @param array $payload The license deactivation payload.
* @since 1.0.0
*/
apply_filters( 'upserv_activate_license_payload', $payload )
);
} catch ( Exception $e ) {
@ -1306,6 +1412,7 @@ class License_API {
* @param array $license
* @param array $license_data
* @return array The response
* @since 1.0.0
*/
protected function handle_invalid_license( $license, $license_data ) {

@ -1340,6 +1447,7 @@ class License_API {
* @param string $action
* @param array $payload
* @return boolean True if authorized, false otherwise
* @since 1.0.0
*/
protected function authorize_private( $action, $payload ) {
$token = false;
@ -1406,8 +1514,15 @@ class License_API {
*
* @param string $method
* @return boolean True if public, false otherwise
* @since 1.0.0
*/
protected function is_api_public( $method ) {
/**
* Filter the list of public License API actions.
*
* @param array $public_api_actions List of public License API actions.
* @since 1.0.0
*/
$public_api = apply_filters(
'upserv_license_public_api_actions',
array(
@ -1461,6 +1576,14 @@ class License_API {
if ( ! $malformed_request ) {
$this->init_server();

/**
* Filter whether the License API request is authorized.
*
* @param bool $authorized Whether the License API request is authorized.
* @param string $method The method of the request.
* @param array $payload The payload of the request.
* @since 1.0.0
*/
$authorized = apply_filters(
'upserv_license_api_request_authorized',
(
@ -1475,6 +1598,13 @@ class License_API {
);

if ( $authorized ) {
/**
* Fired before the License API request is processed.
*
* @param string $method The License API action.
* @param array $payload The payload of the request.
* @since 1.0.0
*/
do_action( 'upserv_license_api_request', $method, $payload );

if ( method_exists( $this, $method ) ) {
@ -1512,9 +1642,8 @@ class License_API {
/**
* Authorize IP
*
* @since 1.0.0
*
* @return boolean True if authorized, false otherwise
* @since 1.0.0
*/
protected function authorize_ip() {
$result = false;
@ -1540,8 +1669,15 @@ class License_API {
/**
* Init server
*
* @since 1.0.0
*/
protected function init_server() {
/**
* Filter the License Server instance.
*
* @param License_Server $license_server The license server instance.
* @since 1.0.0
*/
$this->license_server = apply_filters( 'upserv_license_server', new License_Server() );
}
}

View file

@ -14,16 +14,63 @@ use Anyape\UpdatePulse\Server\Server\Update\Package;
use Anyape\UpdatePulse\Server\Server\Update\Invalid_Package_Exception;
use Anyape\Utils\Utils;

/**
* Package API class
*
* @since 1.0.0
*/
class Package_API {

protected $http_response_code = 200;
protected $api_key_id;
protected $api_access;

/**
* Is doing API request
*
* @var bool|null
* @since 1.0.0
*/
protected static $doing_api_request = null;
/**
* Instance
*
* @var Package_API|null
* @since 1.0.0
*/
protected static $instance;
/**
* Config
*
* @var array|null
* @since 1.0.0
*/
protected static $config;

/**
* HTTP response code
*
* @var int|null
* @since 1.0.0
*/
protected $http_response_code = 200;
/**
* API key ID
*
* @var string|null
* @since 1.0.0
*/
protected $api_key_id;
/**
* API access
*
* @var array|null
* @since 1.0.0
*/
protected $api_access;

/**
* Constructor
*
* @param boolean $init_hooks
* @since 1.0.0
*/
public function __construct( $init_hooks = false ) {

if ( $init_hooks ) {
@ -48,14 +95,37 @@ class Package_API {

// API action --------------------------------------------------

/**
* Browse packages
*
* Get information about multiple packages.
*
* @param string|array $query The search query or parameters.
* @return object Response with package information.
* @since 1.0.0
*/
public function browse( $query ) {
$result = false;
$query = empty( $query ) || ! is_string( $query ) ? array() : json_decode( wp_unslash( $query ), true );
$query['search'] = isset( $query['search'] ) ? trim( esc_html( $query['search'] ) ) : false;
$result = upserv_get_batch_package_info( $query['search'], false );
$result['count'] = is_array( $result ) ? count( $result ) : 0;
$result = apply_filters( 'upserv_package_browse', $result, $query );
/**
* Filter the result of the `browse` operation of the Package API.
*
* @param array $result The result of the `browse` operation
* @param array $query The query - see browse()
* @return array The filtered result
* @since 1.0.0
*/
$result = apply_filters( 'upserv_package_browse', $result, $query );

/**
* Fired after the `browse` Package API action.
*
* @param array $result the result of the action
* @since 1.0.0
*/
do_action( 'upserv_did_browse_package', $result );

if ( empty( $result ) ) {
@ -73,6 +143,16 @@ class Package_API {
return (object) $result;
}

/**
* Read package information
*
* Get information about a single package.
*
* @param string $package_id The package ID/slug.
* @param string $type The package type.
* @return object Response with package information.
* @since 1.0.0
*/
public function read( $package_id, $type ) {
$result = upserv_get_package_info( $package_id, false );

@ -86,8 +166,23 @@ class Package_API {
unset( $result['file_path'] );
}

/**
* Filter the result of the `read` operation of the Package API.
*
* @param array $result The result of the `read` operation
* @param string $package_id The slug of the read package
* @param string $type The type of the read package
* @return array The filtered result
* @since 1.0.0
*/
$result = apply_filters( 'upserv_package_read', $result, $package_id, $type );

/**
* Fired after the `read` Package API action.
*
* @param array $result the result of the action
* @since 1.0.0
*/
do_action( 'upserv_did_read_package', $result );

if ( ! $result ) {
@ -101,6 +196,16 @@ class Package_API {
return (object) $result;
}

/**
* Edit a package
*
* If a package exists, update it by uploading a valid package file, or by downloading it if using a VCS.
*
* @param string $package_id The package ID/slug.
* @param string $type The package type.
* @return object Response with package information or error.
* @since 1.0.0
*/
public function edit( $package_id, $type ) {
$result = false;
$config = self::get_config();
@ -118,6 +223,15 @@ class Package_API {
$result = $result && ! is_wp_error( $result ) ? upserv_get_package_info( $package_id, false ) : $result;
}

/**
* Filter the result of the `edit` operation of the Package API.
*
* @param array $result The result of the `edit` operation
* @param string $package_id The slug of the edited package
* @param string $type The type of the edited package
* @return array The filtered result
* @since 1.0.0
*/
$result = apply_filters( 'upserv_package_edit', $result, $package_id, $type );

if ( empty( $exists ) ) {
@ -139,12 +253,28 @@ class Package_API {
'message' => __( 'Package could not be edited - invalid parameters.', 'updatepulse-server' ),
);
} else {
/**
* Fired after the `edit` Package API action.
*
* @param array $result the result of the action
* @since 1.0.0
*/
do_action( 'upserv_did_edit_package', $result );
}

return (object) $result;
}

/**
* Add a package
*
* If a package does not exist, upload it by providing a valid package file, or download it if using a VCS.
*
* @param string $package_id The package ID/slug.
* @param string $type The package type.
* @return object Response with package information or error.
* @since 1.0.0
*/
public function add( $package_id, $type ) {
$result = false;
$config = self::get_config();
@ -162,6 +292,15 @@ class Package_API {
$result = $result && ! is_wp_error( $result ) ? upserv_get_package_info( $package_id, false ) : $result;
}

/**
* Filter the result of the `add` operation of the Package API.
*
* @param array $result The result of the `add` operation
* @param string $package_id The slug of the added package
* @param string $type The type of the added package
* @return array The filtered result
* @since 1.0.0
*/
$result = apply_filters( 'upserv_package_add', $result, $package_id, $type );

if ( ! empty( $exists ) ) {
@ -183,19 +322,59 @@ class Package_API {
'message' => __( 'Package could not be added - invalid parameters.', 'updatepulse-server' ),
);
} else {
/**
* Fired after the `add` Package API action.
*
* @param array $result the result of the action
* @since 1.0.0
*/
do_action( 'upserv_did_add_package', $result );
}

return (object) $result;
}

/**
* Delete a package
*
* Remove a package from the system.
*
* @param string $package_id The package ID/slug.
* @param string $type The package type.
* @return object Response with deletion status or error.
* @since 1.0.0
*/
public function delete( $package_id, $type ) {
/**
* Fired before the `delete` Package API action.
*
* @param string $package_slug the slug of the package to be deleted
* @param string $type the type of the package to be deleted
* @since 1.0.0
*/
do_action( 'upserv_pre_delete_package', $package_id, $type );

$result = upserv_delete_package( $package_id );
/**
* Filter the result of the `delete` operation of the Package API.
*
* @param bool $result The result of the `delete` operation
* @param string $package_id The slug of the deleted package
* @param string $type The type of the deleted package
* @return bool The filtered result
* @since 1.0.0
*/
$result = apply_filters( 'upserv_package_delete', $result, $package_id, $type );

if ( $result ) {
/**
* Fired after the `delete` Package API action.
*
* @param bool $result the result of the `delete` operation
* @param string $package_slug the slug of the deleted package
* @param string $type the type of the deleted package
* @since 1.0.0
*/
do_action( 'upserv_did_delete_package', $result, $package_id, $type );
} else {
$this->http_response_code = 404;
@ -208,6 +387,16 @@ class Package_API {
return (object) $result;
}

/**
* Download a package
*
* Initiate download of a package file.
*
* @param string $package_id The package ID/slug.
* @param string $type The package type.
* @return array Error information if package not found.
* @since 1.0.0
*/
public function download( $package_id, $type ) {
$path = upserv_get_local_package_path( $package_id );

@ -222,15 +411,40 @@ class Package_API {
}

upserv_download_local_package( $package_id, $path, false );
/**
* Fired after the `download` Package API action.
*
* @param string $package_slug the slug of the downloaded package
* @since 1.0.0
*/
do_action( 'upserv_did_download_package', $package_id );

exit;
}

/**
* Generate signed URL for package download
*
* Create a secure URL for downloading packages.
*
* @param string $package_id The package ID/slug.
* @param string $type The package type.
* @return object Response with signed URL information.
* @since 1.0.0
*/
public function signed_url( $package_id, $type ) {
$package_id = filter_var( $package_id, FILTER_SANITIZE_URL );
$type = filter_var( $type, FILTER_SANITIZE_URL );
$token = apply_filters( 'upserv_package_signed_url_token', false, $package_id, $type );
/**
* Filter the token used to sign the URL.
*
* @param mixed $token The token used to sign the URL
* @param string $package_id The slug of the package for which the URL needs to be signed
* @param string $type The type of the package for which the URL needs to be signed
* @return mixed The filtered token
* @since 1.0.0
*/
$token = apply_filters( 'upserv_package_signed_url_token', false, $package_id, $type );

if ( ! $token ) {
$token = upserv_create_nonce(
@ -244,6 +458,15 @@ class Package_API {
);
}

/**
* Filter the result of the `signed_url` operation of the Package API.
*
* @param array $result The result of the `signed_url` operation
* @param string $package_id The slug of the package for which the URL was signed
* @param string $type The type of the package for which the URL was signed
* @return array The filtered result
* @since 1.0.0
*/
$result = apply_filters(
'upserv_package_signed_url',
array(
@ -262,6 +485,12 @@ class Package_API {
);

if ( $result ) {
/**
* Fired after the `signed_url` Package API action.
*
* @param array $result the result of the action
* @since 1.0.0
*/
do_action( 'upserv_did_signed_url_package', $result );
} else {
$this->http_response_code = 404;
@ -276,13 +505,19 @@ class Package_API {

// WordPress hooks ---------------------------------------------

/**
* Add API endpoints
*
* Register the rewrite rules for the Package API endpoints.
*
* @since 1.0.0
*/
public function add_endpoints() {
add_rewrite_rule(
'^updatepulse-server-package-api/(plugin|theme|generic)/(.+)/*?$',
'index.php?type=$matches[1]&package_id=$matches[2]&$matches[3]&__upserv_package_api=1&',
'top'
);

add_rewrite_rule(
'^updatepulse-server-package-api/*?$',
'index.php?$matches[1]&__upserv_package_api=1&',
@ -290,6 +525,13 @@ class Package_API {
);
}

/**
* Parse API requests
*
* Handle incoming API requests to the Package API endpoints.
*
* @since 1.0.0
*/
public function parse_request() {
global $wp;

@ -300,6 +542,15 @@ class Package_API {
}
}

/**
* Register query variables
*
* Add custom query variables used by the Package API.
*
* @param array $query_vars Existing query variables.
* @return array Modified query variables.
* @since 1.0.0
*/
public function query_vars( $query_vars ) {
$query_vars = array_merge(
$query_vars,
@ -318,6 +569,16 @@ class Package_API {
return $query_vars;
}

/**
* Handle package saved to local event
*
* Actions to perform when a remote package has been saved locally.
*
* @param bool $local_ready Whether the local package is ready.
* @param string $package_type The type of the package.
* @param string $package_slug The slug of the package.
* @since 1.0.0
*/
public function upserv_saved_remote_package_to_local( $local_ready, $package_type, $package_slug ) {

if ( ! $local_ready ) {
@ -336,6 +597,15 @@ class Package_API {
upserv_schedule_webhook( $payload, 'package' );
}

/**
* Handle pre-delete package event
*
* Actions to perform before a package is deleted.
*
* @param string $package_slug The slug of the package.
* @param string $package_type The type of the package.
* @since 1.0.0
*/
public function upserv_pre_delete_package( $package_slug, $package_type ) {
wp_cache_set(
'upserv_package_deleted_info' . $package_slug . '_' . $package_type,
@ -344,6 +614,16 @@ class Package_API {
);
}

/**
* Handle post-delete package event
*
* Actions to perform after a package is deleted.
*
* @param bool $result The result of the deletion.
* @param string $package_slug The slug of the package.
* @param string $package_type The type of the package.
* @since 1.0.0
*/
public function upserv_did_delete_package( $result, $package_slug, $package_type ) {
$package_info = wp_cache_get(
'upserv_package_deleted_info' . $package_slug . '_' . $package_type,
@ -362,6 +642,14 @@ class Package_API {
}
}

/**
* Handle package downloaded event
*
* Actions to perform after a package is downloaded.
*
* @param string $package_slug The slug of the downloaded package.
* @since 1.0.0
*/
public function upserv_did_download_package( $package_slug ) {
$payload = array(
'event' => 'package_downloaded',
@ -373,6 +661,15 @@ class Package_API {
upserv_schedule_webhook( $payload, 'package' );
}

/**
* Register package API actions
*
* Add descriptions for all available Package API actions.
*
* @param array $actions Existing API actions.
* @return array Modified API actions with descriptions.
* @since 1.0.0
*/
public function upserv_api_package_actions( $actions ) {
$actions['browse'] = __( 'Get information about multiple packages', 'updatepulse-server' );
$actions['read'] = __( 'Get information about a single package', 'updatepulse-server' );
@ -384,6 +681,15 @@ class Package_API {
return $actions;
}

/**
* Register webhook events
*
* Add supported webhook events for the Package API.
*
* @param array $webhook_events Existing webhook events.
* @return array Modified webhook events.
* @since 1.0.0
*/
public function upserv_api_webhook_events( $webhook_events ) {

if ( isset( $webhook_events['package'], $webhook_events['package']['events'] ) ) {
@ -395,6 +701,18 @@ class Package_API {
return $webhook_events;
}

/**
* Fetch nonce for public API
*
* Validate nonce for public API requests.
*
* @param mixed $nonce The nonce to validate.
* @param mixed $true_nonce The true nonce value.
* @param int $expiry The nonce expiry time.
* @param array $data Additional data associated with the nonce.
* @return mixed Validated nonce or null if invalid.
* @since 1.0.0
*/
public function upserv_fetch_nonce_public( $nonce, $true_nonce, $expiry, $data ) {
global $wp;

@ -423,6 +741,18 @@ class Package_API {
return $nonce;
}

/**
* Fetch nonce for private API
*
* Validate nonce for private API requests.
*
* @param mixed $nonce The nonce to validate.
* @param mixed $true_nonce The true nonce value.
* @param int $expiry The nonce expiry time.
* @param array $data Additional data associated with the nonce.
* @return mixed Validated nonce or null if invalid.
* @since 1.0.0
*/
public function upserv_fetch_nonce_private( $nonce, $true_nonce, $expiry, $data ) {
$config = self::get_config();
$valid = false;
@ -460,6 +790,15 @@ class Package_API {
return $nonce;
}

/**
* Modify nonce API payload
*
* Adjust the payload for API nonce creation.
*
* @param array $payload The original payload.
* @return array Modified payload.
* @since 1.0.0
*/
public function upserv_nonce_api_payload( $payload ) {
global $wp;

@ -503,12 +842,30 @@ class Package_API {
return $payload;
}

/**
* Filter package information inclusion
*
* Determine whether to include package information in responses.
*
* @param bool $_include Current inclusion status.
* @param array $info Package information.
* @return bool Whether to include the package information.
* @since 1.0.0
*/
public function upserv_package_info_include( $_include, $info ) {
return ! upserv_get_option( 'use_vcs' ) || upserv_is_package_whitelisted( $info['slug'] );
}

// Misc. -------------------------------------------------------

/**
* Check if currently processing an API request
*
* Determine whether the current request is a Package API request.
*
* @return bool Whether the current request is a Package API request.
* @since 1.0.0
*/
public static function is_doing_api_request() {

if ( null === self::$doing_api_request ) {
@ -518,6 +875,14 @@ class Package_API {
return self::$doing_api_request;
}

/**
* Get Package API configuration
*
* Retrieve and filter the Package API configuration settings.
*
* @return array Package API configuration.
* @since 1.0.0
*/
public static function get_config() {

if ( ! self::$config ) {
@ -530,9 +895,24 @@ class Package_API {
self::$config = $config;
}

/**
* Filter the configuration of the Package API.
*
* @param array $config The configuration of the Package API
* @return array The filtered configuration
* @since 1.0.0
*/
return apply_filters( 'upserv_package_api_config', self::$config );
}

/**
* Get Package API instance
*
* Retrieve or create the Package API singleton instance.
*
* @return Package_API The Package API instance.
* @since 1.0.0
*/
public static function get_instance() {

if ( ! self::$instance ) {
@ -546,6 +926,14 @@ class Package_API {
* Protected methods
*******************************************************************/

/**
* Get uploaded file
*
* Retrieve the uploaded file from a request.
*
* @return array|false File information array or false if no valid file.
* @since 1.0.0
*/
protected function get_file() {
$files = $_FILES; // phpcs:ignore WordPress.Security.NonceVerification.Missing
$return = false;
@ -562,6 +950,17 @@ class Package_API {
return $return;
}

/**
* Process uploaded package file
*
* Handle validation and processing of an uploaded package file.
*
* @param array $file The file information array.
* @param string $package_id The package ID/slug.
* @param string $type The package type.
* @return bool|WP_Error True on success, WP_Error on failure.
* @since 1.0.0
*/
protected function process_file( $file, $package_id, $type ) {
list(
$local_filename,
@ -656,11 +1055,30 @@ class Package_API {
upserv_set_package_metadata( $package_id, $meta );
}

/**
* Fired after an attempt to save a downloaded package on the file system has been performed.
* Fired during client update API request.
*
* @param bool $result `true` in case of success, `false` otherwise
* @param string $type type of the saved package - `"Plugin"`, `"Theme"`, or `"Generic"`
* @param string $package_slug slug of the saved package
* @since 1.0.0
*/
do_action( 'upserv_saved_remote_package_to_local', true, $type, $package_id );

return $result;
}

/**
* Download a package file from VCS
*
* Fetch a package from its version control system source.
*
* @param string $package_id The package ID/slug.
* @param string $type The package type.
* @return bool|WP_Error True on success, WP_Error on failure.
* @since 1.0.0
*/
protected function download_file( $package_id, $type ) {
$vcs_url = filter_input( INPUT_POST, 'vcs_url', FILTER_SANITIZE_URL );
$branch = sanitize_text_field( wp_unslash( filter_input( INPUT_POST, 'branch' ) ) );
@ -676,6 +1094,14 @@ class Package_API {
return $result;
}

/**
* Authorize public API request
*
* Validate authorization for public API endpoints.
*
* @return bool Whether the request is authorized.
* @since 1.0.0
*/
protected function authorize_public() {
$nonce = sanitize_text_field( wp_unslash( filter_input( INPUT_GET, 'token' ) ) );

@ -692,6 +1118,15 @@ class Package_API {
return $result;
}

/**
* Authorize private API request
*
* Validate authorization for private API endpoints.
*
* @param string $action The requested API action.
* @return bool Whether the request is authorized.
* @since 1.0.0
*/
protected function authorize_private( $action ) {
$token = false;
$is_auth = false;
@ -726,7 +1161,24 @@ class Package_API {
return $is_auth;
}

/**
* Check if API action is public
*
* Determine if a specific API action is available publicly.
*
* @param string $method The API method to check.
* @return bool Whether the API action is public.
* @since 1.0.0
*/
protected function is_api_public( $method ) {
/**
* Filter the public API actions; public actions can be accessed via the `GET` method and a token,
* all other actions are considered private and can only be accessed via the `POST` method.
*
* @param array $public_api_actions The public API actions
* @return array The filtered public API actions
* @since 1.0.0
*/
$public_api = apply_filters(
'upserv_package_public_api_actions',
array( 'download' )
@ -736,6 +1188,13 @@ class Package_API {
return $is_api_public;
}

/**
* Handle incoming API requests
*
* Process and respond to Package API requests.
*
* @since 1.0.0
*/
protected function handle_api_request() {
global $wp;

@ -765,6 +1224,15 @@ class Package_API {
}

if ( ! $malformed_request ) {
/**
* Filter whether the Package API request is authorized
*
* @param bool $authorized Whether the Package API request is authorized
* @param string $method The method of the request - `GET` or `POST`
* @param array $payload The payload of the request
* @return bool The filtered authorization status
* @since 1.0.0
*/
$authorized = apply_filters(
'upserv_package_api_request_authorized',
(
@ -782,6 +1250,13 @@ class Package_API {
);

if ( $authorized ) {
/**
* Fired before the Package API request is processed; useful to bypass the execution of currently implemented actions, or implement new actions.
*
* @param string $action the Package API action
* @param array $payload the payload of the request
* @since 1.0.0
*/
do_action( 'upserv_package_api_request', $method, $payload );

if ( method_exists( $this, $method ) ) {
@ -823,6 +1298,14 @@ class Package_API {
wp_send_json( $response, $this->http_response_code, Utils::JSON_OPTIONS );
}

/**
* Authorize request by IP address
*
* Validate if the request IP is allowed.
*
* @return bool Whether the request IP is authorized.
* @since 1.0.0
*/
protected function authorize_ip() {
$result = false;
$config = self::get_config();

View file

@ -10,13 +10,42 @@ use Anyape\UpdatePulse\Server\Manager\Data_Manager;
use Anyape\UpdatePulse\Server\Scheduler\Scheduler;
use Anyape\Utils\Utils;

/**
* Update API class
*
* @since 1.0.0
*/
class Update_API {

/**
* Is doing API request
*
* @var bool|null
* @since 1.0.0
*/
protected static $doing_api_request = null;
/**
* Instance
*
* @var Update_API|null
* @since 1.0.0
*/
protected static $instance;

/**
* Update server object
*
* @var object|null
* @since 1.0.0
*/
protected $update_server;

/**
* Constructor
*
* @param boolean $init_hooks Whether to initialize hooks.
* @since 1.0.0
*/
public function __construct( $init_hooks = false ) {

if ( $init_hooks ) {
@ -38,6 +67,13 @@ class Update_API {

// WordPress hooks ---------------------------------------------

/**
* Add API endpoints
*
* Register the rewrite rules for the Update API endpoints.
*
* @since 1.0.0
*/
public function add_endpoints() {
add_rewrite_rule(
'^updatepulse-server-update-api/*$',
@ -46,6 +82,13 @@ class Update_API {
);
}

/**
* Parse API requests
*
* Handle incoming API requests to the Update API endpoints.
*
* @since 1.0.0
*/
public function parse_request() {
global $wp;

@ -54,6 +97,15 @@ class Update_API {
}
}

/**
* Register query variables
*
* Add custom query variables used by the Update API.
*
* @param array $query_vars Existing query variables.
* @return array Modified query variables.
* @since 1.0.0
*/
public function query_vars( $query_vars ) {
$query_vars = array_merge(
$query_vars,
@ -69,10 +121,29 @@ class Update_API {
return $query_vars;
}

/**
* Handle checked remote package update event
*
* Actions to perform when a remote package update has been checked.
*
* @param bool $needs_update Whether the package needs an update.
* @param string $type The type of the package.
* @param string $slug The slug of the package.
* @since 1.0.0
*/
public function upserv_checked_remote_package_update( $needs_update, $type, $slug ) {
$this->schedule_check_remote_event( $slug );
}

/**
* Handle package registered from VCS event
*
* Actions to perform when a package has been registered from VCS.
*
* @param bool $result The result of the registration.
* @param string $slug The slug of the package.
* @since 1.0.0
*/
public function upserv_registered_package_from_vcs( $result, $slug ) {

if ( $result ) {
@ -80,6 +151,16 @@ class Update_API {
}
}

/**
* Handle package removed event
*
* Actions to perform when a package has been removed.
*
* @param bool $result The result of the removal.
* @param string $type The type of the package.
* @param string $slug The slug of the package.
* @since 1.0.0
*/
public function upserv_removed_package( $result, $type, $slug ) {

if ( $result ) {
@ -87,6 +168,18 @@ class Update_API {
}
}

/**
* Pre-filter package information
*
* Filter package information before the update check.
*
* @param array $info Package information.
* @param object $api_obj The API object.
* @param mixed $ref Reference value.
* @param object $update_checker The update checker object.
* @return array Filtered package information.
* @since 1.0.0
*/
public function puc_request_info_pre_filter( $info, $api_obj, $ref, $update_checker ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$vcs_config = upserv_get_package_vcs_config( $info['slug'] );

@ -94,6 +187,13 @@ class Update_API {
return $info;
}

/**
* Filter whether to filter the packages retrieved from the Version Control System.
*
* @param bool $filter_packages Whether to filter the packages retrieved from the Version Control System.
* @param array $info The information of the package from the VCS.
* @since 1.0.0
*/
$filter_packages = apply_filters(
'upserv_vcs_filter_packages',
$vcs_config['filter_packages'],
@ -109,6 +209,18 @@ class Update_API {
return $info;
}

/**
* Filter package information result
*
* Filter package information after the update check.
*
* @param array $info Package information.
* @param object $api_obj The API object.
* @param mixed $ref Reference value.
* @param object $checker The update checker object.
* @return array Filtered package information.
* @since 1.0.0
*/
public function puc_request_info_result( $info, $api_obj, $ref, $checker ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$vcs_config = upserv_get_package_vcs_config( $info['slug'] );

@ -116,6 +228,13 @@ class Update_API {
return $info;
}

/**
* Filter whether to filter the packages retrieved from the Version Control System.
*
* @param bool $filter_packages Whether to filter the packages retrieved from the Version Control System.
* @param array $info The information of the package from the VCS.
* @since 1.0.0
*/
$filter_packages = apply_filters(
'upserv_vcs_filter_packages',
$vcs_config['filter_packages'],
@ -133,6 +252,14 @@ class Update_API {

// Misc. -------------------------------------------------------

/**
* Check if currently processing an API request
*
* Determine whether the current request is an Update API request.
*
* @return bool Whether the current request is an Update API request.
* @since 1.0.0
*/
public static function is_doing_api_request() {

if ( null === self::$doing_api_request ) {
@ -142,6 +269,14 @@ class Update_API {
return self::$doing_api_request;
}

/**
* Get Update API instance
*
* Retrieve or create the Update API singleton instance.
*
* @return Update_API The Update API instance.
* @since 1.0.0
*/
public static function get_instance() {

if ( ! self::$instance ) {
@ -151,6 +286,16 @@ class Update_API {
return self::$instance;
}

/**
* Check for remote package updates
*
* Verify if a remote package has updates available.
*
* @param string $slug The package slug.
* @param string $type The package type.
* @return bool|mixed Result of the remote update check.
* @since 1.0.0
*/
public function check_remote_update( $slug, $type ) {
$this->init_server( $slug );

@ -163,6 +308,17 @@ class Update_API {
return $this->update_server->check_remote_package_update( $slug );
}

/**
* Download a remote package
*
* Download and process a package from a remote source.
*
* @param string $slug The package slug.
* @param string|null $type The package type.
* @param bool $force Whether to force the download.
* @return bool Whether the download was successful.
* @since 1.0.0
*/
public function download_remote_package( $slug, $type = null, $force = false ) {
$result = false;

@ -215,6 +371,14 @@ class Update_API {
* Protected methods
*******************************************************************/

/**
* Schedule remote check event
*
* Set up a scheduled event to check for remote package updates.
*
* @param string $slug The package slug.
* @since 1.0.0
*/
protected function schedule_check_remote_event( $slug ) {
$vcs_config = upserv_get_package_vcs_config( $slug );

@ -238,6 +402,14 @@ class Update_API {
return;
}

/**
* Filter the package update remote check frequency set in the configuration.
* Fired during client update API request.
*
* @param string $frequency The frequency set in the configuration.
* @param string $package_slug The slug of the package to check for updates.
* @since 1.0.0
*/
$frequency = apply_filters(
'upserv_check_remote_frequency',
$vcs_config['check_frequency'],
@ -252,6 +424,18 @@ class Update_API {
$params
);

/**
* Fired after a remote check event has been scheduled for a package.
* Fired during client update API request.
*
* @param bool $result Whether the event was scheduled.
* @param string $package_slug Slug of the package for which the event was scheduled.
* @param int $timestamp Timestamp for when to run the event the first time after it's been scheduled.
* @param string $frequency Frequency at which the event would be ran.
* @param string $hook Event hook to fire when the event is ran.
* @param array $params Parameters passed to the actions registered to $hook when the event is ran.
* @since 1.0.0
*/
do_action(
'upserv_scheduled_check_remote_event',
$result,
@ -263,6 +447,13 @@ class Update_API {
);
}

/**
* Handle API requests
*
* Process and respond to Update API requests.
*
* @since 1.0.0
*/
protected function handle_api_request() {
global $wp;

@ -288,6 +479,13 @@ class Update_API {
ARRAY_FILTER_USE_KEY
)
);
/**
* Filter the parameters used to handle the request made by a client plugin, theme, or generic package to the plugin's API.
* Fired during client update API request.
*
* @param array $params The parameters of the request to the API.
* @since 1.0.0
*/
$params = apply_filters( 'upserv_handle_update_request_params', array_merge( $query, $params ) );

$this->init_server( $params['slug'] );
@ -303,10 +501,25 @@ class Update_API {
);
}

/**
* Fired before handling the request made by a client plugin, theme, or generic package to the plugin's API.
* Fired during client update API request.
*
* @param array $request_params The parameters or the request to the API.
* @since 1.0.0
*/
do_action( 'upserv_before_handle_update_request', $params );
$this->update_server->handle_request( $params );
}

/**
* Initialize update server
*
* Set up the update server for a specific package.
*
* @param string $slug The package slug.
* @since 1.0.0
*/
protected function init_server( $slug ) {
$check_manual = false;

@ -341,13 +554,31 @@ class Update_API {
'directory' => Data_Manager::get_data_dir(),
'vcs_config' => isset( $vcs_config ) ? $vcs_config : null,
);
/**
* Filter the class name to use to instantiate a `Anyape\UpdatePulse\Server\Server\Update\Update_Server` object.
* Fired during client update API request.
*
* @param string $class_name The class name to use to instantiate a `Anyape\UpdatePulse\Server\Server\Update\Update_Server` object.
* @param string $package_slug The slug of the package to serve.
* @param array $config The configuration to use to serve the package.
* @since 1.0.0
*/
$_class_name = apply_filters(
'upserv_server_class_name',
str_replace( 'API', 'Server\\Update', __NAMESPACE__ ) . '\\Update_Server',
$slug,
$filter_args
);
$args = apply_filters(
/**
* Filter the arguments to pass to the constructor of the `Anyape\UpdatePulse\Server\Server\Update\Update_Server` object.
* Fired during client update API request.
*
* @param array $args The arguments to pass to the constructor of the `Anyape\UpdatePulse\Server\Server\Update\Update_Server` object.
* @param string $package_slug The slug of the package to serve.
* @param array $config The configuration to use to serve the package.
* @since 1.0.0
*/
$args = apply_filters(
'upserv_server_constructor_args',
array(
home_url( '/updatepulse-server-update-api/' ),

View file

@ -13,14 +13,48 @@ use Anyape\UpdatePulse\Server\Manager\Data_Manager;
use Anyape\UpdatePulse\Server\Scheduler\Scheduler;
use Anyape\Utils\Utils;

/**
* Webhook API class
*
* @since 1.0.0
*/
class Webhook_API {

/**
* Is doing API request
*
* @var bool|null
*/
protected static $doing_api_request = null;

/**
* Instance
*
* @var Webhook_API|null
*/
protected static $instance;

/**
* Webhooks configuration
*
* @var array
*/
protected $webhooks;

/**
* HTTP response code
*
* @var int
*/
protected $http_response_code = 200;

/**
* Constructor
*
* @since 1.0.0
*
* @param boolean $init_hooks Whether to initialize hooks
*/
public function __construct( $init_hooks = false ) {
$this->webhooks = upserv_get_option( 'api/webhooks', array() );
$vcs_configs = upserv_get_option( 'vcs', array() );
@ -56,6 +90,11 @@ class Webhook_API {

// WordPress hooks ---------------------------------------------

/**
* Add API endpoints
*
* Register the rewrite rules for the Webhook API endpoints.
*/
public function add_endpoints() {
add_rewrite_rule( '^updatepulse-server-webhook$', 'index.php?__upserv_webhook=1&', 'top' );
add_rewrite_rule(
@ -65,6 +104,11 @@ class Webhook_API {
);
}

/**
* Parse API requests
*
* Handle incoming API requests to the Webhook API endpoints.
*/
public function parse_request() {
global $wp;

@ -75,6 +119,14 @@ class Webhook_API {
}
}

/**
* Register query variables
*
* Add custom query variables used by the Webhook API.
*
* @param array $query_vars Existing query variables.
* @return array Modified query variables.
*/
public function query_vars( $query_vars ) {
$query_vars = array_merge(
$query_vars,
@ -88,6 +140,11 @@ class Webhook_API {
return $query_vars;
}

/**
* Handle invalid webhook requests
*
* Display error page for unauthorized webhook requests.
*/
public function upserv_webhook_invalid_request() {
$protocol = empty( $_SERVER['SERVER_PROTOCOL'] ) ? 'HTTP/1.1' : sanitize_text_field( wp_unslash( $_SERVER['SERVER_PROTOCOL'] ) );

@ -105,12 +162,36 @@ class Webhook_API {
exit( -1 );
}

/**
* Process webhook requests
*
* Determine whether to process webhook requests based on branch matching.
* If no branch is specified, the request will be processed to account for events
* registered to the webhook that do not have a branch associated with them.
*
* @param bool $process Current process status.
* @param array $payload Request payload.
* @param string $slug Package slug.
* @param string $type Package type.
* @param bool $package_exists Whether package already exists.
* @param array $vcs_config Version control system configuration.
* @return bool Whether to process the webhook request.
*/
public function upserv_webhook_process_request( $process, $payload, $slug, $type, $package_exists, $vcs_config ) {
return $this->get_payload_vcs_branch( $payload ) === $vcs_config['branch'];
$branch = $this->get_payload_vcs_branch( $payload );

return $process && ( $branch === $vcs_config['branch'] || ! $branch );
}

// Misc. -------------------------------------------------------

/**
* Check if currently processing an API request
*
* Determine whether the current request is a Webhook API request.
*
* @return bool Whether the current request is a Webhook API request.
*/
public static function is_doing_api_request() {

if ( null === self::$doing_api_request ) {
@ -120,6 +201,13 @@ class Webhook_API {
return self::$doing_api_request;
}

/**
* Get Webhook API instance
*
* Retrieve or create the Webhook API singleton instance.
*
* @return Webhook_API The Webhook API instance.
*/
public static function get_instance() {

if ( ! self::$instance ) {
@ -129,6 +217,16 @@ class Webhook_API {
return self::$instance;
}

/**
* Schedule webhook
*
* Schedule a webhook to be fired based on an event.
*
* @param array $payload Webhook payload data.
* @param string $event_type Event type identifier.
* @param bool $instant Whether to fire webhook immediately.
* @return void|WP_Error WP_Error on failure.
*/
public function schedule_webhook( $payload, $event_type, $instant = false ) {

if ( empty( $this->webhooks ) ) {
@ -169,12 +267,29 @@ class Webhook_API {
}
}

/**
* Filter whether to fire the webhook event.
*
* @param bool $fire Whether to fire the event.
* @param array $payload The payload of the event.
* @param string $url The target url of the event.
* @param array $webhook_setting The settings of the webhook.
* @return bool
*/
if ( apply_filters( 'upserv_webhook_fire', $fire, $payload, $info['url'], $info ) ) {
$body = wp_json_encode( $payload, Utils::JSON_OPTIONS );
$hook = 'upserv_webhook';
$params = array( $info['url'], $info['secret'], $body, current_action() );

if ( ! Scheduler::get_instance()->has_scheduled_action( $hook, $params ) ) {
/**
* Filter whether to send the webhook notification immediately.
*
* @param bool $instant Whether to send the notification immediately.
* @param array $payload The payload of the event.
* @param string $event_type The type of event.
* @return bool
*/
$instant = apply_filters(
'upserv_schedule_webhook_is_instant',
$instant,
@ -194,6 +309,17 @@ class Webhook_API {
}
}

/**
* Fire webhook
*
* Send an HTTP request to the webhook endpoint.
*
* @param string $url Webhook endpoint URL.
* @param string $secret Secret key for signature.
* @param string $body Request body.
* @param string $action Current action.
* @return array|WP_Error HTTP response or WP_Error on failure.
*/
public function fire_webhook( $url, $secret, $body, $action ) {
return wp_remote_post(
$url,
@ -213,6 +339,11 @@ class Webhook_API {
* Protected methods
*******************************************************************/

/**
* Handle remote test
*
* Process and respond to webhook test requests.
*/
protected function handle_remote_test() {

if ( empty( $_SERVER['HTTP_X_UPDATEPULSE_SIGNATURE_256'] ) ) {
@ -266,6 +397,11 @@ class Webhook_API {
wp_send_json( $valid, $valid ? 200 : 403, Utils::JSON_OPTIONS );
}

/**
* Handle API request
*
* Process webhook API requests and return appropriate responses.
*/
protected function handle_api_request() {
global $wp;

@ -273,14 +409,34 @@ class Webhook_API {
$this->handle_remote_test();
}

$response = array();
$payload = $this->get_payload();
$url = $this->get_payload_vcs_url( $payload );
$branch = $this->get_payload_vcs_branch( $payload );
$vcs_configs = upserv_get_option( 'vcs', array() );
$vcs_key = hash( 'sha256', trailingslashit( $url ) . '|' . $branch );
$vcs_config = isset( $vcs_configs[ $vcs_key ] ) ? $vcs_configs[ $vcs_key ] : false;
$response = array();
$payload = $this->get_payload();
$url = $this->get_payload_vcs_url( $payload );
$branch = $this->get_payload_vcs_branch( $payload );
$vcs_configs = upserv_get_option( 'vcs', array() );
$vcs_key = hash( 'sha256', trailingslashit( $url ) . '|' . $branch );
$vcs_config = isset( $vcs_configs[ $vcs_key ] ) ? $vcs_configs[ $vcs_key ] : false;
$vcs_candidates = $vcs_config ? array( $vcs_key => $vcs_config ) : array();

if ( empty( $vcs_candidates ) ) {

foreach ( $vcs_configs as $config ) {

if ( 0 === strpos( $config['url'], trailingslashit( $url ) ) ) {
$vcs_candidates[] = $config;
}
}
}

if ( 1 === count( $vcs_candidates ) ) {
$vcs_config = reset( $vcs_candidates );
}

/**
* Fired before handling a webhook request; fired whether it will be processed or not.
*
* @param array $config The configuration used to handle webhook requests.
*/
do_action( 'upserv_webhook_before_handling_request', $vcs_config );

if ( $vcs_config && $this->validate_request( $vcs_config ) ) {
@ -290,10 +446,19 @@ class Webhook_API {
$type = isset( $wp->query_vars['type'] ) ?
trim( rawurldecode( $wp->query_vars['type'] ) ) :
null;
$delay = $vcs_config['check_delay'];
$delay = $vcs_config ? $vcs_config['check_delay'] : 0;
$dir = Data_Manager::get_data_dir( 'packages' );
$package_exists = null;
$payload = $payload ? wp_json_encode( $payload ) : false;
/**
* Filter whether the package exists on the file system before processing the Webhook.
*
* @param bool|null $package_exists Whether the package exists on the file system; return `null` to leave the decision to the default behavior.
* @param array $payload The payload of the request.
* @param string $slug The slug of the package.
* @param string $type The type of the package.
* @param array $vcs_config The configuration used to handle webhook requests.
* @return bool|null
*/
$package_exists = apply_filters(
'upserv_webhook_package_exists',
$package_exists,
@ -308,6 +473,17 @@ class Webhook_API {
$package_exists = file_exists( $package_path );
}

/**
* Filter whether to process the Webhook request.
*
* @param bool $process Whether to process the Webhook request.
* @param array $payload The payload of the request.
* @param string $slug The slug of the package.
* @param string $type The type of the package.
* @param bool $package_exists Whether the package exists on the file system.
* @param array $vcs_config The configuration used to handle webhook requests.
* @return bool
*/
$process = apply_filters(
'upserv_webhook_process_request',
true,
@ -319,6 +495,15 @@ class Webhook_API {
);

if ( $process ) {
/**
* Fired before processing a webhook request.
*
* @param array $payload The data sent by the Version Control System.
* @param string $slug The slug of the package triggering the webhook.
* @param string $type The type of the package triggering the webhook.
* @param bool $package_exists Whether the package exists on the file system.
* @param array $vcs_config The configuration used to handle webhook requests.
*/
do_action(
'upserv_webhook_before_processing_request',
$payload,
@ -340,15 +525,38 @@ class Webhook_API {

if ( ! $scheduled_action ) {
Scheduler::get_instance()->unschedule_all_actions( $hook );
/**
* Fired after a remote check schedule event has been unscheduled for a package.
*
* @param string $package_slug The slug of the package for which a remote check event has been unscheduled.
* @param string $scheduled_hook The remote check event hook that has been unscheduled.
*/
do_action( 'upserv_cleared_check_remote_schedule', $slug, $hook );
}

/**
* Filter the delay time for remote package checks.
*
* @param int $delay The delay time in minutes.
* @param string $slug The slug of the package.
* @return int
*/
$delay = apply_filters( 'upserv_check_remote_delay', $delay, $slug );
$timestamp = ( $delay ) ?
time() + ( abs( intval( $delay ) ) * MINUTE_IN_SECONDS ) :
time();
$result = Scheduler::get_instance()->schedule_single_action( $timestamp, $hook, $params );

/**
* Fired after scheduling a remote check event.
*
* @param bool $result Whether the event was successfully scheduled.
* @param string $slug The slug of the package triggering the webhook.
* @param int $timestamp The timestamp when the event is scheduled to run.
* @param bool $is_cron Whether the event is a cron job.
* @param string $hook The hook name for the scheduled event.
* @param array $params The parameters passed to the scheduled event.
*/
do_action(
'upserv_scheduled_check_remote_event',
$result,
@ -382,6 +590,12 @@ class Webhook_API {
}
} else {
Scheduler::get_instance()->unschedule_all_actions( $hook );
/**
* Fired after a remote check schedule event has been unscheduled for a package.
*
* @param string $package_slug The slug of the package for which a remote check event has been unscheduled.
* @param string $scheduled_hook The remote check event hook that has been unscheduled.
*/
do_action( 'upserv_cleared_check_remote_schedule', $slug, $hook );

$result = upserv_download_remote_package( $slug, $type );
@ -403,6 +617,15 @@ class Webhook_API {
}
}

/**
* Fired after processing a webhook request.
*
* @param array $payload The data sent by the Version Control System.
* @param string $slug The slug of the package triggering the webhook.
* @param string $type The type of the package triggering the webhook.
* @param bool $package_exists Whether the package exists on the file system.
* @param array $vcs_config The configuration used to handle webhook requests.
*/
do_action(
'upserv_webhook_after_processing_request',
$payload,
@ -412,33 +635,92 @@ class Webhook_API {
$vcs_config
);
}
} elseif ( $vcs_config ) {
} elseif ( empty( $vcs_candidates ) ) {
$this->http_response_code = 403;
$response = array(
'code' => 'unauthorized',
'message' => __( 'Invalid request signature', 'updatepulse-server' ),
'code' => 'invalid_request',
'message' => __( 'Invalid request', 'updatepulse-server' ),
);

} elseif ( 1 < count( $vcs_candidates ) ) {
$this->http_response_code = 409;
$response = array(
'code' => 'conflict',
'message' => __( 'Multiple candidate VCS configurations found ; the event has not be processed. Please limit the events sent to the webhook to events specifying the branch in their payload (such as push), or update your UpdatePulse Server VCS configuration to avoid branch conflicts.', 'updatepulse-server' ),
'details' => array(
'vcs_candidates' => array_map(
function ( $config ) {
return array(
'url' => $config['url'],
'branch' => $config['branch'],
);
},
$vcs_candidates
),
),
);
} else {
/**
* Fired when a webhook request is invalid.
*
* @param array $config The configuration used to handle webhook requests.
*/
do_action( 'upserv_webhook_invalid_request', $vcs_config );
}

if ( 200 === $this->http_response_code ) {
$response['time_elapsed'] = Utils::get_time_elapsed();
}
$response['time_elapsed'] = Utils::get_time_elapsed();

/**
* Filter the response data to send to the Version Control System after handling the webhook request.
*
* @param array $response The response data to send to the Version Control System.
* @param int $http_response_code The HTTP response code.
* @param array $vcs_config The configuration used to handle webhook requests.
* @return array
*/
$response = apply_filters( 'upserv_webhook_response', $response, $this->http_response_code, $vcs_config );

/**
* Fired after handling a webhook request; fired whether it was processed or not.
*
* @param array $config The configuration used to handle webhook requests.
* @param array $response The response data that will be sent to the Version Control System.
*/
do_action( 'upserv_webhook_after_handling_request', $vcs_config, $response );
wp_send_json( $response, $this->http_response_code, Utils::JSON_OPTIONS );
}

/**
* Validate webhook request
*
* Verify webhook request signature against stored secrets.
*
* @param array $vcs_config Version control system configuration.
* @return bool Whether the request signature is valid.
*/
protected function validate_request( $vcs_config ) {
$valid = false;
$sign = false;
$secret = $vcs_config && isset( $vcs_config['webhook_secret'] ) ? $vcs_config['webhook_secret'] : false;
$secret = isset( $vcs_config['webhook_secret'] ) ? $vcs_config['webhook_secret'] : false;

/**
* Filter the webhook secret used for request validation.
*
* @param string|bool $secret The secret key for webhook validation.
* @param array $vcs_config The configuration used to handle webhook requests.
* @return string|bool
*/
$secret = apply_filters( 'upserv_webhook_secret', $secret, $vcs_config );

if ( ! $vcs_config || ! $secret ) {
if ( ! $secret ) {
/**
* Filter whether the webhook request is valid after validation.
*
* @param bool $valid Whether the request signature is valid.
* @param string|bool $sign The signature from the request.
* @param string $secret The secret key for webhook validation.
* @param array $vcs_config The configuration used to handle webhook requests.
* @return bool
*/
return apply_filters( 'upserv_webhook_validate_request', $valid, $sign, '', $vcs_config );
}

@ -452,6 +734,14 @@ class Webhook_API {
$sign = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_HUB_SIGNATURE'] ) );
}

/**
* Filter the signature from the webhook request.
*
* @param string|bool $sign The signature from the request.
* @param string $secret The secret key for webhook validation.
* @param array $vcs_config The configuration used to handle webhook requests.
* @return string|bool
*/
$sign = apply_filters( 'upserv_webhook_signature', $sign, $secret, $vcs_config );

if ( $sign ) {
@ -463,9 +753,25 @@ class Webhook_API {
}
}

/**
* Filter whether the webhook request is valid after validation.
*
* @param bool $valid Whether the request signature is valid.
* @param string|bool $sign The signature from the request.
* @param string $secret The secret key for webhook validation.
* @param array $vcs_config The configuration used to handle webhook requests.
* @return bool
*/
return apply_filters( 'upserv_webhook_validate_request', $valid, $sign, $secret, $vcs_config );
}

/**
* Get webhook payload
*
* Extract and decode the payload from the webhook request.
*
* @return array Decoded webhook payload.
*/
protected function get_payload() {
$payload = @file_get_contents( 'php://input' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged
$decoded = json_decode( $payload, true );
@ -480,9 +786,17 @@ class Webhook_API {
}
}

return $decoded;
return ! is_array( $decoded ) ? array( 'decoded' => $decoded ) : $decoded;
}

/**
* Get VCS URL from payload
*
* Extract the version control system URL from webhook payload.
*
* @param array $payload Webhook payload.
* @return string|false VCS URL or false if not found.
*/
protected function get_payload_vcs_url( $payload ) {
$url = false;

@ -501,6 +815,13 @@ class Webhook_API {
$url = $payload['repository']['links']['html']['href'];
}

/**
* Filter the Version Control System URL extracted from the webhook payload.
*
* @param string|bool $url The URL of the Version Control System.
* @param array $payload The webhook payload data.
* @return string|bool
*/
$url = apply_filters( 'upserv_webhook_vcs_url', $url, $payload );
$parsed_url = wp_parse_url( $url );

@ -521,6 +842,14 @@ class Webhook_API {
return trailingslashit( $url );
}

/**
* Get VCS branch from payload
*
* Extract the branch information from webhook payload.
*
* @param array $payload Webhook payload.
* @return string|false Branch name or false if not found.
*/
protected function get_payload_vcs_branch( $payload ) {
$branch = false;

@ -538,8 +867,40 @@ class Webhook_API {
'',
$payload['push']['changes'][0]['new']['name']
);
} elseif ( isset( $payload['ref'] ) ) {
$branch = str_replace( 'refs/heads/', '', $payload['ref'] );
} else {
$branch = $this->find_branch_recursively( $payload );
}

return $branch;
}

/**
* Recursively search for branch references in payload
*
* Search through nested arrays to find values starting with 'refs/heads/'.
*
* @param mixed $data Part of the payload to search through.
* @return string|false Branch name or false if not found.
*/
protected function find_branch_recursively( $data ) {

if ( is_string( $data ) && 0 === strpos( $data, 'refs/heads/' ) ) {
return str_replace( 'refs/heads/', '', $data );
}

if ( is_array( $data ) ) {

foreach ( $data as $value ) {
$result = $this->find_branch_recursively( $value );

if ( false === $result ) {
return $result;
}
}
}

return false;
}
}

View file

@ -11,12 +11,41 @@ use Anyape\UpdatePulse\Server\Manager\Data_Manager;
use Anyape\UpdatePulse\Server\Manager\Package_Manager;
use Anyape\Utils\Utils;

/**
* Main server class for UpdatePulse
*
* @since 1.0.0
*/
class UPServ {

/**
* Class instance
*
* @var UPServ|null
* @since 1.0.0
*/
protected static $instance;
/**
* Default plugin options
*
* @var array
* @since 1.0.0
*/
protected static $default_options;
/**
* Current plugin options
*
* @var array
* @since 1.0.0
*/
protected static $options;

/**
* Constructor
*
* @param boolean $init_hooks Whether to initialize hooks
* @since 1.0.0
*/
public function __construct( $init_hooks = false ) {
self::$default_options = array(
'use_vcs' => 0,
@ -79,6 +108,16 @@ class UPServ {
* Public methods
*******************************************************************/

/**
* Handle Action Scheduler failed execution
*
* Logs information about failed scheduled actions when debug mode is enabled.
*
* @param int $action_id The ID of the failed action
* @param Exception $exception The exception that was thrown
* @param string $context Additional context information
* @since 1.0.0
*/
public function action_scheduler_failed_execution( $action_id, Exception $exception, $context = '' ) {

if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
@ -96,6 +135,13 @@ class UPServ {

// WordPress hooks ---------------------------------------------

/**
* Activate plugin
*
* Runs on plugin activation to verify requirements and initialize settings.
*
* @since 1.0.0
*/
public static function activate() {

if ( ! version_compare( phpversion(), '8.0', '>=' ) ) {
@ -127,25 +173,70 @@ class UPServ {
}
}

/**
* Deactivate plugin
*
* Runs on plugin deactivation.
*
* @since 1.0.0
*/
public static function deactivate() {
flush_rewrite_rules();
}

/**
* Uninstall plugin
*
* Runs on plugin uninstallation.
*
* @since 1.0.0
*/
public static function uninstall() {
require_once UPSERV_PLUGIN_PATH . 'uninstall.php';
}

/**
* Get all plugin options
*
* Retrieves the plugin's options from the database.
*
* @return array Plugin options
* @since 1.0.0
*/
public function get_options() {
$options = get_option( 'upserv_options' );
$options = json_decode( $options, true );
$options = $options ? $options : array();
$options = array_merge( self::$default_options, $options );

/**
* Filter the plugin options.
*
* @param array $options The plugin options
* @return array The filtered options
* @since 1.0.0
*/
return apply_filters( 'upserv_get_options', $options );
}

/**
* Update plugin options
*
* Updates the plugin's options in the database.
*
* @param array $options New options to update
* @return bool Whether the update was successful
* @since 1.0.0
*/
public function update_options( $options ) {
$options = array_merge( self::$options, $options );
/**
* Filter the options before updating.
*
* @param array $options The options to update
* @return array The filtered options
* @since 1.0.0
*/
$options = apply_filters( 'upserv_update_options', $options );
$options = wp_json_encode(
$options,
@ -160,6 +251,16 @@ class UPServ {
return $result;
}

/**
* Get single option value
*
* Retrieves a specific option by its path.
*
* @param string|array $path Option path
* @param mixed $_default Default value if option not found
* @return mixed Option value
* @since 1.0.0
*/
public function get_option( $path, $_default ) {
$options = $this->get_options();
$option = Utils::access_nested_array( $options, $path );
@ -168,9 +269,27 @@ class UPServ {
$option = $_default;
}

/**
* Filter a specific option value.
*
* @param mixed $option The option value
* @param string|array $path The option path
* @return mixed The filtered option value
* @since 1.0.0
*/
return apply_filters( 'upserv_get_option', $option, $path );
}

/**
* Set option in memory
*
* Sets an option value in memory without saving to database.
*
* @param string|array $path Option path
* @param mixed $value Option value
* @return array Updated options
* @since 1.0.0
*/
public function set_option( $path, $value ) {
$options = self::$options;

@ -181,6 +300,16 @@ class UPServ {
return self::$options;
}

/**
* Update single option
*
* Updates a specific option by its path and saves to database.
*
* @param string|array $path Option path
* @param mixed $value Option value
* @return bool Whether the update was successful
* @since 1.0.0
*/
public function update_option( $path, $value ) {
$options = $this->get_options();

@ -189,6 +318,13 @@ class UPServ {
return $this->update_options( $options );
}

/**
* Initialize plugin
*
* Runs during WordPress init hook to set up the plugin.
*
* @since 1.0.0
*/
public function init() {

if ( get_transient( 'upserv_flush' ) ) {
@ -207,10 +343,26 @@ class UPServ {
}
}

/**
* Load text domain
*
* Loads the plugin's translations.
*
* @since 1.0.0
*/
public function load_textdomain() {
load_plugin_textdomain( 'updatepulse-server', false, '/languages' );
}

/**
* Register admin styles
*
* Adds stylesheets for the admin interface.
*
* @param array $styles Existing styles
* @return array Modified styles
* @since 1.0.0
*/
public function upserv_admin_styles( $styles ) {
$styles['main'] = array(
'path' => UPSERV_PLUGIN_PATH . 'css/admin/main' . upserv_assets_suffix() . '.css',
@ -232,6 +384,15 @@ class UPServ {
return $styles;
}

/**
* Register admin scripts
*
* Adds JavaScript files for the admin interface.
*
* @param array $scripts Existing scripts
* @return array Modified scripts
* @since 1.0.0
*/
public function upserv_admin_scripts( $scripts ) {
$scripts['main'] = array(
'path' => UPSERV_PLUGIN_PATH . 'js/admin/main' . upserv_assets_suffix() . '.js',
@ -246,6 +407,16 @@ class UPServ {
return $scripts;
}

/**
* Process script localization
*
* Formats localization strings for JavaScript files.
*
* @param array $l10n Localization data
* @param string $script Script name
* @return array Modified localization data
* @since 1.0.0
*/
public function upserv_scripts_l10n( $l10n, $script ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed

foreach ( $l10n as $key => $values ) {
@ -260,6 +431,14 @@ class UPServ {
return $l10n;
}

/**
* Enqueue admin scripts and styles
*
* Loads the necessary assets for admin pages.
*
* @param string $hook Current admin page hook
* @since 1.0.0
*/
public function admin_enqueue_scripts( $hook ) {

if ( false !== strpos( $hook, 'page_upserv' ) ) {
@ -268,6 +447,13 @@ class UPServ {
}
}

/**
* Register main admin menu
*
* Adds the main UpdatePulse menu item to the admin menu.
*
* @since 1.0.0
*/
public function admin_menu() {
$page_title = __( 'UpdatePulse', 'updatepulse-server' );
$menu_title = $page_title;
@ -276,6 +462,13 @@ class UPServ {
add_menu_page( $page_title, $menu_title, 'manage_options', 'upserv-page', '', $icon );
}

/**
* Register help page in admin menu
*
* Adds the help submenu to the UpdatePulse menu.
*
* @since 1.0.0
*/
public function admin_menu_help() {
$function = array( $this, 'help_page' );
$page_title = __( 'UpdatePulse Server - Help', 'updatepulse-server' );
@ -285,6 +478,15 @@ class UPServ {
add_submenu_page( 'upserv-page', $page_title, $menu_title, 'manage_options', $menu_slug, $function );
}

/**
* Add tab links for admin interface
*
* Registers navigation tabs for the admin interface.
*
* @param array $links Existing tab links
* @return array Modified tab links
* @since 1.0.0
*/
public function upserv_admin_tab_links( $links ) {
$links['help'] = array(
admin_url( 'admin.php?page=upserv-page-help' ),
@ -294,12 +496,31 @@ class UPServ {
return $links;
}

/**
* Add tab states for admin interface
*
* Sets active states for navigation tabs.
*
* @param array $states Existing tab states
* @param string $page Current page
* @return array Modified tab states
* @since 1.0.0
*/
public function upserv_admin_tab_states( $states, $page ) {
$states['help'] = 'upserv-page-help' === $page;

return $states;
}

/**
* Add plugin action links
*
* Adds custom links to the plugin's entry in the plugins list.
*
* @param array $links Existing plugin action links
* @return array Modified plugin action links
* @since 1.0.0
*/
public function add_action_links( $links ) {
$link = array(
'<a href="' . admin_url( 'admin.php?page=upserv-page-help' ) . '">' . __( 'Help', 'updatepulse-server' ) . '</a>',
@ -308,10 +529,28 @@ class UPServ {
return array_merge( $links, $link );
}

/**
* Set action scheduler retention period
*
* Controls how long scheduled actions are kept in the database.
*
* @return int Retention period in seconds
* @since 1.0.0
*/
public function action_scheduler_retention_period() {
return DAY_IN_SECONDS;
}

/**
* Modify admin template arguments
*
* Adds or modifies arguments passed to admin templates.
*
* @param array $args Existing template arguments
* @param string $template_name Name of the template
* @return array Modified template arguments
* @since 1.0.0
*/
public function upserv_get_admin_template_args( $args, $template_name ) {

if ( preg_match( '/^plugin-.*-page\.php$/', $template_name ) ) {
@ -323,6 +562,14 @@ class UPServ {

// Misc. -------------------------------------------------------

/**
* Get class instance
*
* Retrieves or creates the singleton instance of this class.
*
* @return UPServ The class instance
* @since 1.0.0
*/
public static function get_instance() {

if ( ! isset( self::$instance ) ) {
@ -332,20 +579,47 @@ class UPServ {
return self::$instance;
}

/**
* Locate template file
*
* Finds a template file in the theme or plugin directories.
*
* @param string $template_name Template name
* @param bool $load Whether to load the template
* @param bool $required_once Whether to use require_once or require
* @return string Template path
* @since 1.0.0
*/
public static function locate_template( $template_name, $load = false, $required_once = true ) {
$name = str_replace( 'templates/', '', $template_name );
$paths = array(
$name = str_replace( 'templates/', '', $template_name );
$paths = array(
'plugins/updatepulse-server/templates/' . $name,
'plugins/updatepulse-server/' . $name,
'updatepulse-server/templates/' . $name,
'updatepulse-server/' . $name,
);
/**
* Filter the paths where templates can be located.
*
* @param array $paths Array of template paths
* @return array The filtered paths
* @since 1.0.0
*/
$template = locate_template( apply_filters( 'upserv_locate_template_paths', $paths ) );

if ( empty( $template ) ) {
$template = UPSERV_PLUGIN_PATH . 'inc/templates/' . $template_name;
}

/**
* Filter the located template.
*
* @param string $template The path to the template
* @param string $template_name The template name
* @param string $template_path The template path
* @return string The filtered template path
* @since 1.0.0
*/
$template = apply_filters(
'upserv_locate_template',
$template,
@ -360,7 +634,27 @@ class UPServ {
return $template;
}

/**
* Locate admin template file
*
* Finds an admin template file in the plugin directory.
*
* @param string $template_name Template name
* @param bool $load Whether to load the template
* @param bool $required_once Whether to use require_once or require
* @return string Template path
* @since 1.0.0
*/
public static function locate_admin_template( $template_name, $load = false, $required_once = true ) {
/**
* Filter the admin template location.
*
* @param string $template The path to the template
* @param string $template_name The template name
* @param string $template_path The template path
* @return string The filtered template path
* @since 1.0.0
*/
$template = apply_filters(
'upserv_locate_admin_template',
UPSERV_PLUGIN_PATH . 'inc/templates/admin/' . $template_name,
@ -375,6 +669,13 @@ class UPServ {
return $template;
}

/**
* Display MU plugin setup failure notice
*
* Shows admin notice when MU plugin couldn't be installed.
*
* @since 1.0.0
*/
public function setup_mu_plugin_failure_notice() {
$class = 'notice notice-error';
$message = sprintf(
@ -387,6 +688,13 @@ class UPServ {
printf( '<div class="%1$s"><p>%2$s</p></div>', $class, $message ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}

/**
* Display MU plugin setup success notice
*
* Shows admin notice when MU plugin was successfully installed.
*
* @since 1.0.0
*/
public function setup_mu_plugin_success_notice() {
$class = 'notice notice-info is-dismissible';
$message = sprintf(
@ -398,6 +706,14 @@ class UPServ {
printf( '<div class="%1$s"><p>%2$s</p></div>', $class, $message ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}

/**
* Display settings header
*
* Renders the header for settings pages with notices.
*
* @param string|array $notice Optional notice to display
* @since 1.0.0
*/
public function display_settings_header( $notice ) {
echo '<h1>' . esc_html__( 'UpdatePulse Server', 'updatepulse-server' ) . '</h1>';

@ -429,6 +745,13 @@ class UPServ {
$this->display_tabs();
}

/**
* Render help page
*
* Displays the plugin's help documentation.
*
* @since 1.0.0
*/
public function help_page() {

if ( ! current_user_can( 'manage_options' ) ) {
@ -453,6 +776,13 @@ class UPServ {
* Protected methods
*******************************************************************/

/**
* Display navigation tabs
*
* Renders the tab navigation for admin pages.
*
* @since 1.0.0
*/
protected function display_tabs() {
$states = $this->get_tab_states();
$state = array_filter( $states );
@ -463,6 +793,13 @@ class UPServ {

$state = array_keys( $state );
$state = reset( $state );
/**
* Filter the admin tab links.
*
* @param array $links The existing tab links
* @return array The modified tab links
* @since 1.0.0
*/
$links = apply_filters( 'upserv_admin_tab_links', array() );

upserv_get_admin_template(
@ -475,20 +812,51 @@ class UPServ {
);
}

/**
* Get tab states
*
* Determines which tab is currently active.
*
* @return array Tab states
* @since 1.0.0
*/
protected function get_tab_states() {
$page = sanitize_text_field( wp_unslash( filter_input( INPUT_GET, 'page' ) ) );
$states = array();

if ( 0 === strpos( $page, 'upserv-page' ) ) {
/**
* Filter the admin tab states.
*
* @param array $states The existing tab states
* @param string $page The current page
* @return array The modified tab states
* @since 1.0.0
*/
$states = apply_filters( 'upserv_admin_tab_states', $states, $page );
}

return $states;
}

/**
* Enqueue styles
*
* Loads stylesheets for the admin interface.
*
* @param array $styles Styles to enqueue
* @return array Enqueued styles
* @since 1.0.0
*/
protected function enqueue_styles( $styles ) {
$filter = 'upserv_admin_styles';
$styles = apply_filters( $filter, $styles );
/**
* Filter the admin styles to be enqueued.
*
* @param array $styles Array of styles to be enqueued
* @return array Modified array of styles
* @since 1.0.0
*/
$styles = apply_filters( 'upserv_admin_styles', $styles );

if ( ! empty( $styles ) ) {

@ -516,9 +884,24 @@ class UPServ {
return $styles;
}

/**
* Enqueue scripts
*
* Loads JavaScript files for the admin interface.
*
* @param array $scripts Scripts to enqueue
* @return array Enqueued scripts
* @since 1.0.0
*/
protected function enqueue_scripts( $scripts ) {
$filter = 'upserv_admin_scripts';
$scripts = apply_filters( $filter, $scripts );
/**
* Filter the admin scripts to be enqueued.
*
* @param array $scripts Array of scripts to be enqueued
* @return array Modified array of scripts
* @since 1.0.0
*/
$scripts = apply_filters( 'upserv_admin_scripts', $scripts );

if ( ! empty( $scripts ) ) {


View file

@ -10,15 +10,26 @@ if ( ! defined( 'ABSPATH' ) ) {
* Class Utils
*
* @package Anyape\Utils
* @since 1.0.0
*/
class Utils {

// JSON options
/**
* JSON encoding options
*
* @var int
* @since 1.0.0
*/
const JSON_OPTIONS = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE;

/**
* @param string $message
* @param string $prefix
* Log a message to PHP error log
*
* Adds class/method context information to the log message.
*
* @param string $message Message to log
* @param string $prefix Optional prefix for the log message
* @since 1.0.0
*/
public static function php_log( $message = '', $prefix = '' ) {
$prefix = $prefix ? ' ' . $prefix . ' => ' : ' => ';
@ -33,10 +44,14 @@ class Utils {
}

/**
* @param string $ip
* @param string $range
* Check if IP address is within CIDR range
*
* @return bool
* Validates whether a given IP address falls within the specified CIDR range.
*
* @param string $ip IP address to check
* @param string $range CIDR range notation (e.g., 192.168.1.0/24)
* @return bool True if IP is in range, false otherwise
* @since 1.0.0
*/
public static function cidr_match( $ip, $range ) {
list ( $subnet, $bits ) = explode( '/', $range );
@ -54,12 +69,16 @@ class Utils {
}

/**
* @param array $_array
* @param string $path
* @param null $value
* @param bool $update
* Access or update nested array using path notation
*
* @return mixed|null
* Gets or sets a value in a nested array using a path string with / as separator.
*
* @param array $_array Reference to the array to access
* @param string $path Path notation to the nested element (e.g., 'parent/child/item')
* @param mixed $value Optional value to set if updating
* @param bool $update Whether to update the array (true) or just read (false)
* @return mixed|null Retrieved value or null if path doesn't exist
* @since 1.0.0
*/
public static function access_nested_array( &$_array, $path, $value = null, $update = false ) {
$keys = explode( '/', $path );
@ -87,10 +106,13 @@ class Utils {
}

/**
* @param string $path
* @param string $regex
* Check if URL subpath matches a regex pattern
*
* @return int|null
* Tests if the first segment of the current request URI matches the provided regex.
*
* @param string $regex Regular expression to match against the first path segment
* @return int|null 1 if match found, 0 if no match, null if host couldn't be determined
* @since 1.0.0
*/
public static function is_url_subpath_match( $regex ) {
$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : false;
@ -111,10 +133,12 @@ class Utils {
}

/**
* @param string $path
* @param string $regex
* Get time elapsed since request start
*
* @return int|null
* Calculates the time elapsed since the request started in seconds.
*
* @return string|null Time elapsed in seconds with 3 decimal precision, or null if request time not available
* @since 1.0.0
*/
public static function get_time_elapsed() {

@ -132,10 +156,12 @@ class Utils {
}

/**
* @param string $path
* @param string $regex
* Get remote IP address
*
* @return int|null
* Safely retrieves the remote IP address of the client.
*
* @return string IP address of the client or '0.0.0.0' if not available or invalid
* @since 1.0.0
*/
public static function get_remote_ip() {

@ -152,6 +178,15 @@ class Utils {
return $ip;
}

/**
* Get human-readable status string
*
* Converts a status code to a localized human-readable string.
*
* @param string $status Status code to convert
* @return string Localized human-readable status string
* @since 1.0.0
*/
public static function get_status_string( $status ) {
switch ( $status ) {
case 'pending':

View file

@ -11,11 +11,34 @@ use WP_CLI;
use WP_Error;
use Anyape\UpdatePulse\Server\Nonce\Nonce;

/**
* CLI commands for UpdatePulse Server.
*
* @since 1.0.0
*/
class CLI extends WP_CLI_Command {

/**
* Error code for when a resource is not found.
*
* @var int
* @since 1.0.0
*/
protected const RESOURCE_NOT_FOUND = 3;
protected const DEFAULT_ERROR = 1;
protected const LOG_METHODS = array(
/**
* Default error code for general errors.
*
* @var int
* @since 1.0.0
*/
protected const DEFAULT_ERROR = 1;
/**
* Available log methods for WP_CLI.
*
* @var array
* @since 1.0.0
*/
protected const LOG_METHODS = array(
'line',
'log',
'success',
@ -25,7 +48,13 @@ class CLI extends WP_CLI_Command {
'halt',
'error_multi_line',
);
protected const PACKAGE_TYPES = array(
/**
* Available package types supported by the plugin.
*
* @var array
* @since 1.0.0
*/
protected const PACKAGE_TYPES = array(
'plugin',
'theme',
'generic',
@ -41,6 +70,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse cleanup_cache
*
* @since 1.0.0
*/
public function cleanup_cache() {
$this->cleanup( 'cache' );
@ -52,6 +83,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse cleanup_logs
*
* @since 1.0.0
*/
public function cleanup_logs() {
$this->cleanup( 'logs' );
@ -63,6 +96,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse cleanup_tmp
*
* @since 1.0.0
*/
public function cleanup_tmp() {
$this->cleanup( 'tmp' );
@ -74,6 +109,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse cleanup-all
*
* @since 1.0.0
*/
public function cleanup_all() {
$this->cleanup( 'cache' );
@ -95,6 +132,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse check_remote_package_update my-package plugin
*
* @since 1.0.0
*/
public function check_remote_package_update( $args, $assoc_args ) {
$slug = $args[0];
@ -134,6 +173,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse download_remote_package my-package plugin --vcs_url='https://vcs-url.tld/identifier/' --branch='main'
*
* @since 1.0.0
*/

public function download_remote_package( $args, $assoc_args ) {
@ -175,6 +216,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse delete_package my-package
*
* @since 1.0.0
*/
public function delete_package( $args, $assoc_args ) {
$slug = $args[0];
@ -196,6 +239,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse get_package_info my-package
*
* @since 1.0.0
*/
public function get_package_info( $args, $assoc_args ) {
$slug = $args[0];
@ -228,6 +273,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse create_nonce --true_nonce=true --expiry_length=30 --data='{}' --return=nonce_only --store=true
*
* @since 1.0.0
*/
public function create_nonce( $args, $assoc_args ) {
$assoc_args = wp_parse_args(
@ -298,6 +345,7 @@ class CLI extends WP_CLI_Command {
*
* wp updatepulse build_nonce_api_signature --api_key_id='UPDATEPULSE_L_api_key_name' --timestamp=1704067200 --api_key=da9d20647163a1f3c04844387f91e2c3 --payload='{"key": "value"}'
*
* @since 1.0.0
*/
public function build_nonce_api_signature( $args, $assoc_args ) {
$assoc_args = wp_parse_args(
@ -333,6 +381,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse clear_nonces
*
* @since 1.0.0
*/
public function clear_nonces() {
$result = upserv_clear_nonces();
@ -353,6 +403,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse get_nonce_expiry <nonce>
*
* @since 1.0.0
*/
public function get_nonce_expiry( $args, $assoc_args ) {
$nonce = $args[0];
@ -374,6 +426,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse get_nonce_data <nonce>
*
* @since 1.0.0
*/
public function get_nonce_data( $args, $assoc_args ) {
$nonce = $args[0];
@ -395,6 +449,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse delete_nonce <nonce>
*
* @since 1.0.0
*/
public function delete_nonce( $args, $assoc_args ) {
$nonce = $args[0];
@ -416,6 +472,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse browse_licenses <license_query>
*
* @since 1.0.0
*/
public function browse_licenses( $args, $assoc_args ) {
$result = upserv_browse_licenses( $args[0] );
@ -436,6 +494,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse read_license <license_key_or_id>
*
* @since 1.0.0
*/
public function read_license( $args, $assoc_args ) {
$license_data = array();
@ -464,6 +524,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse add_license <license_data>
*
* @since 1.0.0
*/
public function add_license( $args, $assoc_args ) {
$payload = json_decode( $args[0], true );
@ -488,6 +550,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse edit_license <license_data>
*
* @since 1.0.0
*/
public function edit_license( $args, $assoc_args ) {
$payload = json_decode( $args[0], true );
@ -512,6 +576,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse delete_license <license_key_or_id>
*
* @since 1.0.0
*/
public function delete_license( $args, $assoc_args ) {
$license_data = array();
@ -539,6 +605,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse check_license <license_key_or_id>
*
* @since 1.0.0
*/
public function check_license( $args, $assoc_args ) {
$license_data = array();
@ -576,6 +644,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse activate_license <license_key> <package-slug> <domain>
*
* @since 1.0.0
*/
public function activate_license( $args, $assoc_args ) {
$license_data = array(
@ -610,6 +680,8 @@ class CLI extends WP_CLI_Command {
* ## EXAMPLES
*
* wp updatepulse deactivate_license <license_key> <package-slug> <domain>
*
* @since 1.0.0
*/
public function deactivate_license( $args, $assoc_args ) {
$license_data = array(
@ -631,6 +703,13 @@ class CLI extends WP_CLI_Command {
* Protected methods
*******************************************************************/

/**
* Cleans up a specific folder in the plugin directory.
*
* @param string $method The folder to clean up ('cache', 'logs', or 'tmp').
* @return void
* @since 1.0.0
*/
protected function cleanup( $method ) {
$method = 'upserv_force_cleanup_' . $method;
$result = $method();
@ -640,6 +719,17 @@ class CLI extends WP_CLI_Command {
$this->process_result( $result, $success_message, $error_message );
}

/**
* Processes the result of a command and outputs a message based on success or failure.
*
* @param mixed $result The result to evaluate.
* @param mixed $success_message Message to display on success.
* @param string $error_message Message to display on error.
* @param int $error_code Error code to return on failure. Default: 1.
* @param string $error_level WP_CLI error level to use. Default: 'warning'.
* @return void
* @since 1.0.0
*/
protected function process_result( $result, $success_message, $error_message, $error_code = 1, $error_level = 'warning' ) {

if ( $result instanceof WP_Error ) {
@ -680,6 +770,17 @@ class CLI extends WP_CLI_Command {
}
}

/**
* Outputs a message to the CLI.
*
* Handles both string and array message formats. If an array is provided,
* it should contain 'level' and 'output' keys. The level determines which
* WP_CLI output method to use.
*
* @param string|array $message The message to output.
* @return void
* @since 1.0.0
*/
protected function output( $message ) {

if ( is_string( $message ) ) {

View file

@ -8,8 +8,19 @@ if ( ! defined( 'ABSPATH' ) ) {

use stdClass;

/**
* API Manager class
*
* @since 1.0.0
*/
class API_Manager {

/**
* Constructor
*
* @param boolean $init_hooks Whether to initialize hooks
* @since 1.0.0
*/
public function __construct( $init_hooks = false ) {

if ( $init_hooks ) {
@ -28,6 +39,15 @@ class API_Manager {

// WordPress hooks ---------------------------------------------

/**
* Register admin styles
*
* Add custom styles used by the API admin interface.
*
* @param array $styles Existing admin styles.
* @return array Modified admin styles.
* @since 1.0.0
*/
public function upserv_admin_styles( $styles ) {
$styles['api'] = array(
'path' => UPSERV_PLUGIN_PATH . 'css/admin/api' . upserv_assets_suffix() . '.css',
@ -37,6 +57,15 @@ class API_Manager {
return $styles;
}

/**
* Register admin scripts
*
* Add custom scripts used by the API admin interface.
*
* @param array $scripts Existing admin scripts.
* @return array Modified admin scripts.
* @since 1.0.0
*/
public function upserv_admin_scripts( $scripts ) {
$page = ! empty( $_REQUEST['page'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['page'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

@ -93,6 +122,13 @@ class API_Manager {
return $scripts;
}

/**
* Register admin menu
*
* Add the API settings page to the admin menu.
*
* @since 1.0.0
*/
public function admin_menu() {
$function = array( $this, 'plugin_page' );
$page_title = __( 'UpdatePulse Server - API & Webhooks', 'updatepulse-server' );
@ -102,6 +138,15 @@ class API_Manager {
add_submenu_page( 'upserv-page', $page_title, $menu_title, 'manage_options', $menu_slug, $function );
}

/**
* Register admin tab links
*
* Add API tab to the admin navigation.
*
* @param array $links Existing tab links.
* @return array Modified tab links.
* @since 1.0.0
*/
public function upserv_admin_tab_links( $links ) {
$links['api'] = array(
admin_url( 'admin.php?page=upserv-page-api' ),
@ -111,6 +156,16 @@ class API_Manager {
return $links;
}

/**
* Register admin tab states
*
* Set active state for API tab in admin navigation.
*
* @param array $states Existing tab states.
* @param string $page Current admin page.
* @return array Modified tab states.
* @since 1.0.0
*/
public function upserv_admin_tab_states( $states, $page ) {
$states['api'] = 'upserv-page-api' === $page;

@ -119,6 +174,13 @@ class API_Manager {

// Misc. -------------------------------------------------------

/**
* Render plugin page
*
* Output the API settings admin interface.
*
* @since 1.0.0
*/
public function plugin_page() {

if ( ! current_user_can( 'manage_options' ) ) {
@ -160,14 +222,35 @@ class API_Manager {
'plugin-api-page.php',
array(
'options' => $options,
/**
* Filter the list of available License API actions.
*
* @param array $actions The list of available License API actions
* @return array The filtered list of actions
* @since 1.0.0
*/
'license_api_actions' => apply_filters(
'upserv_api_license_actions',
array()
),
/**
* Filter the list of available Package API actions.
*
* @param array $actions The list of available Package API actions
* @return array The filtered list of actions
* @since 1.0.0
*/
'package_api_actions' => apply_filters(
'upserv_api_package_actions',
array()
),
/**
* Filter the list of available webhook events.
*
* @param array $webhook_events The list of available webhook events
* @return array The filtered list of webhook events
* @since 1.0.0
*/
'webhook_events' => apply_filters(
'upserv_api_webhook_events',
array(
@ -189,6 +272,14 @@ class API_Manager {
* Protected methods
*******************************************************************/

/**
* Handle plugin options
*
* Process and save API settings form submissions.
*
* @return string|array Success message or array of errors.
* @since 1.0.0
*/
protected function plugin_options_handler() {
$errors = array();
$result = '';
@ -355,6 +446,16 @@ class API_Manager {
}
}

/**
* Filter whether an API option should be updated.
*
* @param bool $condition Whether the condition for updating the option is met
* @param string $option_name The name of the option
* @param array $option_info Information about the option
* @param array $options All submitted options
* @return bool Whether the option should be updated
* @since 1.0.0
*/
$condition = apply_filters(
'upserv_api_option_update',
$condition,
@ -364,6 +465,16 @@ class API_Manager {
);

if ( $condition ) {
/**
* Filter the value of an API option before it is saved.
*
* @param mixed $value The value to save
* @param string $option_name The name of the option
* @param array $option_info Information about the option
* @param array $options All submitted options
* @return mixed The filtered value to save
* @since 1.0.0
*/
$to_save[ $option_info['path'] ] = apply_filters(
'upserv_api_option_save_value',
$option_info['value'],
@ -395,12 +506,33 @@ class API_Manager {
$result = $errors;
}

/**
* Fired after API options have been updated.
*
* @param array $errors Array of errors that occurred during the update process
* @since 1.0.0
*/
do_action( 'upserv_api_options_updated', $errors );

return $result;
}

/**
* Get submitted options
*
* Retrieve and sanitize form data from API settings form.
*
* @return array Sanitized form data.
* @since 1.0.0
*/
protected function get_submitted_options() {
/**
* Filter the submitted API configuration options.
*
* @param array $config The submitted API configuration options
* @return array The filtered configuration options
* @since 1.0.0
*/
return apply_filters(
'upserv_submitted_api_config',
array(

View file

@ -18,18 +18,70 @@ use Anyape\UpdatePulse\Server\Server\Update\Package;
use Anyape\UpdatePulse\Package_Parser\Parser;
use Anyape\Utils\Utils;

/**
* Cloud Storage Manager class
*
* Handles integration with S3-compatible cloud storage for package management.
*
* @since 1.0.0
*/
class Cloud_Storage_Manager {

/**
* Instance of the Cloud Storage Manager
*
* @var Cloud_Storage_Manager|null
* @since 1.0.0
*/
protected static $instance;
/**
* Cloud storage configuration
*
* @var array|null
* @since 1.0.0
*/
protected static $config;
/**
* Cloud storage client instance
*
* @var PhpS3|null
* @since 1.0.0
*/
protected static $cloud_storage;
/**
* Virtual directory path in cloud storage
*
* @var string|null
* @since 1.0.0
*/
protected static $virtual_dir;
/**
* Hooks registered by the manager
*
* @var array
* @since 1.0.0
*/
protected static $hooks = array();

/**
* Whether we're currently performing a redirect
*
* @var bool
* @since 1.0.0
*/
protected $doing_redirect = false;

/**
* Download URL lifetime in seconds
*/
public const DOWNLOAD_URL_LIFETIME = MINUTE_IN_SECONDS;

/**
* Constructor
*
* @param boolean $init_hooks Whether to initialize hooks
* @since 1.0.0
*/
public function __construct( $init_hooks = false ) {
$config = self::get_config();

@ -57,6 +109,12 @@ class Cloud_Storage_Manager {
}
}

/**
* Initialize cloud storage manager
*
* @param array $config Cloud storage configuration
* @since 1.0.0
*/
protected function init_manager( $config ) {

if ( ! self::$cloud_storage instanceof PhpS3 ) {
@ -70,10 +128,22 @@ class Cloud_Storage_Manager {

self::$cloud_storage->setExceptions();

/**
* Filter the virtual directory path used in cloud storage.
*
* @param string $virtual_dir The default virtual directory name
* @return string The filtered virtual directory name
* @since 1.0.0
*/
self::$virtual_dir = apply_filters( 'upserv_cloud_storage_virtual_dir', 'updatepulse-packages' );
}
}

/**
* Add hooks for cloud storage functionality
*
* @since 1.0.0
*/
protected function add_hooks() {

if ( ! empty( self::$hooks ) ) {
@ -136,6 +206,11 @@ class Cloud_Storage_Manager {
}
}

/**
* Remove hooks for cloud storage functionality
*
* @since 1.0.0
*/
protected function remove_hooks() {

if ( empty( self::$hooks ) ) {
@ -159,6 +234,13 @@ class Cloud_Storage_Manager {
self::$hooks = array();
}

/**
* Get cloud storage configuration
*
* @param boolean $force Whether to force reload the configuration
* @return array Cloud storage configuration
* @since 1.0.0
*/
public static function get_config( $force = false ) {

if ( $force || ! self::$config ) {
@ -168,9 +250,22 @@ class Cloud_Storage_Manager {
self::$config = $config;
}

/**
* Filter the configuration of the Cloud Storage Manager.
*
* @param array $config The configuration of the Cloud Storage Manager
* @return array The filtered configuration
* @since 1.0.0
*/
return apply_filters( 'upserv_cloud_storage_config', self::$config );
}

/**
* Get Cloud Storage Manager instance
*
* @return Cloud_Storage_Manager The singleton instance
* @since 1.0.0
*/
public static function get_instance() {

if ( ! self::$instance ) {
@ -180,6 +275,13 @@ class Cloud_Storage_Manager {
return self::$instance;
}

/**
* Add cloud storage-specific scripts to admin
*
* @param array $scripts Existing registered scripts
* @return array Modified scripts array
* @since 1.0.0
*/
public function upserv_admin_scripts( $scripts ) {
$page = ! empty( $_REQUEST['page'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['page'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

@ -196,6 +298,13 @@ class Cloud_Storage_Manager {
return $scripts;
}

/**
* Process submitted package configurations
*
* @param array $config Existing configuration
* @return array Modified configuration
* @since 1.0.0
*/
public function upserv_submitted_package_config( $config ) {
$config = array_merge(
$config,
@ -268,6 +377,16 @@ class Cloud_Storage_Manager {
return $config;
}

/**
* Validate package option updates
*
* @param boolean $condition Current validation condition
* @param string $option_name Option being updated
* @param array $option_info Option information
* @param array $options All options being processed
* @return boolean Whether option is valid
* @since 1.0.0
*/
public function upserv_package_option_update( $condition, $option_name, $option_info, $options ) {

if ( 'use-cloud-storage' === $option_info['condition'] ) {
@ -300,6 +419,11 @@ class Cloud_Storage_Manager {
return $condition;
}

/**
* Render cloud storage options in template
*
* @since 1.0.0
*/
public function upserv_template_package_manager_option_before_miscellaneous() {
$options = array(
'access_key' => upserv_get_option( 'cloud_storage/access_key' ),
@ -319,6 +443,13 @@ class Cloud_Storage_Manager {
);
}

/**
* Add cloud storage paths to bulk delete
*
* @param array $package_paths Current package paths
* @return array Modified package paths
* @since 1.0.0
*/
public function upserv_delete_packages_bulk_paths( $package_paths ) {
$config = self::get_config();

@ -347,6 +478,15 @@ class Cloud_Storage_Manager {
return $package_paths;
}

/**
* Check if package exists in cloud storage
*
* @param boolean $package_exists Current existence state
* @param array $payload Request payload
* @param string $slug Package slug
* @return boolean|null Whether package exists in cloud storage
* @since 1.0.0
*/
public function upserv_webhook_package_exists( $package_exists, $payload, $slug ) {

if ( null !== $package_exists ) {
@ -383,6 +523,15 @@ class Cloud_Storage_Manager {
}
}

/**
* Process package removal from cloud storage
*
* @param boolean $result Current removal result
* @param string $type Package type
* @param string $slug Package slug
* @return boolean Whether removal was successful
* @since 1.0.0
*/
public function upserv_remove_package_result( $result, $type, $slug ) {
$config = self::get_config();

@ -427,6 +576,14 @@ class Cloud_Storage_Manager {
return $result;
}

/**
* Modify admin template arguments
*
* @param array $args Current template arguments
* @param string $template_name Template being rendered
* @return array Modified template arguments
* @since 1.0.0
*/
public function upserv_get_admin_template_args( $args, $template_name ) {
$template_names = array( 'plugin-packages-page.php', 'plugin-help-page.php', 'plugin-remote-sources-page.php' );

@ -437,6 +594,13 @@ class Cloud_Storage_Manager {
return $args;
}

/**
* Test cloud storage connectivity
*
* AJAX handler for testing cloud storage configuration
*
* @since 1.0.0
*/
public function cloud_storage_test() {
$result = array();
$nonce = sanitize_text_field( wp_unslash( filter_input( INPUT_POST, 'nonce' ) ) );
@ -527,6 +691,13 @@ class Cloud_Storage_Manager {
}
}

/**
* Handle package options update
*
* Set up cloud storage after options are updated
*
* @since 1.0.0
*/
public function upserv_package_options_updated() {
$config = self::get_config( true );

@ -566,6 +737,15 @@ class Cloud_Storage_Manager {
}
}

/**
* Update local package metadata from cloud storage
*
* @param array|false $local_meta Current local metadata
* @param object $local_package Local package instance
* @param string $slug Package slug
* @return array|false Updated metadata or false
* @since 1.0.0
*/
public function upserv_check_remote_package_update_local_meta( $local_meta, $local_package, $slug ) {

if ( ! $local_meta ) {
@ -606,6 +786,14 @@ class Cloud_Storage_Manager {
return $local_meta;
}

/**
* Handle saving remote package to cloud storage
*
* @param boolean $local_ready Whether local package is ready
* @param string $type Package type
* @param string $slug Package slug
* @since 1.0.0
*/
public function upserv_saved_remote_package_to_local( $local_ready, $type, $slug ) {
$config = self::get_config();
$package_directory = Data_Manager::get_data_dir( 'packages' );
@ -651,6 +839,14 @@ class Cloud_Storage_Manager {
}
}

/**
* Handle manual package upload to cloud storage
*
* @param boolean $result Upload result
* @param string $type Package type
* @param string $slug Package slug
* @since 1.0.0
*/
public function upserv_did_manual_upload_package( $result, $type, $slug ) {

if ( ! $result ) {
@ -684,6 +880,16 @@ class Cloud_Storage_Manager {
}
}

/**
* Determine whether to save remote package locally
*
* @param boolean $save Current save decision
* @param string $slug Package slug
* @param string $filename Target filename
* @param boolean $check_remote Whether to check remote storage
* @return boolean Whether to save package locally
* @since 1.0.0
*/
public function upserv_save_remote_to_local( $save, $slug, $filename, $check_remote ) {
$config = self::get_config();

@ -718,6 +924,14 @@ class Cloud_Storage_Manager {
return $save;
}

/**
* Handle pre-download actions for packages
*
* @param string $archive_name Archive name
* @param string $archive_path Archive path
* @param array $package_slugs Package slugs to download
* @since 1.0.0
*/
public function upserv_before_packages_download( $archive_name, $archive_path, $package_slugs ) {

if ( 1 === count( $package_slugs ) ) {
@ -753,6 +967,14 @@ class Cloud_Storage_Manager {
}
}

/**
* Handle pre-download repack actions for packages
*
* @param string $archive_name Archive name
* @param string $archive_path Archive path
* @param array $package_slugs Package slugs to download
* @since 1.0.0
*/
public function upserv_before_packages_download_repack( $archive_name, $archive_path, $package_slugs ) {

if ( ! empty( $package_slugs ) ) {
@ -783,6 +1005,13 @@ class Cloud_Storage_Manager {
}
}

/**
* Handle post-download actions for packages
*
* @param string $archive_name Archive name
* @param string $archive_path Archive path
* @since 1.0.0
*/
public function upserv_after_packages_download( $archive_name, $archive_path ) {

if ( is_file( $archive_path ) ) {
@ -790,6 +1019,13 @@ class Cloud_Storage_Manager {
}
}

/**
* Handle package API requests
*
* @param string $method API method being called
* @param array $payload Request payload
* @since 1.0.0
*/
public function upserv_package_api_request( $method, $payload ) {
$config = self::get_config();

@ -828,12 +1064,26 @@ class Cloud_Storage_Manager {
);
$this->doing_redirect = wp_redirect( $url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect

/**
* Fired after a package is downloaded.
*
* @param string $package_slug the slug of the downloaded package
* @since 1.0.0
*/
do_action( 'upserv_did_download_package', $package_id );

exit;
}
}

/**
* Fetch package from cloud storage when not in cache
*
* @param string $slug Package slug
* @param string $filename Target filename
* @param object $cache Cache instance
* @since 1.0.0
*/
public function upserv_find_package_no_cache( $slug, $filename, $cache ) {

if ( is_file( $filename ) ) {
@ -887,12 +1137,29 @@ class Cloud_Storage_Manager {
}
}

/**
* Generate cache key for cloud storage metadata
*
* @param string $cache_key Current cache key
* @param string $slug Package slug
* @param string $filename Package filename
* @return string Modified cache key
* @since 1.0.0
*/
public function upserv_zip_metadata_parser_cache_key( $cache_key, $slug, $filename ) {
$cloud_cache_key = self::build_cache_key( $slug, $filename );

return $cloud_cache_key ? $cloud_cache_key : $cache_key;
}

/**
* Get package information from cloud storage
*
* @param array|false $package_info Current package information
* @param string $slug Package slug
* @return array|false Updated package information
* @since 1.0.0
*/
public function upserv_package_manager_get_package_info( $package_info, $slug ) {
$cache = new Cache( Data_Manager::get_data_dir( 'cache' ) );
$config = self::get_config();
@ -1018,6 +1285,14 @@ class Cloud_Storage_Manager {
return $package_info;
}

/**
* Get batch package information from cloud storage
*
* @param array $packages Current packages information
* @param string $search Search term
* @return array Updated packages information
* @since 1.0.0
*/
public function upserv_package_manager_get_batch_package_info( $packages, $search ) {
$config = self::get_config();
$contents = wp_cache_get( 'upserv-getBucket', 'updatepulse-server' );
@ -1054,6 +1329,14 @@ class Cloud_Storage_Manager {
false === strpos( strtolower( $info['slug'] ) . '.zip', strtolower( $search ) )
)
);
/**
* Filter whether to include package information in responses.
*
* @param bool $_include Current inclusion status
* @param array $info Package information
* @return bool Whether to include the package information
* @since 1.0.0
*/
$include = apply_filters( 'upserv_package_info_include', $include, $info );

if ( $include ) {
@ -1076,6 +1359,12 @@ class Cloud_Storage_Manager {
return $packages;
}

/**
* Handle package download action
*
* @param object $request Download request
* @since 1.0.0
*/
public function upserv_update_server_action_download( $request ) {
$config = self::get_config();
$url = self::$cloud_storage->getAuthenticatedUrlV4(
@ -1087,10 +1376,24 @@ class Cloud_Storage_Manager {
$this->doing_redirect = wp_redirect( $url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
}

/**
* Check if download request is already handled
*
* @return boolean Whether download is handled
* @since 1.0.0
*/
public function upserv_update_server_action_download_handled() {
return $this->doing_redirect;
}

/**
* Check if package is whitelisted in cloud storage
*
* @param boolean $whitelisted Current whitelist status
* @param string $package_slug Package slug
* @return boolean Updated whitelist status
* @since 1.0.0
*/
public function upserv_is_package_whitelisted( $whitelisted, $package_slug ) {
$data = upserv_get_package_metadata( $package_slug, false );

@ -1107,6 +1410,14 @@ class Cloud_Storage_Manager {
return $whitelisted;
}

/**
* Update package data when whitelisted
*
* @param array $data Package data
* @param string $slug Package slug
* @return array Updated package data
* @since 1.0.0
*/
public function upserv_whitelist_package_data( $data, $slug ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$data['whitelisted']['cloud'] = array(
true,
@ -1116,6 +1427,14 @@ class Cloud_Storage_Manager {
return $data;
}

/**
* Update package data when unwhitelisted
*
* @param array $data Package data
* @param string $slug Package slug
* @return array Updated package data
* @since 1.0.0
*/
public function upserv_unwhitelist_package_data( $data, $slug ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$data['whitelisted']['cloud'] = array(
false,
@ -1125,6 +1444,14 @@ class Cloud_Storage_Manager {
return $data;
}

/**
* Build cache key for cloud storage items
*
* @param string $slug Package slug
* @param string $filename Package filename
* @return string|false Cache key or false
* @since 1.0.0
*/
protected static function build_cache_key( $slug, $filename ) {
$config = self::get_config();
$info = wp_cache_get( $slug . '-getObjectInfo', 'updatepulse-server' );
@ -1147,6 +1474,13 @@ class Cloud_Storage_Manager {
return $cache_key;
}

/**
* Check if virtual folder exists in cloud storage
*
* @param string $name Folder name
* @return boolean Whether folder exists
* @since 1.0.0
*/
protected function virtual_folder_exists( $name ) {
$config = self::get_config();

@ -1156,6 +1490,14 @@ class Cloud_Storage_Manager {
);
}

/**
* Create a virtual folder in cloud storage
*
* @param string $name Folder name
* @param string|null $storage_unit Storage unit name
* @return boolean Whether folder was created
* @since 1.0.0
*/
protected function create_virtual_folder( $name, $storage_unit = null ) {

if ( ! $storage_unit ) {

View file

@ -12,21 +12,48 @@ use Anyape\UpdatePulse\Server\Scheduler\Scheduler;

class Data_Manager {

/**
* Transient data directories
*
* List of directories that store temporary data.
*
* @var array
* @since 1.0.0
*/
public static $transient_data_dirs = array(
'cache',
'logs',
'tmp',
);

/**
* Persistent data directories
*
* List of directories that store permanent data.
*
* @var array
* @since 1.0.0
*/
public static $persistent_data_dirs = array(
'packages',
'metadata',
);

/**
* Transient data in database
*
* List of temporary data stored in the database.
*
* @var array
* @since 1.0.0
*/
public static $transient_data_db = array(
'update_from_remote_locks',
);

/**
* Constructor
*
* @param boolean $init_hooks Whether to initialize hooks.
* @since 1.0.0
*/
public function __construct( $init_hooks = false ) {

if ( $init_hooks ) {
@ -40,6 +67,13 @@ class Data_Manager {

// WordPress hooks ---------------------------------------------

/**
* Activate
*
* Actions to perform when the plugin is activated.
*
* @since 1.0.0
*/
public static function activate() {
set_transient( 'upserv_flush', 1, 60 );

@ -64,10 +98,24 @@ class Data_Manager {
}
}

/**
* Deactivate
*
* Actions to perform when the plugin is deactivated.
*
* @since 1.0.0
*/
public static function deactivate() {
self::clear_schedules();
}

/**
* Initialize scheduler
*
* Register cleanup events and schedules.
*
* @since 1.0.0
*/
public function upserv_scheduler_init() {
self::register_cleanup_events();
self::register_cleanup_schedules();
@ -75,10 +123,25 @@ class Data_Manager {

// Misc. -------------------------------------------------------

/**
* Clear schedules
*
* Remove all scheduled cleanup events.
*
* @since 1.0.0
*/
public static function clear_schedules() {
self::clear_cleanup_schedules();
}

/**
* Setup directories
*
* Create data directories if they don't exist.
*
* @return bool True if directories were created successfully, false otherwise.
* @since 1.0.0
*/
public static function maybe_setup_directories() {
$root_dir = self::get_data_dir();
$result = true;
@ -100,7 +163,17 @@ class Data_Manager {
return $result;
}

/**
* Setup MU plugin
*
* Create or update the must-use plugin file.
*
* @return bool True if the MU plugin was setup successfully, false otherwise.
* @since 1.0.0
*/
public static function maybe_setup_mu_plugin() {
WP_Filesystem();

global $wp_filesystem;

$result = true;
@ -125,6 +198,15 @@ class Data_Manager {
return $result;
}

/**
* Get data directory path
*
* Retrieve the path to a specific data directory.
*
* @param string $dir Directory name or 'root' for the base directory.
* @return string Path to the requested directory.
* @since 1.0.0
*/
public static function get_data_dir( $dir = 'root' ) {
$data_dir = wp_cache_get( 'data_dir_' . $dir, 'updatepulse-server' );

@ -156,6 +238,16 @@ class Data_Manager {
return $data_dir;
}

/**
* Check if directory is valid
*
* Determine whether a directory name is a valid data directory.
*
* @param string $dir The directory name to check.
* @param bool $require_persistent Whether the directory must be persistent.
* @return bool Whether the directory is valid.
* @since 1.0.0
*/
public static function is_valid_data_dir( $dir, $require_persistent = false ) {
$is_valid = false;

@ -168,6 +260,16 @@ class Data_Manager {
return $is_valid;
}

/**
* Maybe cleanup data
*
* Clean up transient data if needed.
*
* @param string $type The type of data to clean up.
* @param bool $force Whether to force cleanup regardless of conditions.
* @return bool Whether cleanup was performed.
* @since 1.0.0
*/
public static function maybe_cleanup( $type, $force = false ) {

if ( in_array( $type, self::$transient_data_db, true ) ) {
@ -191,6 +293,16 @@ class Data_Manager {
* Protected methods
*******************************************************************/

/**
* Maybe cleanup data directory
*
* Clean up a data directory if it exceeds its size limit or if forced.
*
* @param string $type The directory to clean up.
* @param bool $force Whether to force cleanup regardless of conditions.
* @return bool Whether cleanup was performed.
* @since 1.0.0
*/
protected static function maybe_cleanup_data_dir( $type, $force ) {
WP_Filesystem();

@ -234,6 +346,15 @@ class Data_Manager {
$result = $result && self::generate_restricted_htaccess( $directory );
}

/**
* Fired after a data directory cleanup operation.
*
* @param bool $result Whether the cleanup was successful
* @param string $type The type of data that was cleaned up
* @param int $total_size The total size of the data before cleanup
* @param bool $force Whether the cleanup was forced
* @since 1.0.0
*/
do_action( 'upserv_did_cleanup', $result, $type, $total_size, $force );

return $result;
@ -242,6 +363,14 @@ class Data_Manager {
return false;
}

/**
* Maybe cleanup update from remote locks
*
* Clean up expired remote update locks from the database.
*
* @return bool Whether cleanup was performed.
* @since 1.0.0
*/
protected static function maybe_cleanup_update_from_remote_locks() {
$locks = get_option( 'upserv_update_from_remote_locks' );

@ -258,6 +387,17 @@ class Data_Manager {
}
}

/**
* Create data directory
*
* Create a directory for storing plugin data.
*
* @param string $name The name of the directory to create.
* @param bool $include_htaccess Whether to create an .htaccess file.
* @param bool $is_root_dir Whether this is the root data directory.
* @return bool Whether the directory was created successfully.
* @since 1.0.0
*/
protected static function create_data_dir( $name, $include_htaccess = true, $is_root_dir = false ) {
$wp_upload_dir = wp_upload_dir();
$root_dir = trailingslashit( $wp_upload_dir['basedir'] . '/updatepulse-server' );
@ -271,6 +411,15 @@ class Data_Manager {
return $result;
}

/**
* Generate restricted htaccess
*
* Create an .htaccess file that prevents direct access to files.
*
* @param string $directory The directory path where to create the .htaccess file.
* @return bool Whether the .htaccess file was created successfully.
* @since 1.0.0
*/
protected static function generate_restricted_htaccess( $directory ) {
WP_Filesystem();

@ -288,6 +437,13 @@ class Data_Manager {
return $wp_filesystem->put_contents( $htaccess, $contents, 0644 );
}

/**
* Clear cleanup schedules
*
* Unschedule all cleanup events.
*
* @since 1.0.0
*/
protected static function clear_cleanup_schedules() {

if ( upserv_is_doing_update_api_request() ) {
@ -304,10 +460,26 @@ class Data_Manager {
}

Scheduler::get_instance()->unschedule_all_actions( 'upserv_cleanup', $params );

/**
* Fired after a cleanup schedule has been cleared.
*
* @param string $type The type of data for which the schedule was cleared
* @param array $params The parameters that were used for the schedule
* @since 1.0.0
*/
do_action( 'upserv_cleared_cleanup_schedule', $type, $params );
}
}

/**
* Register cleanup schedules
*
* Register action hooks for cleanup events.
*
* @return bool Whether the schedules were registered successfully.
* @since 1.0.0
*/
protected static function register_cleanup_schedules() {

if ( upserv_is_doing_update_api_request() ) {
@ -326,10 +498,25 @@ class Data_Manager {
$hook = array( __NAMESPACE__ . '\\Data_Manager', 'maybe_cleanup' );

add_action( 'upserv_cleanup', $hook, 10, 2 );

/**
* Fired after a cleanup schedule has been registered.
*
* @param string $type The type of data for which the schedule was registered
* @param array $params The parameters that are used for the schedule
* @since 1.0.0
*/
do_action( 'upserv_registered_cleanup_schedule', $type, $params );
}
}

/**
* Register cleanup events
*
* Schedule recurring cleanup events.
*
* @since 1.0.0
*/
protected static function register_cleanup_events() {
$cleanable_datatypes = array_merge( self::$transient_data_dirs, self::$transient_data_db );

@ -342,6 +529,14 @@ class Data_Manager {
}

if ( ! Scheduler::get_instance()->has_scheduled_action( $hook, $params ) ) {
/**
* Filter the cleanup schedule frequency.
*
* @param string $frequency The frequency of the cleanup schedule (default 'hourly')
* @param string $type The type of data to clean up
* @return string The filtered frequency
* @since 1.0.0
*/
$frequency = apply_filters( 'upserv_schedule_cleanup_frequency', 'hourly', $type );
$schedules = wp_get_schedules();
$timestamp = time();
@ -352,6 +547,17 @@ class Data_Manager {
$params
);

/**
* Fired after a cleanup event has been scheduled.
*
* @param bool $result Whether the scheduling was successful
* @param string $type The type of data for which the event was scheduled
* @param int $timestamp The timestamp at which the event will first run
* @param string $frequency The frequency of the scheduled event
* @param string $hook The hook that will be triggered
* @param array $params The parameters that will be passed to the hook
* @since 1.0.0
*/
do_action(
'upserv_scheduled_cleanup_event',
$result,

View file

@ -13,13 +13,48 @@ use Anyape\UpdatePulse\Server\Table\Licenses_Table;
use Anyape\UpdatePulse\Server\Server\License\License_Server;
use Anyape\UpdatePulse\Server\Scheduler\Scheduler;

/**
* License Manager class
*
* @since 1.0.0
*/
class License_Manager {

/**
* Licenses table
*
* @var Licenses_Table|null
* @since 1.0.0
*/
protected $licenses_table;
/**
* Message to display
*
* @var string
* @since 1.0.0
*/
protected $message = '';
protected $errors = array();
/**
* Error messages
*
* @var array
* @since 1.0.0
*/
protected $errors = array();
/**
* License server instance
*
* @var License_Server|null
* @since 1.0.0
*/
protected $license_server;

/**
* Constructor
*
* @param boolean $init_hooks Whether to initialize hooks
* @since 1.0.0
*/
public function __construct( $init_hooks = false ) {

if ( $init_hooks ) {
@ -53,6 +88,13 @@ class License_Manager {

// WordPress hooks ---------------------------------------------

/**
* Activate license system
*
* Creates necessary database tables and sets up license expiration schedule.
*
* @since 1.0.0
*/
public static function activate() {
$result = self::maybe_create_or_upgrade_db();

@ -62,22 +104,50 @@ class License_Manager {
die( $error_message ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}

$manager = new self();
$manager = new self();
/**
* Filter the frequency at which the license maintenance task runs.
*
* @param string $frequency The WordPress schedule frequency (hourly, daily, etc.)
*/
$frequency = apply_filters( 'upserv_schedule_license_frequency', 'hourly' );

$manager->register_license_schedules( $frequency );
}

/**
* Deactivate license system
*
* Removes scheduled license expiration tasks.
*
* @since 1.0.0
*/
public static function deactivate() {
Scheduler::get_instance()->unschedule_all_actions( 'upserv_expire_licenses' );

/**
* Fired after the license maintenance event has been unscheduled.
*/
do_action( 'upserv_cleared_license_schedule' );
}

/**
* Initialize scheduler
*
* Sets up recurring schedule for license expiration checks.
*
* @since 1.0.0
*/
public function upserv_scheduler_init() {
$hook = 'upserv_expire_licenses';

if ( ! Scheduler::get_instance()->has_scheduled_action( $hook ) ) {
$frequency = apply_filters( 'upserv_schedule_license_frequency', 'daily' );
/**
* Filter the frequency at which the license maintenance task runs.
*
* @param string $frequency The WordPress schedule frequency (daily, etc.)
*/
$frequency = apply_filters( 'upserv_schedule_license_frequency', 'hourly' );
$schedules = wp_get_schedules();
$d = new DateTime( 'now', new DateTimeZone( wp_timezone_string() ) );

@ -90,12 +160,27 @@ class License_Manager {
$hook
);

/**
* Fired after the license maintenance event has been scheduled.
*
* @param bool $result Whether the event was scheduled successfully
* @param int $timestamp Timestamp for when to run the event the first time
* @param string $frequency Frequency at which the event would be ran
* @param string $hook Event hook to fire when the event is ran
*/
do_action( 'upserv_scheduled_license_event', $result, $timestamp, $frequency, $hook );
}

$this->register_license_schedules();
}

/**
* Initialize admin area
*
* Sets up license table and processes form submissions.
*
* @since 1.0.0
*/
public function admin_init() {

if ( ! is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
@ -188,6 +273,15 @@ class License_Manager {
}
}

/**
* Register admin styles
*
* Adds license-specific styles to the admin area.
*
* @param array $styles Existing admin styles
* @return array Modified admin styles
* @since 1.0.0
*/
public function upserv_admin_styles( $styles ) {
$styles['license'] = array(
'path' => UPSERV_PLUGIN_PATH . 'css/admin/license' . upserv_assets_suffix() . '.css',
@ -199,6 +293,15 @@ class License_Manager {
return $styles;
}

/**
* Register admin scripts
*
* Adds license-specific scripts to the admin area.
*
* @param array $scripts Existing admin scripts
* @return array Modified admin scripts
* @since 1.0.0
*/
public function upserv_admin_scripts( $scripts ) {
$page = ! empty( $_REQUEST['page'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['page'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

@ -221,6 +324,12 @@ class License_Manager {
'params' => array(
'cm_settings' => wp_enqueue_code_editor( array( 'type' => 'text/json' ) ),
),
/**
* Filter the internationalization strings passed to the frontend scripts.
*
* @param array $l10n The internationalization strings passed to the frontend scripts
* @param string $handle The handle of the script
*/
'l10n' => apply_filters( 'upserv_scripts_l10n', $l10n, 'license' ),
);

@ -229,6 +338,13 @@ class License_Manager {
return $scripts;
}

/**
* Add page options
*
* Adds screen options for the licenses page.
*
* @since 1.0.0
*/
public function add_page_options() {
$option = 'per_page';
$args = array(
@ -240,22 +356,61 @@ class License_Manager {
add_screen_option( $option, $args );
}

/**
* Save page options
*
* Handles saving of screen options.
*
* @param mixed $status Status of the option
* @param string $option Option name
* @param mixed $value Option value
* @return mixed Filtered option value
* @since 1.0.0
*/
public function set_page_options( $status, $option, $value ) {
return $value;
}

/**
* Add package table columns
*
* Adds license columns to the packages table.
*
* @param array $columns Existing table columns
* @return array Modified table columns
* @since 1.0.0
*/
public function upserv_packages_table_columns( $columns ) {
$columns['col_use_license'] = __( 'License status', 'updatepulse-server' );

return $columns;
}

/**
* Add sortable columns
*
* Adds sortable license columns to the packages table.
*
* @param array $columns Existing sortable columns
* @return array Modified sortable columns
* @since 1.0.0
*/
public function upserv_packages_table_sortable_columns( $columns ) {
$columns['col_use_license'] = __( 'License status', 'updatepulse-server' );

return $columns;
}

/**
* Display package table cell content
*
* Populates license status cells in the packages table.
*
* @param string $column_name Name of the column
* @param array $record Record data
* @param string $record_key Record identifier
* @since 1.0.0
*/
public function upserv_packages_table_cell( $column_name, $record, $record_key ) {
$use_license = upserv_is_package_require_license( $record_key );

@ -264,6 +419,13 @@ class License_Manager {
}
}

/**
* Add admin menu
*
* Registers the licenses submenu page.
*
* @since 1.0.0
*/
public function admin_menu() {
$function = array( $this, 'plugin_page' );
$page_title = __( 'UpdatePulse Server - Licenses', 'updatepulse-server' );
@ -275,6 +437,15 @@ class License_Manager {
add_submenu_page( $parent_slug, $page_title, $menu_title, $capability, $menu_slug, $function );
}

/**
* Add admin tab links
*
* Adds licenses tab to the admin navigation.
*
* @param array $links Existing tab links
* @return array Modified tab links
* @since 1.0.0
*/
public function upserv_admin_tab_links( $links ) {
$links['licenses'] = array(
admin_url( 'admin.php?page=upserv-page-licenses' ),
@ -284,6 +455,16 @@ class License_Manager {
return $links;
}

/**
* Manage admin tab states
*
* Updates active state for the licenses tab.
*
* @param array $states Current tab states
* @param string $page Current page
* @return array Modified tab states
* @since 1.0.0
*/
public function upserv_admin_tab_states( $states, $page ) {
$states['licenses'] = 'upserv-page-licenses' === $page;

@ -292,17 +473,44 @@ class License_Manager {

// Misc. -------------------------------------------------------

/**
* Expire licenses
*
* Changes status of expired licenses.
*
* @since 1.0.0
*/
public function expire_licenses() {
$this->license_server->switch_expired_licenses_status();
}

/**
* Register license schedules
*
* Sets up hooks for scheduled license tasks.
*
* @since 1.0.0
*/
public function register_license_schedules() {
$scheduled_hook = array( $this, 'expire_licenses' );

add_action( 'upserv_expire_licenses', $scheduled_hook, 10, 2 );

/**
* Fired after the license maintenance action has been registered.
*
* @param string $scheduled_hook The license event hook that has been registered
*/
do_action( 'upserv_registered_license_schedule', $scheduled_hook );
}

/**
* Display plugin page
*
* Renders the licenses management page.
*
* @since 1.0.0
*/
public function plugin_page() {

if ( ! current_user_can( 'manage_options' ) ) {
@ -349,6 +557,14 @@ class License_Manager {
* Protected methods
*******************************************************************/

/**
* Create or upgrade database
*
* Sets up and updates the licenses database table.
*
* @return bool Whether database setup was successful
* @since 1.0.0
*/
protected static function maybe_create_or_upgrade_db() {
global $wpdb;

@ -398,6 +614,14 @@ class License_Manager {
return true;
}

/**
* Handle plugin options
*
* Processes and saves plugin settings.
*
* @return string|bool Success message or false on failure
* @since 1.0.0
*/
protected function plugin_options_handler() {
$errors = array();
$result = '';
@ -456,7 +680,20 @@ class License_Manager {
return $result;
}

/**
* Get submitted options
*
* Retrieves and validates options from form submission.
*
* @return array Options array with validation parameters
* @since 1.0.0
*/
protected function get_submitted_options() {
/**
* Filter the submitted license configuration values before saving them.
*
* @param array $config The submitted license configuration values
*/
return apply_filters(
'upserv_submitted_licenses_config',
array(
@ -470,6 +707,15 @@ class License_Manager {
);
}

/**
* Change license statuses in bulk
*
* Updates status for multiple licenses at once.
*
* @param string $status New status to apply
* @param array $license_data Licenses to update
* @since 1.0.0
*/
protected function change_license_statuses_bulk( $status, $license_data ) {

if ( ! is_array( $license_data ) ) {
@ -524,6 +770,15 @@ class License_Manager {
}
}

/**
* Delete licenses in bulk
*
* Removes multiple licenses from the system.
*
* @param array $license_ids IDs of licenses to delete
* @return array IDs of deleted licenses
* @since 1.0.0
*/
protected function delete_license_bulk( $license_ids ) {

if ( ! is_array( $license_ids ) ) {
@ -550,6 +805,14 @@ class License_Manager {
return $license_ids;
}

/**
* Update a license
*
* Updates an existing license with new data.
*
* @param string $license_data JSON encoded license data
* @since 1.0.0
*/
protected function update_license( $license_data ) {
$payload = json_decode( $license_data, true );
$payload['data'] = json_decode( $payload['data'], true );
@ -564,6 +827,14 @@ class License_Manager {
}
}

/**
* Create a new license
*
* Adds a new license to the system.
*
* @param string $license_data JSON encoded license data
* @since 1.0.0
*/
protected function create_license( $license_data ) {
$payload = json_decode( $license_data, true );
$payload['data'] = json_decode( $payload['data'], true );
@ -578,6 +849,13 @@ class License_Manager {
}
}

/**
* Delete all licenses
*
* Removes all licenses from the system.
*
* @since 1.0.0
*/
protected function delete_all_licenses() {
$this->license_server->purge_licenses();


File diff suppressed because it is too large Load diff

View file

@ -11,8 +11,19 @@ use Anyape\UpdatePulse\Server\Manager\Data_Manager;
use Anyape\UpdatePulse\Server\API\Update_API;
use Anyape\UpdatePulse\Server\Scheduler\Scheduler;

/**
* Remote Sources Manager class
*
* @since 1.0.0
*/
class Remote_Sources_Manager {

/**
* Constructor
*
* @param boolean $init_hooks Whether to initialize WordPress hooks.
* @since 1.0.0
*/
public function __construct( $init_hooks = false ) {

if ( $init_hooks ) {
@ -40,14 +51,35 @@ class Remote_Sources_Manager {

// WordPress hooks ---------------------------------------------

/**
* Activate
*
* Register schedules when the plugin is activated.
*
* @since 1.0.0
*/
public static function activate() {
self::register_schedules();
}

/**
* Deactivate
*
* Clear schedules when the plugin is deactivated.
*
* @since 1.0.0
*/
public static function deactivate() {
self::clear_schedules();
}

/**
* Enqueue admin scripts
*
* @param array $scripts List of scripts to enqueue.
* @return array Modified list of scripts.
* @since 1.0.0
*/
public function upserv_admin_scripts( $scripts ) {
$page = ! empty( $_REQUEST['page'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['page'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

@ -64,6 +96,13 @@ class Remote_Sources_Manager {
return $scripts;
}

/**
* Enqueue admin styles
*
* @param array $styles List of styles to enqueue.
* @return array Modified list of styles.
* @since 1.0.0
*/
public function upserv_admin_styles( $styles ) {
$styles['remote_sources'] = array(
'path' => UPSERV_PLUGIN_PATH . 'css/admin/remote-sources' . upserv_assets_suffix() . '.css',
@ -73,6 +112,11 @@ class Remote_Sources_Manager {
return $styles;
}

/**
* Register remote check scheduled hooks
*
* @since 1.0.0
*/
public function register_remote_check_scheduled_hooks() {

if ( upserv_is_doing_update_api_request() ) {
@ -89,7 +133,7 @@ class Remote_Sources_Manager {

foreach ( $vcs_configs as $vcs_c ) {

if ( $vcs_c['use_webhooks'] || ! isset( $vcs_c['url'] ) ) {
if ( ! isset( $vcs_c['url'] ) ) {
continue;
}

@ -104,6 +148,15 @@ class Remote_Sources_Manager {

foreach ( $slugs as $slug ) {
add_action( 'upserv_check_remote_' . $slug, $action_hook, 10, 3 );

/**
* Fired after a remote check action has been registered for a package.
* Fired during client update API request.
*
* @param string $package_slug The slug of the package for which an action has been registered
* @param string $scheduled_hook The event hook the action has been registered to
* @param string $action_hook The action that has been registered
*/
do_action(
'upserv_registered_check_remote_schedule',
$slug,
@ -114,6 +167,13 @@ class Remote_Sources_Manager {
}
}

/**
* Clear remote check scheduled hooks
*
* @param array|null $vcs_configs VCS configurations.
* @return bool True on success, false on failure.
* @since 1.0.0
*/
public function clear_remote_check_scheduled_hooks( $vcs_configs = null ) {

if ( upserv_is_doing_update_api_request() ) {
@ -144,6 +204,14 @@ class Remote_Sources_Manager {
$scheduled_hook = 'upserv_check_remote_' . $slug;

Scheduler::get_instance()->unschedule_all_actions( $scheduled_hook );

/**
* Fired after a remote check schedule event has been unscheduled for a package.
* Fired during client update API request.
*
* @param string $package_slug The slug of the package for which a remote check event has been unscheduled
* @param string $scheduled_hook The remote check event hook that has been unscheduled
*/
do_action( 'upserv_cleared_check_remote_schedule', $slug, $scheduled_hook );
}
}
@ -151,6 +219,11 @@ class Remote_Sources_Manager {
return true;
}

/**
* Add admin menu
*
* @since 1.0.0
*/
public function admin_menu() {
$function = array( $this, 'plugin_page' );
$page_title = __( 'UpdatePulse Server - Version Control Systems ', 'updatepulse-server' );
@ -160,6 +233,13 @@ class Remote_Sources_Manager {
add_submenu_page( 'upserv-page', $page_title, $menu_title, 'manage_options', $menu_slug, $function );
}

/**
* Add admin tab links
*
* @param array $links List of admin tab links.
* @return array Modified list of admin tab links.
* @since 1.0.0
*/
public function upserv_admin_tab_links( $links ) {
$links['remote-sources'] = array(
admin_url( 'admin.php?page=upserv-page-remote-sources' ),
@ -169,12 +249,25 @@ class Remote_Sources_Manager {
return $links;
}

/**
* Add admin tab states
*
* @param array $states List of admin tab states.
* @param string $page Current admin page.
* @return array Modified list of admin tab states.
* @since 1.0.0
*/
public function upserv_admin_tab_states( $states, $page ) {
$states['remote-sources'] = 'upserv-page-remote-sources' === $page;

return $states;
}

/**
* Force clean
*
* @since 1.0.0
*/
public function force_clean() {
$result = false;
$type = false;
@ -225,6 +318,11 @@ class Remote_Sources_Manager {
}
}

/**
* VCS test
*
* @since 1.0.0
*/
public function vcs_test() {
$result = false;

@ -301,12 +399,23 @@ class Remote_Sources_Manager {

// Misc. -------------------------------------------------------

/**
* Clear schedules
*
* @return bool True on success, false on failure.
* @since 1.0.0
*/
public static function clear_schedules() {
$manager = new self();

return $manager->clear_remote_check_scheduled_hooks();
}

/**
* Register schedules
*
* @since 1.0.0
*/
public static function register_schedules() {
$options = get_option( 'upserv_options' );
$options = json_decode( $options, true );
@ -329,6 +438,13 @@ class Remote_Sources_Manager {
}
}

/**
* Reschedule remote check recurring events
*
* @param array $vcs_c VCS configuration.
* @return bool True on success, false on failure.
* @since 1.0.0
*/
public function reschedule_remote_check_recurring_events( $vcs_c ) {

if (
@ -346,10 +462,17 @@ class Remote_Sources_Manager {
}

foreach ( $slugs as $slug ) {
$meta = upserv_get_package_metadata( $slug );
$type = isset( $meta['type'] ) ? $meta['type'] : null;
$hook = 'upserv_check_remote_' . $slug;
$params = array( $slug, $type, false );
$meta = upserv_get_package_metadata( $slug );
$type = isset( $meta['type'] ) ? $meta['type'] : null;
$hook = 'upserv_check_remote_' . $slug;
$params = array( $slug, $type, false );

/**
* Filter the frequency at which remote checks for updates are performed for a package.
*
* @param string $frequency The frequency at which remote checks are performed
* @param string $package_slug The slug of the package
*/
$frequency = apply_filters(
'upserv_check_remote_frequency',
isset( $vcs_c['check_frequency'] ) ? $vcs_c['check_frequency'] : 'daily',
@ -359,6 +482,14 @@ class Remote_Sources_Manager {
$schedules = wp_get_schedules();

Scheduler::get_instance()->unschedule_all_actions( $hook, $params );

/**
* Fired after a remote check schedule event has been unscheduled for a package.
* Fired during client update API request.
*
* @param string $package_slug The slug of the package for which a remote check event has been unscheduled
* @param string $scheduled_hook The remote check event hook that has been unscheduled
*/
do_action( 'upserv_cleared_check_remote_schedule', $slug, $hook );

$result = Scheduler::get_instance()->schedule_recurring_action(
@ -368,6 +499,17 @@ class Remote_Sources_Manager {
$params
);

/**
* Fired after a remote check event has been scheduled for a package.
* Fired during client update API request.
*
* @param bool $result Whether the event was scheduled
* @param string $package_slug The slug of the package for which the event was scheduled
* @param int $timestamp Timestamp for when to run the event the first time after it's been scheduled
* @param string $frequency Frequency at which the event would be ran
* @param string $hook Event hook to fire when the event is ran
* @param array $params Parameters passed to the actions registered to $hook when the event is ran
*/
do_action(
'upserv_scheduled_check_remote_event',
$result,
@ -382,6 +524,11 @@ class Remote_Sources_Manager {
return true;
}

/**
* Plugin page
*
* @since 1.0.0
*/
public function plugin_page() {

if ( ! current_user_can( 'manage_options' ) ) {
@ -419,6 +566,12 @@ class Remote_Sources_Manager {
* Protected methods
*******************************************************************/

/**
* Plugin options handler
*
* @return array|string Result of the options update.
* @since 1.0.0
*/
protected function plugin_options_handler() {
$errors = array();
$result = '';
@ -454,6 +607,14 @@ class Remote_Sources_Manager {
$option_info['value'] = (bool) $option_info['value'];
}

/**
* Filter whether to update the remote source option.
*
* @param bool $condition Whether to update the option
* @param string $option_name The name of the option
* @param array $option_info Information about the option
* @param array $options All submitted options
*/
$condition = apply_filters(
'upserv_remote_source_option_update',
$condition,
@ -463,6 +624,14 @@ class Remote_Sources_Manager {
);

if ( $condition ) {
/**
* Filter the value of the remote source option before saving it.
*
* @param mixed $value The value of the option
* @param string $option_name The name of the option
* @param array $option_info Information about the option
* @param array $options All submitted options
*/
$to_save[ $option_info['path'] ] = apply_filters(
'upserv_remote_sources_option_save_value',
$option_info['value'],
@ -567,11 +736,26 @@ class Remote_Sources_Manager {
}

set_transient( 'upserv_flush', 1, 60 );

/**
* Fired after the options in "Remote Sources" have been updated.
*
* @param array|string $result The result of the options update, an array of errors or a success message
*/
do_action( 'upserv_remote_sources_options_updated', $result );

return $result;
}

/**
* Filter JSON input
*
* @param array $inputs JSON input data.
* @param string $option_name Option name.
* @param array $errors List of errors.
* @return array Filtered JSON input data.
* @since 1.0.0
*/
protected function filter_json_input( $inputs, $option_name, &$errors ) {
$filtered = array();
$index = 0;
@ -642,7 +826,19 @@ class Remote_Sources_Manager {
return $filtered;
}

/**
* Get submitted options
*
* @return array List of submitted options.
* @since 1.0.0
*/
protected function get_submitted_options() {
/**
* Filter the submitted remote sources configuration values before using them.
*
* @param array $config The submitted remote sources configuration values
* @return array The filtered configuration
*/
return apply_filters(
'upserv_submitted_remote_sources_config',
array(
@ -663,6 +859,13 @@ class Remote_Sources_Manager {
);
}

/**
* Get package slugs
*
* @param string $vcs_url VCS URL.
* @return array List of package slugs.
* @since 1.0.0
*/
protected function get_package_slugs( $vcs_url ) {
$slugs = wp_cache_get( 'package_slugs', 'updatepulse-server' );

@ -682,7 +885,7 @@ class Remote_Sources_Manager {
}
}

if ( empty( $slugs ) ) {
if ( ! empty( $slugs ) ) {

foreach ( $slugs as $idx => $slug ) {
$meta = upserv_get_package_metadata( $slug );

View file

@ -6,19 +6,61 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}

use WP_Error;
use ZipArchive;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
use FilesystemIterator;
use Anyape\Utils\Utils;

/**
* Zip Package Manager class
*
* @since 1.0.0
*/
class Zip_Package_Manager {

/**
* Package slug
*
* @var string
* @since 1.0.0
*/
protected $package_slug;

/**
* Path to the received package
*
* @var string
* @since 1.0.0
*/
protected $received_package_path;

/**
* Temporary directory path
*
* @var string
* @since 1.0.0
*/
protected $tmp_dir;

/**
* Packages directory path
*
* @var string
* @since 1.0.0
*/
protected $packages_dir;

/**
* Constructor
*
* @param string $package_slug The package slug.
* @param string $received_package_path Path to the received package.
* @param string $tmp_dir Temporary directory path.
* @param string $packages_dir Packages directory path.
* @since 1.0.0
*/
public function __construct( $package_slug, $received_package_path, $tmp_dir, $packages_dir ) {
$this->package_slug = $package_slug;
$this->received_package_path = $received_package_path;
@ -30,10 +72,31 @@ class Zip_Package_Manager {
* Public methods
*******************************************************************/

/**
* Unzip package
*
* Extract a zip package to a destination.
*
* @param string $source Path to the source zip file.
* @param string $destination Path to the destination directory.
* @return bool|WP_Error True on success, WP_Error on failure.
* @since 1.0.0
*/
public static function unzip_package( $source, $destination ) {
return unzip_file( $source, $destination );
}

/**
* Zip package
*
* Create a zip archive from a directory or file.
*
* @param string $source Path to the source directory or file.
* @param string $destination Path to the destination zip file.
* @param string $container_dir Optional container directory within the zip.
* @return bool Whether the zip creation was successful.
* @since 1.0.0
*/
public static function zip_package( $source, $destination, $container_dir = '' ) {
$zip = new ZipArchive();

@ -86,6 +149,14 @@ class Zip_Package_Manager {
return $zip->close() && file_exists( $destination );
}

/**
* Clean package
*
* Clean the received package by moving and repacking it.
*
* @return bool True on success, false on failure.
* @since 1.0.0
*/
public function clean_package() {
WP_Filesystem();

@ -94,7 +165,7 @@ class Zip_Package_Manager {
$return = true;
$error_message = __METHOD__ . ': ';

if ( is_wp_error( $this->received_package_path ) ) {
if ( $this->received_package_path instanceof WP_Error ) {
$return = false;
$error_message .= $this->received_package_path->get_error_message();
}
@ -169,6 +240,14 @@ class Zip_Package_Manager {
* Protected methods
*******************************************************************/

/**
* Repack package
*
* Repack the received package by unzipping and zipping it again.
*
* @return bool True on success, false on failure.
* @since 1.0.0
*/
protected function repack_package() {
WP_Filesystem();

@ -207,6 +286,14 @@ class Zip_Package_Manager {

$wp_filesystem->chmod( $temp_path, false, true );

/**
* Fired before packing the files received from the Version Control System. Can be used for extra files manipulation.
* Fired during client update API request.
*
* @param string $package_slug The slug of the package.
* @param string $files_path The path of the directory where the package files are located.
* @param string $archive_path The path where the package archive will be located after packing.
*/
do_action( 'upserv_before_remote_package_zip', $this->package_slug, $temp_path, $archive_path );

$zipped = self::zip_package( $temp_path, $archive_path );

View file

@ -12,25 +12,91 @@ use PasswordHash;
use Anyape\Utils\Utils;
use Anyape\UpdatePulse\Server\Scheduler\Scheduler;

/**
* Nonce class
*
* @since 1.0.0
*/
class Nonce {

/**
* Default expiry length
*
* Default time in seconds before a nonce expires.
*
* @var int
* @since 1.0.0
*/
const DEFAULT_EXPIRY_LENGTH = MINUTE_IN_SECONDS / 2;
const NONCE_ONLY = 1;
const NONCE_INFO_ARRAY = 2;
/**
* Nonce only return type
*
* Constant indicating to return just the nonce string.
*
* @var int
* @since 1.0.0
*/
const NONCE_ONLY = 1;
/**
* Nonce info array return type
*
* Constant indicating to return the nonce with additional information.
*
* @var int
* @since 1.0.0
*/
const NONCE_INFO_ARRAY = 2;

/**
* True nonce flag
*
* Indicates if a nonce is a true nonce.
*
* @var bool|null
* @since 1.0.0
*/
protected static $true_nonce;
/**
* Expiry length
*
* Time in seconds before a nonce expires.
*
* @var int|null
* @since 1.0.0
*/
protected static $expiry_length;
/**
* API request flag
*
* Indicates if the current request is an API request.
*
* @var bool|null
* @since 1.0.0
*/
protected static $doing_api_request = null;
/**
* Private keys
*
* Array of private keys used for authentication.
*
* @var array|null
* @since 1.0.0
*/
protected static $private_keys;

/*******************************************************************
* Public methods
*******************************************************************/

// API action --------------------------------------------------

// WordPress hooks ---------------------------------------------

/**
* Activate
*
* Setup necessary database tables on plugin activation.
*
* @since 1.0.0
*/
public static function activate() {
$result = self::maybe_create_or_upgrade_db();

@ -41,12 +107,33 @@ class Nonce {
}
}

/**
* Deactivate
*
* Clean up scheduled actions on plugin deactivation.
*
* @since 1.0.0
*/
public static function deactivate() {
Scheduler::get_instance()->unschedule_all_actions( 'upserv_nonce_cleanup' );
}

/**
* Uninstall
*
* Placeholder for uninstall logic.
*
* @since 1.0.0
*/
public static function uninstall() {}

/**
* Initialize scheduler
*
* Schedule recurring actions for nonce cleanup.
*
* @since 1.0.0
*/
public static function upserv_scheduler_init() {

if ( Scheduler::get_instance()->has_scheduled_action( 'upserv_nonce_cleanup' ) ) {
@ -63,6 +150,13 @@ class Nonce {
);
}

/**
* Add endpoints
*
* Add rewrite rules for nonce and token endpoints.
*
* @since 1.0.0
*/
public static function add_endpoints() {
add_rewrite_rule(
'^updatepulse-server-token/*?$',
@ -76,6 +170,13 @@ class Nonce {
);
}

/**
* Parse request
*
* Handle incoming requests to the nonce and token endpoints.
*
* @since 1.0.0
*/
public static function parse_request() {
global $wp;

@ -101,6 +202,12 @@ class Nonce {

unset( $payload['action'] );

/**
* Filter the payload sent to the Nonce API.
*
* @param array $payload The payload sent to the Nonce API
* @param string $method The api action - `token` or `nonce`
*/
$payload = apply_filters( 'upserv_nonce_api_payload', $payload, $method );

if (
@ -128,12 +235,35 @@ class Nonce {
}
}

$code = apply_filters( 'upserv_nonce_api_code', $code, $wp->query_vars );
/**
* Filter the HTTP response code to be sent by the Nonce API.
*
* @param string $code The HTTP response code to be sent by the Nonce API
* @param array $request_params The request's parameters
*/
$code = apply_filters( 'upserv_nonce_api_code', $code, $wp->query_vars );

/**
* Filter the response to be sent by the Nonce API.
*
* @param array $response The response to be sent by the Nonce API
* @param string $code The HTTP response code sent by the Nonce API
* @param array $request_params The request's parameters
*/
$response = apply_filters( 'upserv_nonce_api_response', $response, $code, $wp->query_vars );

wp_send_json( $response, $code );
}

/**
* Add query vars
*
* Add custom query variables for nonce and token endpoints.
*
* @param array $query_vars Existing query variables.
* @return array Modified query variables.
* @since 1.0.0
*/
public static function query_vars( $query_vars ) {
$query_vars = array_merge(
$query_vars,
@ -150,10 +280,16 @@ class Nonce {
return $query_vars;
}

// Overrides ---------------------------------------------------

// Misc. -------------------------------------------------------

/**
* Create or upgrade database
*
* Create or upgrade the necessary database tables.
*
* @return bool True on success, false on failure.
* @since 1.0.0
*/
public static function maybe_create_or_upgrade_db() {
global $wpdb;

@ -191,6 +327,13 @@ class Nonce {
return true;
}

/**
* Register hooks
*
* Register WordPress hooks for the nonce functionality.
*
* @since 1.0.0
*/
public static function register() {

if ( ! self::is_doing_api_request() ) {
@ -204,10 +347,26 @@ class Nonce {
add_filter( 'query_vars', array( __CLASS__, 'query_vars' ), -99, 1 );
}

/**
* Initialize authentication
*
* Initialize the private keys used for authentication.
*
* @param array $private_keys Array of private keys.
* @since 1.0.0
*/
public static function init_auth( $private_keys ) {
self::$private_keys = $private_keys;
}

/**
* Check if doing API request
*
* Check if the current request is an API request.
*
* @return bool True if doing API request, false otherwise.
* @since 1.0.0
*/
public static function is_doing_api_request() {

if ( null === self::$doing_api_request ) {
@ -217,6 +376,19 @@ class Nonce {
return self::$doing_api_request;
}

/**
* Create nonce
*
* Create a new nonce.
*
* @param bool $true_nonce Indicates if the nonce is a true nonce.
* @param int $expiry_length Time in seconds before the nonce expires.
* @param array $data Additional data to store with the nonce.
* @param int $return_type Return type (nonce only or nonce info array).
* @param bool $store Indicates if the nonce should be stored in the database.
* @return mixed The nonce or nonce info array.
* @since 1.0.0
*/
public static function create_nonce(
$true_nonce = true,
$expiry_length = self::DEFAULT_EXPIRY_LENGTH,
@ -224,6 +396,17 @@ class Nonce {
$return_type = self::NONCE_ONLY,
$store = true
) {
/**
* Filter the value of the nonce before it is created; if $nonce_value is truthy,
* the value is used as nonce and the default generation algorithm is bypassed;
* developers must respect the $return_type.
*
* @param bool|string|array $nonce_value The value of the nonce before it is created - if truthy, the nonce is considered created with this value
* @param bool $true_nonce Whether the nonce is a true, one-time-use nonce
* @param int $expiry_length The expiry length of the nonce in seconds
* @param array $data Data to store along the nonce
* @param int $return_type UPServ_Nonce::NONCE_ONLY or UPServ_Nonce::NONCE_INFO_ARRAY
*/
$nonce = apply_filters(
'upserv_created_nonce',
false,
@ -279,6 +462,15 @@ class Nonce {
return $return;
}

/**
* Get nonce expiry
*
* Get the expiry time of a nonce.
*
* @param string $nonce The nonce string.
* @return int The expiry time in seconds.
* @since 1.0.0
*/
public static function get_nonce_expiry( $nonce ) {
global $wpdb;

@ -304,6 +496,15 @@ class Nonce {
return intval( $nonce_expiry );
}

/**
* Get nonce data
*
* Get the data associated with a nonce.
*
* @param string $nonce The nonce string.
* @return array The nonce data.
* @since 1.0.0
*/
public static function get_nonce_data( $nonce ) {
global $wpdb;

@ -329,6 +530,15 @@ class Nonce {
return $data;
}

/**
* Validate nonce
*
* Validate a nonce.
*
* @param string $value The nonce string.
* @return bool True if the nonce is valid, false otherwise.
* @since 1.0.0
*/
public static function validate_nonce( $value ) {

if ( empty( $value ) ) {
@ -341,7 +551,15 @@ class Nonce {
return $valid;
}


/**
* Delete nonce
*
* Delete a nonce from the database.
*
* @param string $value The nonce string.
* @return bool True on success, false on failure.
* @since 1.0.0
*/
public static function delete_nonce( $value ) {
global $wpdb;

@ -352,6 +570,13 @@ class Nonce {
return (bool) $result;
}

/**
* Nonce cleanup
*
* Clean up expired nonces from the database.
*
* @since 1.0.0
*/
public static function upserv_nonce_cleanup() {

if ( defined( 'WP_SETUP_CONFIG' ) || defined( 'WP_INSTALLING' ) ) {
@ -363,17 +588,33 @@ class Nonce {
$sql = "DELETE FROM {$wpdb->prefix}upserv_nonce
WHERE expiry < %d
AND (
JSON_VALID(`data`) = 1
AND (
JSON_EXTRACT(`data` , '$.permanent') IS NULL
OR JSON_EXTRACT(`data` , '$.permanent') = 0
OR JSON_EXTRACT(`data` , '$.permanent') = '0'
OR JSON_EXTRACT(`data` , '$.permanent') = false
JSON_VALID(`data`) = 0
OR (
JSON_VALID(`data`) = 1
AND (
JSON_EXTRACT(`data` , '$.permanent') IS NULL
OR JSON_EXTRACT(`data` , '$.permanent') = 0
OR JSON_EXTRACT(`data` , '$.permanent') = '0'
OR JSON_EXTRACT(`data` , '$.permanent') = false
)
)
) OR
JSON_VALID(`data`) = 0;";
);";
$sql_args = array( time() - self::DEFAULT_EXPIRY_LENGTH );
$sql = apply_filters( 'upserv_clear_nonces_query', $sql, $sql_args );

/**
* Filter the SQL query used to clear expired nonces.
*
* @param string $sql The SQL query used to clear expired nonces
* @param array $sql_args The arguments passed to the SQL query used to clear expired nonces
*/
$sql = apply_filters( 'upserv_clear_nonces_query', $sql, $sql_args );

/**
* Filter the arguments passed to the SQL query used to clear expired nonces.
*
* @param array $sql_args The arguments passed to the SQL query used to clear expired nonces
* @param string $sql The SQL query used to clear expired nonces
*/
$sql_args = apply_filters( 'upserv_clear_nonces_query_args', $sql_args, $sql );
$result = $wpdb->query( $wpdb->prepare( $sql, $sql_args ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

@ -386,14 +627,42 @@ class Nonce {

// API action --------------------------------------------------

/**
* Generate token API response
*
* Generate a response for the token API endpoint.
*
* @param array $payload The request payload.
* @return array The API response.
* @since 1.0.0
*/
protected static function generate_token_api_response( $payload ) {
return self::generate_api_response( $payload, false );
}

/**
* Generate nonce API response
*
* Generate a response for the nonce API endpoint.
*
* @param array $payload The request payload.
* @return array The API response.
* @since 1.0.0
*/
protected static function generate_nonce_api_response( $payload ) {
return self::generate_api_response( $payload, true );
}

/**
* Generate API response
*
* Generate a response for the API endpoint.
*
* @param array $payload The request payload.
* @param bool $is_nonce Indicates if the response is for a nonce.
* @return array The API response.
* @since 1.0.0
*/
protected static function generate_api_response( $payload, $is_nonce ) {
return self::create_nonce(
$is_nonce,
@ -407,6 +676,15 @@ class Nonce {

// Misc. -------------------------------------------------------

/**
* Fetch nonce
*
* Fetch a nonce from the database.
*
* @param string $value The nonce string.
* @return string|null The nonce or null if not found.
* @since 1.0.0
*/
protected static function fetch_nonce( $value ) {
global $wpdb;

@ -442,6 +720,16 @@ class Nonce {
}

if ( $row->expiry < time() && ! $permanent ) {
/**
* Filter whether to consider the nonce has expired.
*
* @param bool $expire_nonce Whether to consider the nonce has expired
* @param string $nonce_value The value of the nonce
* @param bool $true_nonce Whether the nonce is a true, one-time-use nonce
* @param int $expiry The timestamp at which the nonce expires
* @param array $data Data stored along the nonce
* @param object $row The database record corresponding to the nonce
*/
$row->nonce = apply_filters(
'upserv_expire_nonce',
null,
@ -453,6 +741,16 @@ class Nonce {
);
}

/**
* Filter whether to delete the nonce.
*
* @param bool $delete Whether to delete the nonce
* @param string $nonce_value The value of the nonce
* @param bool $true_nonce Whether the nonce is a true, one-time-use nonce
* @param int $expiry The timestamp at which the nonce expires
* @param array $data Data stored along the nonce
* @param object $row The database record corresponding to the nonce
*/
$delete_nonce = apply_filters(
'upserv_delete_nonce',
$row->true_nonce || null === $row->nonce,
@ -466,11 +764,32 @@ class Nonce {
self::delete_nonce( $value );
}

/**
* Filter the value of the nonce after it has been fetched from the database.
*
* @param string $nonce_value The value of the nonce after it has been fetched from the database
* @param bool $true_nonce Whether the nonce is a true, one-time-use nonce
* @param int $expiry The timestamp at which the nonce expires
* @param array $data Data stored along the nonce
* @param object $row The database record corresponding to the nonce
*/
$nonce = apply_filters( 'upserv_fetch_nonce', $row->nonce, $row->true_nonce, $row->expiry, $data, $row );

return $nonce;
}

/**
* Store nonce
*
* Store a nonce in the database.
*
* @param string $nonce The nonce string.
* @param bool $true_nonce Indicates if the nonce is a true nonce.
* @param int $expiry The expiry time in seconds.
* @param string $data The nonce data.
* @return array|false The stored nonce data or false on failure.
* @since 1.0.0
*/
protected static function store_nonce( $nonce, $true_nonce, $expiry, $data ) {
global $wpdb;

@ -489,6 +808,14 @@ class Nonce {
return false;
}

/**
* Generate ID
*
* Generate a unique ID.
*
* @return string The generated ID.
* @since 1.0.0
*/
protected static function generate_id() {
require_once ABSPATH . 'wp-includes/class-phpass.php';

@ -497,6 +824,14 @@ class Nonce {
return md5( $hasher->get_random_bytes( 100, false ) );
}

/**
* Authorize request
*
* Authorize the incoming request using the provided credentials and signature.
*
* @return bool True if the request is authorized, false otherwise.
* @since 1.0.0
*/
protected static function authorize() {
$sign = false;
$key_id = false;
@ -560,6 +895,13 @@ class Nonce {
$auth = hash_equals( $values['signature'], $sign );
}

/**
* Filter whether the request for a nonce is authorized.
*
* @param bool $authorized Whether the request is authorized
* @param string $received_key The key use to attempt the authorization
* @param array $private_auth_keys The valid authorization keys
*/
return apply_filters(
'upserv_nonce_authorize',
$auth,

View file

@ -8,9 +8,26 @@ if ( ! defined( 'ABSPATH' ) ) {

use WP_Error;

/**
* Scheduler class
*
* @since 1.0.0
*/
class Scheduler {
/**
* Instance
*
* @var Scheduler|null
* @since 1.0.0
*/
protected static $instance = null;

/**
* Constructor
*
* @param boolean $init_hooks Whether to initialize hooks.
* @since 1.0.0
*/
public function __construct( $init_hooks = false ) {

if ( $init_hooks ) {
@ -19,6 +36,14 @@ class Scheduler {
}
}

/**
* Get instance
*
* Retrieve or create the Scheduler singleton instance.
*
* @return Scheduler The scheduler instance.
* @since 1.0.0
*/
public static function get_instance() {

if ( ! self::$instance ) {
@ -28,6 +53,16 @@ class Scheduler {
return self::$instance;
}

/**
* Magic method handler
*
* Routes method calls to either ActionScheduler functions or native WordPress functions.
*
* @param string $name The method name.
* @param array $arguments The method arguments.
* @return mixed|WP_Error The result of the method call or error if method doesn't exist.
* @since 1.0.0
*/
public function __call( $name, $arguments ) {

if ( ! method_exists( $this, $name ) ) {
@ -51,10 +86,24 @@ class Scheduler {
return $this->$name( ...$arguments );
}

/**
* Action scheduler initialization
*
* Fires when the Action Scheduler is initialized.
*
* @since 1.0.0
*/
public function action_scheduler_init() {
do_action( 'upserv_scheduler_init' );
}

/**
* Initialize
*
* Handles plugin initialization logic.
*
* @since 1.0.0
*/
public function init() {

if ( ! class_exists( 'ActionScheduler', false ) ) {
@ -62,6 +111,20 @@ class Scheduler {
}
}

/**
* Schedule single action
*
* Schedule a one-time action event.
*
* @param int $timestamp When the action should run (Unix timestamp).
* @param string $hook The hook to execute.
* @param array $args Arguments to pass to the hook's callback.
* @param string $group The group to assign this action to.
* @param bool $unique Whether to ensure this action is unique.
* @param int $priority The priority of the action.
* @return bool|int The action ID or false if not scheduled.
* @since 1.0.0
*/
protected function schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed

if ( $unique ) {
@ -71,6 +134,21 @@ class Scheduler {
return wp_schedule_single_event( $timestamp, $hook, $args );
}

/**
* Schedule recurring action
*
* Schedule a repeating action event.
*
* @param int $timestamp When the action should first run (Unix timestamp).
* @param int $interval_in_seconds How long to wait between runs.
* @param string $hook The hook to execute.
* @param array $args Arguments to pass to the hook's callback.
* @param string $group The group to assign this action to.
* @param bool $unique Whether to ensure this action is unique.
* @param int $priority The priority of the action.
* @return bool|int The action ID or false if not scheduled.
* @since 1.0.0
*/
protected function schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed

if ( $unique ) {
@ -101,6 +179,17 @@ class Scheduler {
return wp_schedule_event( $timestamp, $interval, $hook, $args );
}

/**
* Unschedule all actions
*
* Cancel all scheduled instances of a specific action.
*
* @param string $hook The action hook to unschedule.
* @param array $args Args matching those of the action to unschedule.
* @param string $group The group to which the action belongs.
* @return void
* @since 1.0.0
*/
protected function unschedule_all_actions( $hook, $args = array(), $group = '' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$timestamp = wp_next_scheduled( $hook, $args );

@ -110,10 +199,32 @@ class Scheduler {
}
}

/**
* Get next scheduled action
*
* Retrieve the next timestamp for a scheduled action.
*
* @param string $hook The hook to check.
* @param array $args Args matching those of the action to check.
* @param string $group The group to which the action belongs.
* @return int|false The timestamp for the next occurrence or false if not scheduled.
* @since 1.0.0
*/
protected function next_scheduled_action( $hook, $args = array(), $group = '' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
return wp_next_scheduled( $hook, $args );
}

/**
* Check if action is scheduled
*
* Determine whether an action is currently scheduled.
*
* @param string $hook The hook to check.
* @param array $args Args matching those of the action to check.
* @param string $group The group to which the action belongs.
* @return bool Whether the action is scheduled.
* @since 1.0.0
*/
protected function has_scheduled_action( $hook, $args = array(), $group = '' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
return (bool) wp_next_scheduled( $hook, $args );
}

View file

@ -13,8 +13,19 @@ use WP_Error;
use Anyape\Crypto\Crypto;
use Anyape\Utils\Utils;

/**
* License Server class
*
* @since 1.0.0
*/
class License_Server {

/**
* License definition template
*
* @var array
* @since 1.0.0
*/
public static $license_definition = array(
'id' => 0,
'license_key' => '',
@ -32,13 +43,27 @@ class License_Server {
'package_type' => '',
'data' => array(),
);
public static $browsing_query = array(

/**
* Default browsing query settings
*
* @var array
* @since 1.0.0
*/
public static $browsing_query = array(
'relationship' => 'AND',
'limit' => 999,
'offset' => 0,
'order_by' => 'date_created',
'criteria' => array(),
);

/**
* Supported browsing operators
*
* @var array
* @since 1.0.0
*/
public static $browsing_operators = array(
'=',
'!=',
@ -53,7 +78,14 @@ class License_Server {
'LIKE',
'NOT LIKE',
);
public static $license_statuses = array(

/**
* Supported license statuses
*
* @var array
* @since 1.0.0
*/
public static $license_statuses = array(
'pending',
'activated',
'deactivated',
@ -62,12 +94,26 @@ class License_Server {
'expired',
);

/**
* Constructor
*
* @since 1.0.0
*/
public function __construct() {}

/*******************************************************************
* Public methods
*******************************************************************/

/**
* Build license payload
*
* Creates a properly structured license payload from input data.
*
* @param array $payload The raw license data.
* @return array The processed license payload.
* @since 1.0.0
*/
public function build_license_payload( $payload ) {
$payload = $this->extend_license_payload( $this->filter_license_payload( $payload ) );

@ -76,11 +122,27 @@ class License_Server {
return $this->cleanup_license_payload( $payload );
}

/**
* Browse licenses
*
* Retrieve multiple licenses based on query criteria.
*
* @param array $payload The query parameters.
* @return array|WP_Error Array of licenses or WP_Error on failure.
* @since 1.0.0
*/
public function browse_licenses( $payload ) {
global $wpdb;

$prepare_args = array();
$payload = apply_filters( 'upserv_browse_licenses_payload', $payload );
/**
* Filter the payload used to browse licenses - before the payload has been cleaned up and the License Query has been validated.
* Fired during client license API request.
*
* @param array $payload A dirty payload for a License Query
* @since 1.0.0
*/
$payload = apply_filters( 'upserv_browse_licenses_payload', $payload );

try {
$browsing_query = $this->build_browsing_query( $payload );
@ -138,11 +200,29 @@ class License_Server {
}
}

/**
* Fired after browsing license records.
* Fired during client license API request.
*
* @param array $licenses The license records retrieved or an empty array
* @param array $payload The payload of the request
* @since 1.0.0
*/
do_action( 'upserv_did_browse_licenses', $licenses, $payload );

return $licenses;
}

/**
* Read license
*
* Retrieve a single license by ID or license key.
*
* @param array $payload The query parameters containing ID or license key.
* @param bool $force Whether to bypass cache.
* @return object|WP_Error License object or WP_Error on failure.
* @since 1.0.0
*/
public function read_license( $payload, $force = false ) {
$where_field = isset( $payload['license_key'] ) ? 'license_key' : 'id';
$where_value = isset( $payload[ $where_field ] ) ? $payload[ $where_field ] : null;
@ -151,7 +231,14 @@ class License_Server {
$validation = $this->validate_license_payload( $payload, true );

if ( ( $force || ! $found ) && true === $validation ) {
$payload = $this->filter_license_payload( $payload );
$payload = $this->filter_license_payload( $payload );
/**
* Filter the payload used to read a license record - after the payload has been cleaned up, before the payload has been validated.
* Fired during client license API request.
*
* @param array $payload Payload used to read a license record
* @since 1.0.0
*/
$payload = apply_filters( 'upserv_read_license_payload', $payload );
$validation = $this->validate_license_payload( $payload, true );
$return = $validation;
@ -177,13 +264,37 @@ class License_Server {
$return = $validation;
}

/**
* Fired after reading a license record.
* Fired during client license API request.
*
* @param mixed $return The result of the operation - a license object record or an empty array
* @param array $payload The payload of the request
* @since 1.0.0
*/
do_action( 'upserv_did_read_license', $return, $payload );

return $return;
}

/**
* Edit license
*
* Update an existing license.
*
* @param array $payload The license data to update.
* @return object|WP_Error Updated license object or WP_Error on failure.
* @since 1.0.0
*/
public function edit_license( $payload ) {
$payload = $this->cleanup_license_payload( $this->filter_license_payload( $payload ) );
$payload = $this->cleanup_license_payload( $this->filter_license_payload( $payload ) );
/**
* Filter the payload used to edit a license record - after the payload has been cleaned up, before the payload has been validated.
* Fired during client license API request.
*
* @param array $payload Payload used to edit a license record
* @since 1.0.0
*/
$payload = apply_filters( 'upserv_edit_license_payload', $payload );
$validation = $this->validate_license_payload( $payload, true );
$return = $validation;
@ -221,13 +332,38 @@ class License_Server {
}
}

/**
* Fired after editing a license record.
* Fired during client license API request.
*
* @param mixed $return The result of the operation - a license record object or an array of errors
* @param array $payload The payload of the request
* @param mixed $original The original record to edit - a license record object or an array of errors
* @since 1.0.0
*/
do_action( 'upserv_did_edit_license', $return, $payload, $original );

return $return;
}

/**
* Add license
*
* Create a new license.
*
* @param array $payload The license data.
* @return object|WP_Error New license object or WP_Error on failure.
* @since 1.0.0
*/
public function add_license( $payload ) {
$payload = $this->build_license_payload( $payload );
$payload = $this->build_license_payload( $payload );
/**
* Filter the payload used to add a license record - after the payload has been cleaned up, before the payload has been validated.
* Fired during client license API request.
*
* @param array $payload Payload used to add a license record
* @since 1.0.0
*/
$payload = apply_filters( 'upserv_add_license_payload', $payload );
$validation = $this->validate_license_payload( $payload );
$return = $validation;
@ -266,13 +402,37 @@ class License_Server {
}
}

/**
* Fired after adding a license record.
* Fired during client license API request.
*
* @param mixed $return The result of the operation - a license record object or an array of errors
* @param array $payload The payload of the request
* @since 1.0.0
*/
do_action( 'upserv_did_add_license', $return, $payload );

return $return;
}

/**
* Delete license
*
* Remove a license from the system.
*
* @param array $payload The license identifier data.
* @return object|WP_Error Deleted license object or WP_Error on failure.
* @since 1.0.0
*/
public function delete_license( $payload ) {
$payload = $this->filter_license_payload( $payload );
$payload = $this->filter_license_payload( $payload );
/**
* Filter the payload used to delete a license record - after the payload has been cleaned up, before the payload has been validated.
* Fired during client license API request.
*
* @param array $payload Payload used to delete a license record
* @since 1.0.0
*/
$payload = apply_filters( 'upserv_delete_license_payload', $payload );
$validation = $this->validate_license_payload( $payload, true );
$return = $validation;
@ -305,11 +465,29 @@ class License_Server {
}
}

/**
* Fired after deleting a license record.
* Fired during client license API request.
*
* @param mixed $return The result of the operation - a license record object or an empty array
* @param array $payload The payload of the request
* @since 1.0.0
*/
do_action( 'upserv_did_delete_license', $return, $payload );

return $return;
}

/**
* Generate license signature
*
* Create a cryptographic signature for a license and domain.
*
* @param object $license The license object.
* @param string $domain The domain to generate signature for.
* @return string The generated signature.
* @since 1.0.0
*/
public function generate_license_signature( $license, $domain ) {
$hmac_key = $license->hmac_key;
$crypto_key = $license->crypto_key;
@ -319,6 +497,16 @@ class License_Server {
return $signature;
}

/**
* Is signature valid
*
* Verify if a license signature is valid.
*
* @param string $license_key The license key.
* @param string $license_signature The signature to validate.
* @return bool Whether the signature is valid.
* @since 1.0.0
*/
public function is_signature_valid( $license_key, $license_signature ) {
$valid = false;
$crypt = $license_signature;
@ -344,7 +532,15 @@ class License_Server {
in_array( $domain, $license->allowed_domains, true ) &&
$license->package_slug === $package_slug
) {
$valid = true;
/**
* Filter whether to bypass the license signature check.
* Fired during client license API request.
*
* @param bool $bypass Whether to bypass the license signature check
* @param object $license The license object
* @since 1.0.0
*/
$valid = apply_filters( 'upserv_license_bypass_signature', true, $license );
}
}
}
@ -352,6 +548,13 @@ class License_Server {
return $valid;
}

/**
* Switch expired licenses status
*
* Update status of licenses that have reached their expiry date.
*
* @since 1.0.0
*/
public function switch_expired_licenses_status() {
global $wpdb;

@ -401,6 +604,15 @@ class License_Server {
$item->data['operation'] = 'edit';
$item->data['operation_id'] = bin2hex( random_bytes( 16 ) );

/**
* Fired after editing a license record.
* Fired during client license API request.
*
* @param mixed $item The result of the operation - a license record object or an array of errors
* @param array $payload The payload of the request
* @param mixed $original The original record to edit - a license record object or an array of errors
* @since 1.0.0
*/
do_action(
'upserv_did_edit_license',
$item,
@ -414,6 +626,15 @@ class License_Server {
}
}

/**
* Update licenses status
*
* Bulk update status for multiple licenses.
*
* @param string $status The new status to set.
* @param array $license_ids Optional array of license IDs to update.
* @since 1.0.0
*/
public function update_licenses_status( $status, $license_ids = array() ) {
$license_query = array( 'limit' => '-1' );

@ -455,6 +676,15 @@ class License_Server {
$item->data['operation'] = 'edit';
$item->data['operation_id'] = bin2hex( random_bytes( 16 ) );

/**
* Fired after editing a license record.
* Fired during client license API request.
*
* @param mixed $item The result of the operation - a license record object or an array of errors
* @param array $payload The payload of the request
* @param mixed $original The original record to edit - a license record object or an array of errors
* @since 1.0.0
*/
do_action(
'upserv_did_edit_license',
$item,
@ -468,6 +698,14 @@ class License_Server {
}
}

/**
* Purge licenses
*
* Delete licenses from the database.
*
* @param array $license_ids Optional array of license IDs to delete.
* @since 1.0.0
*/
public function purge_licenses( $license_ids = array() ) {
$license_query = array( 'limit' => '-1' );

@ -509,6 +747,14 @@ class License_Server {
$item->data['operation'] = 'delete';
$item->data['operation_id'] = bin2hex( random_bytes( 16 ) );

/**
* Fired after deleting a license record.
* Fired during client license API request.
*
* @param mixed $item The result of the operation - a license record object or an empty array
* @param array $payload The payload of the request
* @since 1.0.0
*/
do_action( 'upserv_did_delete_license', $item, array( $item->license_key ) );
}
}
@ -518,6 +764,16 @@ class License_Server {
* Protected methods
*******************************************************************/

/**
* Build browsing query
*
* Construct a valid query structure for browsing licenses.
*
* @param array $payload The raw query parameters.
* @return array The processed query structure.
* @throws Exception If query parameters are invalid.
* @since 1.0.0
*/
protected function build_browsing_query( $payload ) {
$original = $payload;
$payload = array_intersect_key( $payload, self::$browsing_query );
@ -669,6 +925,15 @@ class License_Server {
return $payload;
}

/**
* Cleanup license payload
*
* Fill in default values for missing license data.
*
* @param array $payload The license data to clean up.
* @return array The processed license data.
* @since 1.0.0
*/
protected function cleanup_license_payload( $payload ) {

if ( isset( $payload['license_key'] ) && empty( $payload['license_key'] ) ) {
@ -688,14 +953,41 @@ class License_Server {
return $payload;
}

/**
* Filter license payload
*
* Remove any properties not in the license definition.
*
* @param array $payload The license data to filter.
* @return array The filtered license data.
* @since 1.0.0
*/
protected function filter_license_payload( $payload ) {
return is_array( $payload ) ? array_intersect_key( $payload, self::$license_definition ) : self::$license_definition;
}

/**
* Extend license payload
*
* Add default values for missing properties in license data.
*
* @param array $payload The license data to extend.
* @return array The extended license data.
* @since 1.0.0
*/
protected function extend_license_payload( $payload ) {
return array_merge( self::$license_definition, $payload );
}

/**
* Sanitize payload
*
* Clean and validate license data.
*
* @param array $license The license data to sanitize.
* @return array The sanitized license data.
* @since 1.0.0
*/
protected function sanitize_payload( $license ) {

foreach ( $license as $key => $value ) {
@ -749,6 +1041,16 @@ class License_Server {
return $license;
}

/**
* Validate license payload
*
* Check if license data is valid.
*
* @param array $license The license data to validate.
* @param bool $partial Whether to perform partial validation.
* @return bool|array True if valid, array of errors otherwise.
* @since 1.0.0
*/
protected function validate_license_payload( $license, $partial = false ) {
global $wpdb;

@ -835,7 +1137,20 @@ class License_Server {
! ( $partial && ! isset( $license['status'] ) ) &&
! in_array( $license['status'], self::$license_statuses, true )
) {
$errors['invalid_status'] = __( 'The license status is invalid.', 'updatepulse-server' );
/**
* Filter whether a license is valid when requesting for an update.
* Fired during client license API request.
*
* @param bool $is_valid Whether the license is valid
* @param mixed $license The license to validate
* @param string $license_signature The signature of the license
* @since 1.0.0
*/
$valid_status = apply_filters( 'upserv_license_valid', false, $license, '' );

if ( ! $valid_status ) {
$errors['invalid_status'] = __( 'The license status is invalid.', 'updatepulse-server' );
}
}

if (

View file

@ -9,13 +9,33 @@ if ( ! defined( 'ABSPATH' ) ) {
/**
* Cache class.
*
* Cache data to the filesystem.
* @since 1.0.0
*/
class Cache {

/**
* Cache directory path
*
* @var string
* @since 1.0.0
*/
protected $cache_directory;

/**
* File extension for cache files
*
* @var string
* @since 1.0.0
*/
protected $extension;

/**
* Constructor
*
* @param string $cache_directory Directory to store cache files.
* @param string $extension File extension for cache files. Default 'dat'.
* @since 1.0.0
*/
public function __construct( $cache_directory, $extension = 'dat' ) {
$this->cache_directory = $cache_directory;
$this->extension = $extension;
@ -24,8 +44,11 @@ class Cache {
/**
* Get cached value.
*
* @param string $key
* @return mixed|null
* Retrieves a value from the cache if it exists and hasn't expired.
*
* @param string $key Cache key identifier.
* @return mixed|null Cached value or null if not found or expired.
* @since 1.0.0
*/
public function get( $key ) {
$filename = $this->get_cache_filename( $key );
@ -53,10 +76,13 @@ class Cache {
/**
* Update the cache.
*
* @param string $key Cache key.
* Stores a value in the cache with the specified expiration time.
*
* @param string $key Cache key identifier.
* @param mixed $value The value to store in the cache.
* @param int $expiration Time until expiration, in seconds. Optional. Default `0`.
* @return void
* @since 1.0.0
*/
public function set( $key, $value, $expiration = 0 ) {
$cache = array(
@ -75,8 +101,11 @@ class Cache {
/**
* Clear the cache by key.
*
* @param string $key Cache key.
* Removes a specific cached value by its key.
*
* @param string $key Cache key identifier.
* @return void
* @since 1.0.0
*/
public function clear( $key ) {
$file = $this->get_cache_filename( $key );
@ -87,8 +116,13 @@ class Cache {
}

/**
* @param string $key
* @return string
* Get cache filename
*
* Constructs the full path to a cache file based on its key.
*
* @param string $key Cache key identifier.
* @return string Full path to the cache file.
* @since 1.0.0
*/
protected function get_cache_filename( $key ) {
return $this->cache_directory . '/' . $key . '.' . $this->extension;

View file

@ -12,6 +12,11 @@ use Countable;
use ArrayIterator;
use Traversable;

/**
* Headers class
*
* @since 1.0.0
*/
class Headers implements ArrayAccess, IteratorAggregate, Countable {

/**
@ -19,6 +24,7 @@ class Headers implements ArrayAccess, IteratorAggregate, Countable {
* These special headers don't have that prefix, so we need an explicit list to identify them.
*
* @var array
* @since 1.0.0
*/
protected static $unprefixed_names = array(
'CONTENT_TYPE',
@ -29,8 +35,24 @@ class Headers implements ArrayAccess, IteratorAggregate, Countable {
'AUTH_TYPE',
);

/**
* Headers collection
*
* Stores all HTTP headers.
*
* @var array
* @since 1.0.0
*/
protected $headers = array();

/**
* Constructor
*
* Initialize headers from provided array.
*
* @param array $headers Initial headers to set.
* @since 1.0.0
*/
public function __construct( $headers = array() ) {

foreach ( $headers as $name => $value ) {
@ -41,8 +63,9 @@ class Headers implements ArrayAccess, IteratorAggregate, Countable {
/**
* Extract HTTP headers from an array of data ( usually $_SERVER ).
*
* @param array $environment
* @return array
* @param array $environment Server environment variables.
* @return array Extracted HTTP headers.
* @since 1.0.0
*/
protected static function parse_server() {
$results = array();
@ -65,8 +88,9 @@ class Headers implements ArrayAccess, IteratorAggregate, Countable {
/**
* Check if a $_SERVER key looks like a HTTP header name.
*
* @param string $key
* @return bool
* @param string $key The key to check.
* @return bool Whether the key is a HTTP header name.
* @since 1.0.0
*/
protected static function is_header_name( $key ) {
return (
@ -80,7 +104,8 @@ class Headers implements ArrayAccess, IteratorAggregate, Countable {
* Parse headers for the current HTTP request.
* Will automatically choose the best way to get the headers from PHP.
*
* @return array
* @return array HTTP headers from the current request.
* @since 1.0.0
*/
public static function parse_current() {

@ -98,8 +123,9 @@ class Headers implements ArrayAccess, IteratorAggregate, Countable {
/**
* Convert a header name to "Title-Case-With-Dashes".
*
* @param string $name
* @return string
* @param string $name Header name to normalize.
* @return string Normalized header name.
* @since 1.0.0
*/
protected function normalize_name( $name ) {
$name = strtolower( $name );
@ -113,9 +139,10 @@ class Headers implements ArrayAccess, IteratorAggregate, Countable {
/**
* Check if a string starts with the given prefix.
*
* @param string $string
* @param string $prefix
* @return bool
* @param string $_string The string to check.
* @param string $prefix The prefix to look for.
* @return bool Whether the string starts with the prefix.
* @since 1.0.0
*/
protected static function starts_with( $_string, $prefix ) {
return ( substr( $_string, 0, strlen( $prefix ) ) === $prefix );
@ -126,7 +153,8 @@ class Headers implements ArrayAccess, IteratorAggregate, Countable {
*
* @param string $name Header name.
* @param mixed $_default The default value to return if the header doesn't exist.
* @return string|null
* @return string|null Header value or default if not found.
* @since 1.0.0
*/
public function get( $name, $_default = null ) {
$name = $this->normalize_name( $name );
@ -141,8 +169,9 @@ class Headers implements ArrayAccess, IteratorAggregate, Countable {
/**
* Set a header to value.
*
* @param string $name
* @param string $value
* @param string $name Header name.
* @param string $value Header value.
* @since 1.0.0
*/
public function set( $name, $value ) {
$name = $this->normalize_name( $name );
@ -151,34 +180,83 @@ class Headers implements ArrayAccess, IteratorAggregate, Countable {

/* ArrayAccess interface */

/**
* Check if header exists
*
* Implementation for ArrayAccess interface.
*
* @param mixed $offset The header name.
* @return bool Whether the header exists.
* @since 1.0.0
*/
#[\ReturnTypeWillChange]
public function offsetExists( $offset ): bool {
return array_key_exists( $offset, $this->headers );
}

/**
* Get header value
*
* Implementation for ArrayAccess interface.
*
* @param mixed $offset The header name.
* @return mixed The header value.
* @since 1.0.0
*/
#[\ReturnTypeWillChange]
public function offsetGet( $offset ): mixed {
return $this->get( $offset );
}

/**
* Set header value
*
* Implementation for ArrayAccess interface.
*
* @param mixed $offset The header name.
* @param mixed $value The header value.
* @since 1.0.0
*/
#[\ReturnTypeWillChange]
public function offsetSet( $offset, $value ): void {
$this->set( $offset, $value );
}

/**
* Unset header
*
* Implementation for ArrayAccess interface.
*
* @param mixed $offset The header name.
* @since 1.0.0
*/
#[\ReturnTypeWillChange]
public function offsetUnset( $offset ): void {
$name = $this->normalize_name( $offset );
unset( $this->headers[ $name ] );
}

/* Countable interface */
/**
* Count headers
*
* Implementation for Countable interface.
*
* @return int Number of headers.
* @since 1.0.0
*/
#[\ReturnTypeWillChange]
public function count(): int {
return count( $this->headers );
}

/* IteratorAggregate interface */
/**
* Get iterator for headers
*
* Implementation for IteratorAggregate interface.
*
* @return Traversable Iterator for headers.
* @since 1.0.0
*/
#[\ReturnTypeWillChange]
public function getIterator(): Traversable {
return new ArrayIterator( $this->headers );

View file

@ -10,16 +10,32 @@ if ( ! defined( 'ABSPATH' ) ) {
* This class represents the collection of files and metadata that make up
* a WordPress plugin or theme, or a generic software package.
*
* @since 1.0.0
*/
class Package {

/** @var string Path to the Zip archive that contains the package. */
/**
* Path to the Zip archive that contains the package.
*
* @var string
* @since 1.0.0
*/
protected $filename;

/** @var array Package metadata in a format suitable for the update checker. */
/**
* Package metadata in a format suitable for the update checker.
*
* @var array
* @since 1.0.0
*/
protected $metadata = array();

/** @var string Package slug. */
/**
* Package slug.
*
* @var string
* @since 1.0.0
*/
public $slug;

/**
@ -29,9 +45,10 @@ class Package {
* of instantiating this class directly. Still, you can do it if you want to, for example,
* load package metadata from the database instead of extracting it from a Zip file.
*
* @param string $slug
* @param string $filename
* @param array $metadata
* @param string $slug The package slug.
* @param string $filename The path to the package file.
* @param array $metadata The package metadata.
* @since 1.0.0
*/
public function __construct( $slug, $filename = null, $metadata = array() ) {
$this->slug = $slug;
@ -42,7 +59,8 @@ class Package {
/**
* Get the full file path of this package.
*
* @return string
* @return string The full file path of the package.
* @since 1.0.0
*/
public function get_filename() {
return $this->filename;
@ -52,7 +70,8 @@ class Package {
* Get package metadata.
*
* @see self::extractMetadata()
* @return array
* @return array The package metadata merged with the slug.
* @since 1.0.0
*/
public function get_metadata() {
return array_merge( $this->metadata, array( 'slug' => $this->slug ) );
@ -63,10 +82,11 @@ class Package {
*
* @param string $filename Path to a Zip archive that contains a package.
* @param string $slug Optional package slug. Will be detected automatically.
* @param Cache $cache
* @return Package
* @param Cache|null $cache Optional cache object for metadata.
* @return Package A new Package instance with the extracted metadata.
* @since 1.0.0
*/
public static function from_archive( $filename, $slug = null, Cache $cache = null ) {
public static function from_archive( $filename, $slug = null, $cache = null ) {
$meta_obj = new Zip_Metadata_Parser( $slug, $filename, $cache );
$metadata = $meta_obj->get();

@ -80,7 +100,8 @@ class Package {
/**
* Get the size of the package (in bytes).
*
* @return int
* @return int The size of the package file in bytes.
* @since 1.0.0
*/
public function get_file_size() {
return filesize( $this->filename );
@ -89,7 +110,8 @@ class Package {
/**
* Get the Unix timestamp of the last time this package was modified.
*
* @return int
* @return int The Unix timestamp when the package was last modified.
* @since 1.0.0
*/
public function get_last_modified() {
return filemtime( $this->filename );

View file

@ -8,29 +8,94 @@ if ( ! defined( 'ABSPATH' ) ) {

/**
* Simple request class for the update server.
*
* Handles incoming update requests, parsing parameters and headers.
*/
class Request {

/** @var array Query parameters. */
/**
* Query parameters
*
* @var array
* @since 1.0.0
*/
public $query = array();
/** @var string Client's IP address. */
/**
* Client's IP address
*
* @var string
* @since 1.0.0
*/
public $client_ip;
/** @var string The HTTP method, e.g. "POST" or "GET". */
/**
* The HTTP method
*
* @var string
* @since 1.0.0
*/
public $http_method;
/** @var string The name of the current action. For example, "get_metadata". */
/**
* The name of the current action
*
* @var string
* @since 1.0.0
*/
public $action;
/** @var string Package slug from the current request. */
/**
* Package slug from the current request
*
* @var string
* @since 1.0.0
*/
public $slug;
/** @var Package The package that matches the current slug, if any. */
/**
* The package that matches the current slug
*
* @var Package|null
* @since 1.0.0
*/
public $package = null;
/** @var string WordPress version number as extracted from the User-Agent header. */
/**
* WordPress version number
*
* @var string|null
* @since 1.0.0
*/
public $wp_version = null;
/** @var string WordPress site URL, also from the User-Agent. */
/**
* WordPress site URL
*
* @var string|null
* @since 1.0.0
*/
public $wp_site_url = null;
/**
* Request headers container
*
* @var Headers
* @since 1.0.0
*/
public $headers;

/** @var array Other, arbitrary request properties. */
/**
* Other, arbitrary request properties
*
* @var array
* @since 1.0.0
*/
protected $props = array();

/**
* Constructor
*
* Initialize a new request object with query parameters, headers and connection info.
*
* @param array $query Request query parameters.
* @param array $headers Request HTTP headers.
* @param string $client_ip Client's IP address, defaults to '0.0.0.0'.
* @param string $http_method HTTP method used for the request, defaults to 'GET'.
* @since 1.0.0
*/
public function __construct( $query, $headers, $client_ip = '0.0.0.0', $http_method = 'GET' ) {
$this->query = $query;
$this->headers = new Headers( $headers );
@ -58,9 +123,12 @@ class Request {
/**
* Get the value of a query parameter.
*
* @param string $name Parameter name.
* Safely retrieves a parameter from the query array with an optional default value.
*
* @param string $name Parameter name to retrieve.
* @param mixed $_default The value to return if the parameter doesn't exist. Defaults to null.
* @return mixed
* @return mixed The parameter value or default if not found.
* @since 1.0.0
*/
public function param( $name, $_default = null ) {

@ -71,6 +139,15 @@ class Request {
}
}

/**
* Magic getter for dynamic properties
*
* Retrieves dynamically stored properties from the props array.
*
* @param string $name Property name to retrieve.
* @return mixed The property value or null if not found.
* @since 1.0.0
*/
public function __get( $name ) {

if ( array_key_exists( $name, $this->props ) ) {
@ -80,14 +157,40 @@ class Request {
return null;
}

/**
* Magic setter for dynamic properties
*
* Sets values in the dynamic props array.
*
* @param string $name Property name to set.
* @param mixed $value Value to assign to the property.
* @since 1.0.0
*/
public function __set( $name, $value ) {
$this->props[ $name ] = $value;
}

/**
* Magic isset checker for dynamic properties
*
* Checks if a dynamic property exists in the props array.
*
* @param string $name Property name to check.
* @return bool Whether the property exists.
* @since 1.0.0
*/
public function __isset( $name ) {
return isset( $this->props[ $name ] );
}

/**
* Magic unset for dynamic properties
*
* Removes a property from the props array.
*
* @param string $name Property name to remove.
* @since 1.0.0
*/
public function __unset( $name ) {
unset( $this->props[ $name ] );
}

File diff suppressed because it is too large Load diff

View file

@ -14,14 +14,24 @@ use Anyape\UpdatePulse\Package_Parser\Parser;
class Zip_Metadata_Parser {

/**
* @var int $cache_time How long the package metadata should be cached in seconds.
* Defaults to 1 week ( 7 * 24 * 60 * 60 ).
*/
* Cache time
*
* How long the package metadata should be cached in seconds.
* Defaults to 1 week ( 7 * 24 * 60 * 60 ).
*
* @var int
* @since 1.0.0
*/
public static $cache_time = 604800;

/**
* @var array Package PHP header mapping, i.e. which tags to add to the metadata under which array key
*/
* Header map
*
* Package PHP header mapping, i.e. which tags to add to the metadata under which array key.
*
* @var array
* @since 1.0.0
*/
protected $header_map = array(
'Name' => 'name',
'Version' => 'version',
@ -36,49 +46,76 @@ class Zip_Metadata_Parser {
'Depends' => 'depends',
'Provides' => 'provides',
);

/**
* @var array Plugin readme file mapping, i.e. which tags to add to the metadata
*/
* Readme map
*
* Plugin readme file mapping, i.e. which tags to add to the metadata.
*
* @var array
* @since 1.0.0
*/
protected $readme_map = array(
'requires',
'tested',
'requires_php',
);

/**
* @var array Package info as retrieved by the parser
*/
* Package info
*
* Package info as retrieved by the parser.
*
* @var array
* @since 1.0.0
*/
protected $package_info;

/**
* @var string Path to the Zip archive that contains the package.
*/
* Filename
*
* Path to the Zip archive that contains the package.
*
* @var string
* @since 1.0.0
*/
protected $filename;

/**
* @var string Package slug.
*/
* Slug
*
* Package slug.
*
* @var string
* @since 1.0.0
*/
protected $slug;

/**
* @var Cache object.
*/
* Cache
*
* Cache object.
*
* @var object
* @since 1.0.0
*/
protected $cache;

/**
* @var array Package metadata in a format suitable for the update checker.
*/
* Metadata
*
* Package metadata in a format suitable for the update checker.
*
* @var array
* @since 1.0.0
*/
protected $metadata;


/**
* Get the metadata from a zip file.
*
* @param string $slug
* @param string $filename
* @param $cache
*/
* Constructor
*
* Get the metadata from a zip file.
*
* @param string $slug Package slug.
* @param string $filename Path to the Zip archive.
* @param object $cache Cache object.
* @since 1.0.0
*/
public function __construct( $slug, $filename, $cache = null ) {
$this->slug = $slug;
$this->filename = $filename;
@ -88,8 +125,15 @@ class Zip_Metadata_Parser {
}

/**
* Build the cache key (cache filename) for a file
*/
* Build cache key
*
* Build the cache key (cache filename) for a file.
*
* @param string $slug Package slug.
* @param string $filename Path to the Zip archive.
* @return string The cache key.
* @since 1.0.0
*/
public static function build_cache_key( $slug, $filename ) {
$cache_key = $slug . '-b64-';

@ -97,6 +141,15 @@ class Zip_Metadata_Parser {
$cache_key .= md5( $filename . '|' . filesize( $filename ) . '|' . filemtime( $filename ) );
}

/**
* Filter the cache key used for storing package metadata.
*
* @param string $cache_key The generated cache key for the package.
* @param string $slug The package slug.
* @param string $filename The path to the Zip archive.
* @return string The filtered cache key.
* @since 1.0.0
*/
return apply_filters(
'upserv_zip_metadata_parser_cache_key',
$cache_key,
@ -106,20 +159,27 @@ class Zip_Metadata_Parser {
}

/**
* Get metadata.
*
* @return array
*/
* Get metadata
*
* Get the package metadata.
*
* @return array Package metadata.
* @since 1.0.0
*/
public function get() {
return $this->metadata;
}

/**
* Load metadata information from a cache or create it.
*
* We'll try to load processed metadata from the cache first (if available), and if that
* fails we'll extract package details from the specified Zip file.
*/
* Set metadata
*
* Load metadata information from a cache or create it.
*
* We'll try to load processed metadata from the cache first (if available), and if that
* fails we'll extract package details from the specified Zip file.
*
* @since 1.0.0
*/
protected function set_metadata() {
$cache_key = self::build_cache_key( $this->slug, $this->filename );

@ -151,11 +211,14 @@ class Zip_Metadata_Parser {
}

/**
* Extract package headers and readme contents from a ZIP file and convert them
* into a structure compatible with the custom update checker.
*
* @throws Invalid_Package_Exception if the input file can't be parsed as a package.
*/
* Extract metadata
*
* Extract package headers and readme contents from a ZIP file and convert them
* into a structure compatible with the custom update checker.
*
* @throws Invalid_Package_Exception if the input file can't be parsed as a package.
* @since 1.0.0
*/
protected function extract_metadata() {
$this->package_info = Parser::parse_package( $this->filename, true );

@ -177,8 +240,12 @@ class Zip_Metadata_Parser {
}

/**
* Extract relevant metadata from the package header information
*/
* Set info from header
*
* Extract relevant metadata from the package header information.
*
* @since 1.0.0
*/
protected function set_info_from_header() {

if ( isset( $this->package_info['header'] ) && ! empty( $this->package_info['header'] ) ) {
@ -188,8 +255,12 @@ class Zip_Metadata_Parser {
}

/**
* Extract relevant metadata from the plugin readme
*/
* Set info from readme
*
* Extract relevant metadata from the plugin readme.
*
* @since 1.0.0
*/
protected function set_info_from_readme() {

if ( ! empty( $this->package_info['readme'] ) ) {
@ -202,15 +273,18 @@ class Zip_Metadata_Parser {
}

/**
* Extract selected metadata from the retrieved package info
*
* @see http://codex.wordpress.org/File_Header
* @see https://wordpress.org/plugins/about/readme.txt
*
* @param array $input The package info sub-array to use to retrieve the info from
* @param array $map The key mapping for that sub-array where the key is the key as used in the
* input array and the value is the key to use for the output array
*/
* Set mapped fields
*
* Extract selected metadata from the retrieved package info.
*
* @see http://codex.wordpress.org/File_Header
* @see https://wordpress.org/plugins/about/readme.txt
*
* @param array $input The package info sub-array to use to retrieve the info from.
* @param array $map The key mapping for that sub-array where the key is the key as used in the
* input array and the value is the key to use for the output array.
* @since 1.0.0
*/
protected function set_mapped_fields( $input, $map ) {

foreach ( $map as $field_key => $meta_key ) {
@ -222,12 +296,16 @@ class Zip_Metadata_Parser {
}

/**
* Determine the details url for themes
*
* Theme metadata should include a "details_url" that specifies the page to display
* when the user clicks "View version x.y.z details". If the developer didn't provide
* it by setting the "Details URI" header, we'll default to the theme homepage ( "Theme URI" ).
*/
* Set theme details URL
*
* Determine the details url for themes.
*
* Theme metadata should include a "details_url" that specifies the page to display
* when the user clicks "View version x.y.z details". If the developer didn't provide
* it by setting the "Details URI" header, we'll default to the theme homepage ( "Theme URI" ).
*
* @since 1.0.0
*/
protected function set_theme_details_url() {

if (
@ -239,10 +317,13 @@ class Zip_Metadata_Parser {
}

/**
* Extract the texual information sections from a readme file
*
* @see https://wordpress.org/plugins/about/readme.txt
*/
* Set readme sections
*
* Extract the texual information sections from a readme file.
*
* @see https://wordpress.org/plugins/about/readme.txt
* @since 1.0.0
*/
protected function set_readme_sections() {

if (
@ -263,10 +344,13 @@ class Zip_Metadata_Parser {
}

/**
* Extract the upgrade notice for the current version from a readme file
*
* @see https://wordpress.org/plugins/about/readme.txt
*/
* Set readme upgrade notice
*
* Extract the upgrade notice for the current version from a readme file.
*
* @see https://wordpress.org/plugins/about/readme.txt
* @since 1.0.0
*/
protected function set_readme_upgrade_notice() {

//Check if we have an upgrade notice for this version
@ -282,8 +366,12 @@ class Zip_Metadata_Parser {
}

/**
* Add last update date to the metadata ; this is tied to the version
*/
* Set last update date
*
* Add last update date to the metadata; this is tied to the version.
*
* @since 1.0.0
*/
protected function set_last_update_date() {

if ( isset( $this->metadata['last_updated'] ) ) {
@ -309,10 +397,24 @@ class Zip_Metadata_Parser {
$this->metadata['last_updated'] = $meta['version_time'];
}

/**
* Set type
*
* Set the package type in the metadata.
*
* @since 1.0.0
*/
protected function set_type() {
$this->metadata['type'] = $this->package_info['type'];
}

/**
* Set slug
*
* Set the package slug in the metadata.
*
* @since 1.0.0
*/
protected function set_slug() {

if ( 'plugin' === $this->package_info['type'] ) {
@ -327,8 +429,12 @@ class Zip_Metadata_Parser {
}

/**
* Extract icons and banners info for plugins
*/
* Set info from assets
*
* Extract icons and banners info for plugins.
*
* @since 1.0.0
*/
protected function set_info_from_assets() {

if ( ! empty( $this->package_info['extra'] ) ) {

View file

@ -9,13 +9,43 @@ if ( ! defined( 'ABSPATH' ) ) {
use WP_List_Table;
use Anyape\Utils\Utils;

/**
* Licenses table class
*
* @since 1.0.0
*/
class Licenses_Table extends WP_List_Table {

/**
* Bulk action error
*
* @var mixed
* @since 1.0.0
*/
public $bulk_action_error;
/**
* Nonce action name
*
* @var string
* @since 1.0.0
*/
public $nonce_action;

/**
* Table rows data
*
* @var array
* @since 1.0.0
*/
protected $rows;

/**
* Constructor
*
* Sets up the table properties and hooks
*
* @since 1.0.0
*/
public function __construct() {
parent::__construct(
array(
@ -34,6 +64,14 @@ class Licenses_Table extends WP_List_Table {

// Overrides ---------------------------------------------------

/**
* Get table columns
*
* Define the columns for the licenses table
*
* @return array The table columns
* @since 1.0.0
*/
public function get_columns() {
return array(
'cb' => '<input type="checkbox" />',
@ -48,10 +86,28 @@ class Licenses_Table extends WP_List_Table {
);
}

/**
* Default column rendering
*
* Default handler for displaying column data
*
* @param array $item The row item
* @param string $column_name The column name
* @return mixed The column value
* @since 1.0.0
*/
public function column_default( $item, $column_name ) {
return $item[ $column_name ];
}

/**
* Get sortable columns
*
* Define which columns can be sorted
*
* @return array The sortable columns
* @since 1.0.0
*/
public function get_sortable_columns() {
return array(
'col_status' => array( 'status', false ),
@ -64,6 +120,13 @@ class Licenses_Table extends WP_List_Table {
);
}

/**
* Prepare table items
*
* Query the database and set up the items for display
*
* @since 1.0.0
*/
public function prepare_items() {
global $wpdb;

@ -174,6 +237,13 @@ class Licenses_Table extends WP_List_Table {
$this->items = $items;
}

/**
* Display table rows
*
* Output the HTML for each row in the table
*
* @since 1.0.0
*/
public function display_rows() {
$records = $this->items;
$table = $this;
@ -214,6 +284,14 @@ class Licenses_Table extends WP_List_Table {

// Misc. -------------------------------------------------------

/**
* Set table rows
*
* Set the row data for the table
*
* @param array $rows The rows data
* @since 1.0.0
*/
public function set_rows( $rows ) {
$this->rows = $rows;
}
@ -224,6 +302,16 @@ class Licenses_Table extends WP_List_Table {

// Overrides ---------------------------------------------------

/**
* Generate row actions
*
* Create action links for each row
*
* @param array $actions The actions array
* @param bool $always_visible Whether actions should be always visible
* @return string HTML for the row actions
* @since 1.0.0
*/
protected function row_actions( $actions, $always_visible = false ) {
$action_count = count( $actions );
$i = 0;
@ -248,6 +336,14 @@ class Licenses_Table extends WP_List_Table {
return $out;
}

/**
* Display extra tablenav
*
* Add additional controls above or below the table
*
* @param string $which The location ('top' or 'bottom')
* @since 1.0.0
*/
protected function extra_tablenav( $which ) {

if ( 'bottom' === $which ) {
@ -255,6 +351,14 @@ class Licenses_Table extends WP_List_Table {
}
}

/**
* Get bulk actions
*
* Define available bulk actions for the table
*
* @return array The available bulk actions
* @since 1.0.0
*/
protected function get_bulk_actions() {
$actions = array(
'pending' => __( 'Set to Pending', 'updatepulse-server' ),
@ -268,6 +372,14 @@ class Licenses_Table extends WP_List_Table {
return $actions;
}

/**
* Get table classes
*
* Define CSS classes for the table
*
* @return array The table CSS classes
* @since 1.0.0
*/
protected function get_table_classes() {
$mode = get_user_setting( 'posts_list_mode', 'list' );


View file

@ -9,14 +9,51 @@ if ( ! defined( 'ABSPATH' ) ) {
use WP_List_Table;
use DateTimeZone;

/**
* Packages Table class
*
* Manages the display of packages in the admin area
*
* @since 1.0.0
*/
class Packages_Table extends WP_List_Table {

/**
* Bulk action error message
*
* @var string|null
* @since 1.0.0
*/
public $bulk_action_error;
/**
* Nonce action name
*
* @var string
* @since 1.0.0
*/
public $nonce_action;

/**
* Table rows data
*
* @var array
* @since 1.0.0
*/
protected $rows;
/**
* Package manager instance
*
* @var object
* @since 1.0.0
*/
protected $package_manager;

/**
* Constructor
*
* @param object $package_manager The package manager instance
* @since 1.0.0
*/
public function __construct( $package_manager ) {
parent::__construct(
array(
@ -36,7 +73,22 @@ class Packages_Table extends WP_List_Table {

// Overrides ---------------------------------------------------

/**
* Get table columns
*
* Define the columns shown in the packages table.
*
* @return array Table columns
* @since 1.0.0
*/
public function get_columns() {
/**
* Filter the columns shown in the packages table.
*
* @param array $columns The default columns for the packages table
* @return array The filtered columns
* @since 1.0.0
*/
$columns = apply_filters(
'upserv_packages_table_columns',
array(
@ -54,11 +106,36 @@ class Packages_Table extends WP_List_Table {
return $columns;
}

/**
* Default column renderer
*
* Default handler for columns without specific renderers.
*
* @param array $item The current row item
* @param string $column_name The current column name
* @return mixed Column content
* @since 1.0.0
*/
public function column_default( $item, $column_name ) {
return $item[ $column_name ];
}

/**
* Get sortable columns
*
* Define which columns can be sorted in the table.
*
* @return array Sortable columns configuration
* @since 1.0.0
*/
public function get_sortable_columns() {
/**
* Filter the sortable columns in the packages table.
*
* @param array $columns The default sortable columns
* @return array The filtered sortable columns
* @since 1.0.0
*/
$columns = apply_filters(
'upserv_packages_table_sortable_columns',
array(
@ -75,6 +152,13 @@ class Packages_Table extends WP_List_Table {
return $columns;
}

/**
* Prepare table items
*
* Process data for table display including pagination.
*
* @since 1.0.0
*/
public function prepare_items() {
$total_items = count( $this->rows );
$offset = 0;
@ -111,7 +195,13 @@ class Packages_Table extends WP_List_Table {
uasort( $this->items, array( &$this, 'uasort_reorder' ) );
}


/**
* Display table rows
*
* Render the rows of the packages table.
*
* @since 1.0.0
*/
public function display_rows() {
$records = $this->items;
$table = $this;
@ -206,10 +296,28 @@ class Packages_Table extends WP_List_Table {

// Misc. -------------------------------------------------------

/**
* Set table rows
*
* Set the rows data for the table.
*
* @param array $rows Table rows data
* @since 1.0.0
*/
public function set_rows( $rows ) {
$this->rows = $rows;
}

/**
* Custom sorting function
*
* Sort table items based on request parameters.
*
* @param array $a First item to compare
* @param array $b Second item to compare
* @return int Comparison result
* @since 1.0.0
*/
public function uasort_reorder( $a, $b ) {
$order_by = ! empty( $_REQUEST['orderby'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ) ) : 'name'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$order = ! empty( $_REQUEST['order'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) : 'asc'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
@ -242,6 +350,14 @@ class Packages_Table extends WP_List_Table {

// Overrides ---------------------------------------------------

/**
* Display extra table navigation
*
* Add additional controls above or below the table.
*
* @param string $which Position ('top' or 'bottom')
* @since 1.0.0
*/
protected function extra_tablenav( $which ) {

if ( 'top' === $which ) {
@ -258,6 +374,14 @@ class Packages_Table extends WP_List_Table {
}
}

/**
* Get table CSS classes
*
* Define the CSS classes for the table.
*
* @return array Table CSS classes
* @since 1.0.0
*/
protected function get_table_classes() {
$mode = get_user_setting( 'posts_list_mode', 'list' );
$mode_class = esc_attr( 'table-view-' . $mode );
@ -265,7 +389,22 @@ class Packages_Table extends WP_List_Table {
return array( 'widefat', 'striped', $mode_class, $this->_args['plural'] );
}

/**
* Get bulk actions
*
* Define available bulk actions for the table.
*
* @return array Bulk actions
* @since 1.0.0
*/
protected function get_bulk_actions() {
/**
* Filter the bulk actions available in the packages table.
*
* @param array $actions The default bulk actions
* @return array The filtered bulk actions
* @since 1.0.0
*/
$actions = apply_filters(
'upserv_packages_table_bulk_actions',
array(
@ -277,6 +416,15 @@ class Packages_Table extends WP_List_Table {
return $actions;
}

/**
* Get VCS icon class
*
* Get the appropriate icon class for a VCS provider.
*
* @param array $vcs_config VCS configuration
* @return string CSS class for the VCS icon
* @since 1.0.0
*/
protected function get_vcs_class( $vcs_config ) {

switch ( $vcs_config['type'] ) {

View file

@ -52,7 +52,7 @@
printf(
// translators: %s is <code>upserv_download_remote_package( string $package_slug, string $type );</code>
esc_html__( '[expert] calling the %s method in your own code, with the VCS-related parameters corresponding to a VCS configuration saved in UpdatePulse Server', 'updatepulse-server' ),
'<code>upserv_download_remote_package( string $package_slug, string $type, string $vcs_url = false, string branch = \'main\');</code>'
'<code>upserv_download_remote_package( string $package_slug, string $type, string $vcs_url = false, string branch = \'main\' );</code>'
);
?>
</li>
@ -285,7 +285,7 @@ Licensed With: another-plugin-or-theme-slug</pre><br>
// translators: %1$s is a link to opening an issue, %2$s is a contact email
esc_html__( 'After reading the documentation, for more help on how to use UpdatePulse Server, please %1$s - bugfixes are welcome via pull requests, detailed bug reports with accurate pointers as to where and how they occur in the code will be addressed in a timely manner, and a fee will apply for any other request (if they are addressed). If and only if you found a security issue, please contact %2$s with full details for responsible disclosure.', 'updatepulse-server' ),
'<a target="_blank" href="https://github.com/anyape/updatepulse-server/issues">' . esc_html__( 'open an issue on Github', 'updatepulse-server' ) . '</a>',
'<a href="mailto:updatepulse@anyape.come">updatepulse@anyape.com</a>',
'<a href="mailto:updatepulse@anyape.com">updatepulse@anyape.com</a>',
);
?>
</p>

View file

@ -107,7 +107,16 @@
<td>
<input class="vcs-setting regular-text" type="text" id="upserv_vcs_branch" data-prop="branch" value="">
<p class="description">
<?php esc_html_e( 'The branch to download when getting remote packages from the Version Control System.', 'updatepulse-server' ); ?>
<?php
printf(
// translators: %1$s line break, %2$s is <code>PUC_FORCE_BRANCH</code>, %3$s is <code>true</code>, %4$s is <code>wp-config.php</code>
esc_html__( 'The branch to download when getting remote packages from the Version Control System.%1$sIf the VCS supports releases or tags, they will be prioritised over the branch name (release first, then tag, then branch).%1$sTo bypass this behaviour and exclusively rely on the branch, set the %2$s constant to %3$s in %4$s.', 'updatepulse-server' ),
'<br/>',
'<code>PUC_FORCE_BRANCH</code>',
'<code>true</code>',
'<code>wp-config.php</code>'
);
?>
</p>
</td>
</tr>
@ -295,7 +304,16 @@
<td>
<input class="regular-text" type="text" id="upserv_add_vcs_branch" data-prop="branch" value="">
<p class="description">
<?php esc_html_e( 'The branch to download when getting remote packages from the Version Control System.', 'updatepulse-server' ); ?>
<?php
printf(
// translators: %1$s line break, %2$s is <code>PUC_FORCE_BRANCH</code>, %3$s is <code>true</code>, %4$s is <code>wp-config.php</code>
esc_html__( 'The branch to download when getting remote packages from the Version Control System.%1$sIf the VCS supports releases or tags, they will be prioritised over the branch name (release first, then tag, then branch).%1$sTo bypass this behaviour and exclusively rely on the branch, set the %2$s constant to %3$s in %4$s.', 'updatepulse-server' ),
'<br/>',
'<code>PUC_FORCE_BRANCH</code>',
'<code>true</code>',
'<code>wp-config.php</code>'
);
?>
</p>
</td>
</tr>

File diff suppressed because it is too large Load diff

View file

@ -84,7 +84,10 @@ if ( ! class_exists( BitbucketApi::class, false ) ) :
return $this->get_branch( $config_branch );
};

if ( ( 'main' === $config_branch || 'master' === $config_branch ) ) {
if (
( 'main' === $config_branch || 'master' === $config_branch ) &&
( ! defined( 'PUC_FORCE_BRANCH' ) || ! (bool) ( constant( 'PUC_FORCE_BRANCH' ) ) )
) {
$strategies[ self::STRATEGY_LATEST_TAG ] = array( $this, 'get_latest_tag' );
}


View file

@ -458,7 +458,10 @@ if ( ! class_exists( GitHubApi::class, false ) ) :
protected function get_update_detection_strategies( $config_branch ) {
$strategies = array();

if ( 'main' === $config_branch || 'master' === $config_branch ) {
if (
( 'main' === $config_branch || 'master' === $config_branch ) &&
( ! defined( 'PUC_FORCE_BRANCH' ) || ! (bool) ( constant( 'PUC_FORCE_BRANCH' ) ) )
) {
// Use the latest release.
$strategies[ self::STRATEGY_LATEST_RELEASE ] = array( $this, 'get_latest_release' );
// Failing that, use the tag with the highest version number.

View file

@ -440,7 +440,10 @@ if ( ! class_exists( GitLabApi::class, false ) ) :
protected function get_update_detection_strategies( $config_branch ) {
$strategies = array();

if ( ( 'main' === $config_branch ) || ( 'master' === $config_branch ) ) {
if (
( 'main' === $config_branch ) || ( 'master' === $config_branch ) &&
( ! defined( 'PUC_FORCE_BRANCH' ) || ! (bool) ( constant( 'PUC_FORCE_BRANCH' ) ) )
) {
$strategies[ self::STRATEGY_LATEST_RELEASE ] = array( $this, 'get_latest_release' );
$strategies[ self::STRATEGY_LATEST_TAG ] = array( $this, 'get_latest_tag' );
}

View file

@ -1,9 +1,10 @@
=== UpdatePulse Server ===
Contributors: frogerme
Donate link: https://paypal.me/frogerme
Tags: Plugin updates, Theme updates, WordPress updates, License
Requires at least: 6.7
Tested up to: 6.7
Stable tag: 1.0.5
Stable tag: 1.0.10
Requires PHP: 8.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-3.0.html
@ -46,6 +47,78 @@ This plugin adds the following major features to WordPress:
* **API:** UpdatePulse Server provides APIs to manage packages and licenses. The APIs keys are secured with a system of tokens: the API keys are never shared over the network, acquiring a token requires signed payloads, and the tokens have a limited lifetime. For more details about tokens and security, see [the Nonce API documentation](https://github.com/anyape/updatepulse-server/blob/main/docs/misc.md#nonce-api).

To connect their plugins or themes and UpdatePulse Server, developers can find integration examples in the [UpdatePulse Server Integration Examples](https://github.com/Anyape/updatepulse-server-integration) repository - theme and plugin examples rely heavily on the popular [Plugin Update Checker](https://github.com/YahnisElsts/plugin-update-checker) by [Yahnis Elsts](https://github.com/YahnisElsts).
== Companion Plugins ==

The following plugins are compatible with UpdatePulse Server and can be used to extend its functionality:
* [Updatepulse Blocks](https://store.anyape.com/product/updatepulse-blocks/?wl=1): a seamless way to display packages from UpdatePulse Server directly within your site using the WordPress Block Editor or shortcodes.
* [UpdatePulse for WooCommerce](https://store.anyape.com/product/updatepulse-for-woocommerce/?wl=1): a WooCommerce connector for UpdatePulse Server, allowing you to sell licensed packages through your WooCommerce store, either on the same WordPress installation or a separate store site.

Developers are encouraged to build plugins and themes [integrated](https://github.com/anyape/updatepulse-server/blob/main/README.md) with UpdatePulse Server, leveraging its publicly available functions, actions and filters, or by making use of the provided APIs.

If you wish to see your plugin added to this list, please [contact the author](mailto:updatepulse@anyape.com).

== Troubleshooting ==

Please read the plugin FAQ, there is a lot that may help you there!

UpdatePulse Server is regularly updated for compatibility, and bug reports are welcome, preferably on [Github](https://github.com/anyape/updatepulse-server/). Pull Requests from developers following the [WordPress Coding Standards](https://github.com/WordPress/WordPress-Coding-Standards) (`WordPress-Extra` ruleset) are highly appreciated and will be credited upon merge.

In case the plugin has not been updated for a while, no panic: it simply means the compatibility flag has not been changed, and it very likely remains compatible with the latest version of WordPress. This is because it was designed with long-term compatibility in mind from the ground up.

Each **bug** report will be addressed in a timely manner if properly documented previously unanswered general inquiries and issues reported on the WordPress forum may take significantly longer to receive a response (if any).

**Only issues occurring with WordPress core, WooCommerce, and default WordPress themes (incl. WooCommerce Storefront) will be considered.**

**Troubleshooting involving 3rd-party plugins or themes will not be addressed on the WordPress support forum.**

== Upgrade Notice ==

= 1.0.9 =

For installations using VCS in schedule mode (as opposed to webhook mode):
- delete all packages and re-register them
- remove any remaining `json` files from `wp-content/uploads/updatepulse-server/metadata` folder
- use the "Force Clear & Reschedule" button in the VCS settings

== FAQ ==

= How do I use UpdatePulse Server? =
UpdatePulse Server is a plugin for developers, not end-users. It allows developers to provide updates for their software packages, including WordPress plugins and themes. For more information on how to use it, please refer to the [documentation](https://github.com/anyape/updatepulse-server/blob/main/README.md).

= How do I connect my plugin/theme to UpdatePulse Server? =
To connect your plugin or theme to UpdatePulse Server, you can either use one of the integration examples provided in the [UpdatePulse Server Integration Examples](https://github.com/Anyape/updatepulse-server-integration), or develop your own on top of [Plugin Update Checker](https://github.com/YahnisElsts/plugin-update-checker).

If you decide to develop your own, the key is to call the [UpdatePulse Server Update API](https://github.com/anyape/updatepulse-server/blob/main/docs/misc.md#update-api) to check for updates, with the necessary information in the request. The API will return a JSON response with the update information, which you can then use to display the update notification, check for a license for your plugin or theme, and download the update package.

= How does the license system work? =
The license system allows developers to manage licenses for their software packages. Licenses prevent packages from being updated without a valid license. License Keys are generated automatically by default and the values are unguessable (it is recommended to keep the default). When checking the validity of licenses, an extra license signature is also checked to prevent the use of a license on more than the configured allowed domains.

= How do I manage packages? =
You can manage packages through the UpdatePulse Server interface, through the API, or by letting the plugin download them automatically from a Version Control System (preferred). The interface allows you to view a listing of packages, view details, delete, download, and upload new packages manually (discouraged).

= I have a problem with the plugin, what should I do? =
If you have a problem with the plugin, please check the FAQ and the documentation first.

Then, make sure to flush your WordPress permalinks (Settings > Permalinks > Save Changes), clear your browser cache, and clear any caching plugins you may have installed. If you are using a CDN, make sure to clear the cache there as well.

Make sure you are not trying to update a package installed alongside UpdatePulse Server - the package must be installed on a different WordPress installation.

If you still have a problem, please open an issue on [GitHub](https://github.com/Anyape/updatepulse-server/issues) with a **detailed description of the problem**, including any **error messages you are receiving**, and **most importantly, the steps to reproduce the issue, in details**.

Only issues occurring with WordPress core, WooCommerce, and default WordPress themes (incl. WooCommerce Storefront) will be considered: integration with 3rd-party plugins or themes will only be addressed if you can provide a patch in a pull request, and if this makes sense for the author. If not, please either contact the author of the plugin/theme you are having issues with, or provide your own integration with a custom plugin.

= How can I sell package licenses? =
UpdatePulse Server does not provide a built-in way to sell licenses. To sell licenses, your chosen e-commerce solution must be integrated with UpdatePulse Server License API. This can be done by creating a custom plugin that connects your e-commerce solution with UpdatePulse Server License API, or by using an existing integration if available. At this time, there is no official e-commerce integration plugin for UpdatePulse Server.

= Is UpdatePulse Server compatible with X Plugin/Theme? with multisite? =

UpdatePulse Server by itself does not provide any frontend functionality to your users.

As a general rule, the more isolated UpdatePulse Server is from the rest of your ecosystem, the better, as it allows the server to perform without interference: it is not meant to be used alongside other plugins or themes, but more as a standalone server.

UpdatePulse Server is not meant to be used in a multisite environment either: it is a server delivering packages and licenses to clients, and has no place in a multisite environment.

If you still decide to use UpdatePulse Server on a website not solely dedicated to it, it is still possible ; to avoid interference, you may want to add the MU Plugin `upserv-plugins-optimizer.php` provided in the [UpdatePulse Server Integration](https://github.com/Anyape/updatepulse-server-integration) repository to bypass plugins and themes when calling the UpdatePulse Server APIs.

== Screenshots ==

@ -65,6 +138,28 @@ This section describes how to install the plugin and get it working.

== Changelog ==

= 1.0.10 =
* Introduce constant `PUC_FORCE_BRANCH` to bypass tags & releases in VCS detection strategies
* Minor fix
* Fix activation issue - `WP_Filesystem` call

= 1.0.9 =
* Schedule mode: remove package metadata files when deleting packages
* Schedule mode: make sure to reinitialise the update checker to avoid slug conflicts

= 1.0.8 =
* Fix scheduled mode package overrides. After update, if using this mode: delete all packages and re-register them ; remove any remaining `json` files from `wp-content/uploads/updatepulse-server/metadata` folder ; use the "Force Clear & Reschedule" button in the VCS settings
* Fix VCS candidates with webhook mode

= 1.0.7 =
* Full documentation of all classes and functions

= 1.0.6 =
* Fix webhook payload handling (thanks @eHtmlu on github)
* Fix webhook payload scheduling (thanks @BabaYaga0179 on github)
* Implement a VCS candidates logic to handle events that do not specify a branch; gracefully fail with a message in the response if multiple candidates are found
* Major in-code and .md documentation improvements

= 1.0.5 =
* Fix JSON details modal view - escaping characters
* Make sure to differenciate between `file_last_modified` ("File Modified", the time the file was changed on the file system) and `last_updated` (package version update time)

View file

@ -3,7 +3,7 @@
* Plugin Name: UpdatePulse Server
* Plugin URI: https://github.com/anyape/updatepulse-server/
* Description: Run your own update server.
* Version: 1.0.5
* Version: 1.0.10
* Author: Alexandre Froger
* Author URI: https://froger.me/
* License: GPLv2 or later