HEX
Server: nginx
System: Linux pool195-106-36.bur.atomicsites.net 6.12.57+deb12-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1~bpo12+1 (2025-11-17) x86_64
User: (0)
PHP: 8.3.32
Disabled: pcntl_fork
Upload Files
File: /wordpress/plugins/wp-cloud-client/1.1.7/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 = WSP_MAX_SITE_LIMIT 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 = WSP_MAX_SITE_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 );
		}

		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. `WSP_MAX_SITE_LIMIT` constant (set by the Vendasta mu-plugin from platform data).
	 *  3. Hard default: 5.
	 *
	 * @param string $partnerId The partner/site ID from SettingsRepository.
	 * @return int
	 */
	private function getBaseLimit( string $partnerId ): int {
		/**
		 * Filter the base subsite limit for a partner.
		 *
		 * @param int    $default   Default limit.
		 * @param string $partnerId The partner/site ID.
		 */
		$default = \defined( 'WSP_MAX_SITE_LIMIT' ) ? (int) \WSP_MAX_SITE_LIMIT : 5; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound
		return (int) apply_filters( 'wpcc_get_base_site_limit', $default, $partnerId );
	}
}