File: /wordpress/plugins/wp-cloud-client/1.0.1/src/Multisite/SubsiteLimit.php
<?php
declare(strict_types=1);
namespace VPlugins\WPCloudClient\Multisite;
use VPlugins\WPCloudClient\Settings\SettingsRepository;
/**
* Enforces subsite quotas on a WordPress multisite network.
*
* Quota = partner base limit + WSP_SUBSCRIBED_COUNT add-on seats from Atomic_Persistent_Data.
*
* Features:
* - Blocks new site creation via UI and server-side when quota is reached.
* - Shows a quota banner on Network Admin > Sites and Add New Site.
* - Disables the "Activate" row action for deactivated sites when at limit.
* - Auto-deactivates the newest over-limit sites on every request.
* - Renames "Deleted" → "Deactivated" in the network admin sites list.
*/
final class SubsiteLimit {
/**
* Construct with the per-site settings repository (used for partner/site ID).
*
* @param SettingsRepository $settings Per-site settings repository.
*/
public function __construct(
private readonly SettingsRepository $settings,
) {}
/**
* Register WordPress hooks.
*
* @return void
*/
public function register(): void {
// Restrict site creation — UI notice + submit disable.
add_action( 'network_site_new_form', [ $this, 'restrictNewSiteCreationCheck' ], 10 );
// Restrict site creation — server-side delete if over limit.
add_action( 'wpmu_create_blog', [ $this, 'restrictNewSiteCreationServerSide' ], 10, 1 );
// Quota notice in Network Admin.
add_action( 'network_admin_notices', [ $this, 'showSubsiteQuotaNotice' ] );
// Disable Activate row action when at limit.
add_filter( 'manage_sites_action_links', [ $this, 'maybeDisableActivateAction' ], 10, 3 );
// Rename "Deleted" → "Deactivated" in view tab labels.
add_filter( 'views_sites-network', [ $this, 'renameDeletedViewLabel' ], 10, 1 );
// Rename "Deleted" → "Deactivated" in translated strings on sites screen.
add_filter( 'gettext', [ $this, 'renameDeletedStringOnSitesScreen' ], 10, 3 );
// Auto-enforce limit on every request (priority 1 so it runs early).
add_action( 'init', [ $this, 'enforceSiteLimitOnLoad' ], 1 );
}
// -------------------------------------------------------------------------
// Site creation restriction
// -------------------------------------------------------------------------
/**
* Display a UI notice on the Add New Site screen and disable the submit
* button when the quota is already reached.
*
* Hooked to: network_site_new_form
*
* @return void
*/
public function restrictNewSiteCreationCheck(): void {
$count = $this->getSubsiteCount();
$limit = $this->getMultisiteLimit();
if ( 0 === $limit ) {
return; // 0 = unlimited.
}
$remaining = max( 0, $limit - $count );
if ( $count >= $limit ) {
?>
<script>
jQuery(document).ready(function($) {
$('input[type=submit]').attr('disabled', 'disabled');
$('input[type=submit]').after(
'<p style="color:red;"><?php echo esc_js( sprintf( /* translators: %d: max site count */ __( 'You have reached the maximum limit of %d sites.', 'wp-cloud-client' ), $limit ) ); ?></p>'
);
});
</script>
<?php
} else {
echo '<p style="color:red;">' . esc_html(
sprintf(
/* translators: %d: number of sites remaining */
__( 'Sites remaining: %d', 'wp-cloud-client' ),
$remaining
)
) . '</p>';
}
}
/**
* Delete a newly created blog if the network is already over quota.
*
* Hooked to: wpmu_create_blog
*
* @param int $blogId The newly created blog ID.
* @return void
*/
public function restrictNewSiteCreationServerSide( int $blogId ): void {
$count = $this->getSubsiteCount();
$limit = $this->getMultisiteLimit();
if ( 0 === $limit ) {
return; // 0 = unlimited.
}
if ( $count > $limit ) {
wpmu_delete_blog( $blogId, true );
wp_die(
esc_html__( 'You have reached the maximum number of sites allowed on this network.', 'wp-cloud-client' ),
esc_html__( 'Site limit reached', 'wp-cloud-client' ),
[ 'response' => 403 ]
);
}
}
// -------------------------------------------------------------------------
// Quota notice
// -------------------------------------------------------------------------
/**
* Show a quota notice banner in Network Admin on the Sites and Add New
* Site screens.
*
* Hooked to: network_admin_notices
*
* @return void
*/
public function showSubsiteQuotaNotice(): void {
if ( ! function_exists( 'get_current_screen' ) ) {
return;
}
$screen = get_current_screen();
if ( ! $screen || ! in_array( $screen->id, [ 'sites-network', 'site-new-network' ], true ) ) {
return;
}
$count = $this->getSubsiteCount();
$limit = $this->getMultisiteLimit();
$available = ( 0 === $limit ) ? PHP_INT_MAX : max( 0, $limit - $count );
if ( 0 === $limit ) {
// Unlimited — nothing useful to display.
return;
}
echo '<div class="notice notice-info"><p>' .
esc_html(
sprintf(
/* translators: 1: limit, 2: active count, 3: remaining */
__( 'Subsite Limit – %1$d | Active – %2$d | Remaining – %3$d', 'wp-cloud-client' ),
$limit,
$count,
$available
)
) .
'</p></div>';
if ( 0 === $available ) {
$hasDeactivated = (bool) get_sites(
[
'number' => 1,
'deleted' => 1,
'fields' => 'ids',
]
);
if ( $hasDeactivated ) {
echo '<div class="notice notice-error"><p>' .
esc_html__(
'Maximum active subsites reached. Please deactivate another site before reactivating this one.',
'wp-cloud-client'
) .
'</p></div>';
}
}
}
// -------------------------------------------------------------------------
// Activate action link
// -------------------------------------------------------------------------
/**
* Replace the "Activate" row action with a disabled label for deactivated
* sites when the network is already at quota.
*
* Hooked to: manage_sites_action_links
*
* @param array<string, string> $actions Row action links.
* @param int $blogId Site ID.
* @param string $blogname Site name (unused).
* @return array<string, string>
*/
public function maybeDisableActivateAction( array $actions, int $blogId, string $blogname ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
if ( ! is_network_admin() || ! isset( $actions['activate'] ) ) {
return $actions;
}
$limit = $this->getMultisiteLimit();
if ( 0 === $limit ) {
return $actions; // 0 = unlimited.
}
$details = get_blog_details( $blogId );
if ( ! $details || 1 !== (int) $details->deleted ) {
return $actions; // Site is not deactivated.
}
if ( $this->getSubsiteCount() < $limit ) {
return $actions; // Still within quota.
}
$actions['activate'] = '<span class="disabled" aria-disabled="true" title="' .
esc_attr__( 'Active subsite limit reached', 'wp-cloud-client' ) .
'">' . esc_html__( 'Activate', 'wp-cloud-client' ) . '</span>';
return $actions;
}
// -------------------------------------------------------------------------
// Label renaming
// -------------------------------------------------------------------------
/**
* Rename the "Deleted" tab label to "Deactivated" in the Sites list view
* filters.
*
* Hooked to: views_sites-network
*
* @param array<string, string> $views View labels keyed by status slug.
* @return array<string, string>
*/
public function renameDeletedViewLabel( array $views ): array {
if ( isset( $views['deleted'] ) ) {
$views['deleted'] = str_replace( 'Deleted', 'Deactivated', $views['deleted'] );
}
return $views;
}
/**
* Replace the translated string "Deleted" with "Deactivated" on the
* Network Admin > Sites screen only.
*
* Hooked to: gettext
*
* @param string $translated Translated text.
* @param string $text Original text.
* @param string $domain Text domain (unused).
* @return string
*/
public function renameDeletedStringOnSitesScreen( string $translated, string $text, string $domain ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
if ( ! is_network_admin() || ! function_exists( 'get_current_screen' ) ) {
return $translated;
}
$screen = get_current_screen();
if ( ! $screen || 'sites-network' !== $screen->id ) {
return $translated;
}
if ( 'Deleted' === $text ) {
return 'Deactivated';
}
return $translated;
}
// -------------------------------------------------------------------------
// Auto-enforce limit
// -------------------------------------------------------------------------
/**
* Auto-deactivate the newest over-limit active sub-sites on every request.
*
* Runs at init priority 1 so it fires early. Skips if within quota.
*
* Hooked to: init (priority 1)
*
* @return void
*/
public function enforceSiteLimitOnLoad(): void {
if ( ! is_multisite() ) {
return;
}
$limit = $this->getMultisiteLimit();
if ( 0 === $limit ) {
return; // 0 = unlimited.
}
$count = $this->getSubsiteCount();
if ( $count <= $limit ) {
return;
}
$mainSiteId = get_main_site_id();
// Fetch active sub-sites newest-first so we deactivate the most recent ones.
$sites = get_sites(
[
'number' => 0,
'deleted' => 0,
'orderby' => 'registered',
'order' => 'DESC',
'site__not_in' => [ $mainSiteId ],
]
);
$disabled = 0;
foreach ( $sites as $site ) {
if ( ( $count - $disabled ) <= $limit ) {
break;
}
update_blog_status( (int) $site->blog_id, 'deleted', 1 );
++$disabled;
}
}
// -------------------------------------------------------------------------
// Quota helpers
// -------------------------------------------------------------------------
/**
* Get the number of active sub-sites (excludes the main site and deactivated
* sites).
*
* @return int
*/
public function getSubsiteCount(): int {
return (int) count(
get_sites(
[
'number' => 0,
'deleted' => 0,
'site__not_in' => [ get_main_site_id() ],
'fields' => 'ids',
]
)
);
}
/**
* Check whether the network is currently at or over quota.
*
* @return bool
*/
public function isAtLimit(): bool {
$limit = $this->getMultisiteLimit();
if ( 0 === $limit ) {
return false;
}
return $this->getSubsiteCount() >= $limit;
}
/**
* Get the maximum allowed sub-site count.
*
* Returns 0 to indicate unlimited if no base or add-on quota is configured.
*
* Final limit = base limit + WSP_SUBSCRIBED_COUNT add-on seats from Atomic_Persistent_Data.
*
* @return int
*/
public function getMultisiteLimit(): int {
$partnerId = (string) $this->settings->getSiteId();
$base = $this->getBaseLimit( $partnerId );
$addon = 0;
if ( class_exists( 'Atomic_Persistent_Data' ) ) {
$persistent = new \Atomic_Persistent_Data();
$addon = (int) ( $persistent->WSP_SUBSCRIBED_COUNT ?? 0 );
}
// Backward-compat: v1.0.0 read add-on seats from $_SERVER['SUBSCRIBED_COUNT'] injected by nginx.
if ( 0 === $addon && isset( $_SERVER['SUBSCRIBED_COUNT'] ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( '[WP Cloud Client] SUBSCRIBED_COUNT server var is deprecated; platform should provide WSP_SUBSCRIBED_COUNT via Atomic_Persistent_Data.' );
$addon = (int) $_SERVER['SUBSCRIBED_COUNT']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
}
return $base + $addon;
}
/**
* Get the partner-specific base site limit.
*
* Resolution order:
* 1. `wpcc_get_base_site_limit` filter (runtime override, no DB write needed).
* 2. `WPCC_MAX_SITE_LIMIT` PHP constant (set in the mu-plugin).
* 3. Hard default: 5.
*
* @param string $partnerId The partner/site ID from SettingsRepository.
* @return int
*/
private function getBaseLimit( string $partnerId ): int {
$default = defined( 'WPCC_MAX_SITE_LIMIT' ) ? (int) WPCC_MAX_SITE_LIMIT : 5;
/**
* Filter the base subsite limit for a partner.
*
* @param int $default Default limit (from WPCC_MAX_SITE_LIMIT or 0).
* @param string $partnerId The partner/site ID.
*/
return (int) apply_filters( 'wpcc_get_base_site_limit', $default, $partnerId );
}
}