Files
s4luorth a0972b4bbf feat: belegte domains als liste mit papierkorb + status-live-refresh
- Bei voller Lizenz werden alle belegten Domains untereinander gelistet, je
  mit Papierkorb-Button + Bestaetigungsdialog (statt Dropdown).
- Trash gibt die Domain frei (deaktiviert serverseitig) und aktiviert diese Seite.
- Lizenz-Tab re-validiert den Status live (max alle 5 Min), damit eine an anderer
  Stelle freigegebene Domain hier sofort als ungueltig erscheint statt stale aktiv.
- EN-Uebersetzung (122) aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:27:34 +02:00

421 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
defined( 'ABSPATH' ) || exit;
/**
* License handling: activates a key against the (self-hosted) license backend,
* binds it to this site's domain, and re-checks daily.
*
* Design decision (deliberate): an invalid/expired license NEVER disables the
* actual content blocking. Silently switching protection off on a GDPR plugin
* would create a data-protection hole for the customer. Instead we show an admin
* notice and gate non-critical extras (e.g. update delivery). Core blocking
* always stays on.
*/
class CB_License {
const CRON_HOOK = 'cb_license_daily_check';
public static function init(): void {
add_action( 'admin_post_cb_license_activate', [ __CLASS__, 'handle_activate' ] );
add_action( 'admin_post_cb_license_deactivate', [ __CLASS__, 'handle_deactivate' ] );
add_action( 'admin_post_cb_license_swap', [ __CLASS__, 'handle_swap' ] );
add_action( self::CRON_HOOK, [ __CLASS__, 'cron_check' ] );
add_action( 'admin_notices', [ __CLASS__, 'maybe_notice' ] );
// No license → no updates. Strip any update offer for this plugin when
// the license is not active. (The update source is served by the backend
// only to licensed sites; this filter enforces it client-side too.)
add_filter( 'site_transient_update_plugins', [ __CLASS__, 'gate_updates' ] );
}
/** Remove this plugin from the update list unless the license is active. */
public static function gate_updates( mixed $transient ): mixed {
if ( self::is_active() || ! is_object( $transient ) ) {
return $transient;
}
$basename = plugin_basename( CB_FILE );
if ( isset( $transient->response[ $basename ] ) ) {
unset( $transient->response[ $basename ] );
}
return $transient;
}
public static function on_activation(): void {
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', self::CRON_HOOK );
}
}
public static function on_deactivation(): void {
$ts = wp_next_scheduled( self::CRON_HOOK );
if ( $ts ) {
wp_unschedule_event( $ts, self::CRON_HOOK );
}
}
/* ───────────────────────── helpers ───────────────────────── */
public static function get_license(): array {
$saved = get_option( CB_LICENSE_OPTION, [] );
$lic = wp_parse_args( is_array( $saved ) ? $saved : [], [
'key' => '',
'status' => 'inactive', // inactive | active | invalid | limit
'domain' => '',
'last_check' => 0,
'message' => '',
'pending_domains' => [],
] );
if ( ! is_array( $lic['pending_domains'] ) ) {
$lic['pending_domains'] = [];
}
return $lic;
}
public static function is_active(): bool {
return self::get_license()['status'] === 'active';
}
/** Mask a key for display: keep dashes + last 4 chars, hide the rest. */
public static function mask_key( string $key ): string {
$n = strlen( $key );
if ( $n <= 4 ) {
return $key;
}
$tail = substr( $key, -4 );
$head = preg_replace( '/[A-Za-z0-9]/', '•', substr( $key, 0, $n - 4 ) );
return $head . $tail;
}
public static function api_url(): string {
return untrailingslashit( (string) apply_filters( 'cb_license_api_url', CB_LICENSE_API_URL ) );
}
public static function domain(): string {
$host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
$host = strtolower( $host );
return (string) preg_replace( '/^www\./', '', $host );
}
/** POST JSON to a backend endpoint. Returns decoded array or WP_Error. */
private static function remote( string $endpoint, array $body ): array|WP_Error {
$response = wp_remote_post( self::api_url() . $endpoint, [
'timeout' => 15,
'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json' ],
'body' => wp_json_encode( $body ),
] );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return new WP_Error( 'cb_bad_response', __( 'Ungültige Antwort vom Lizenzserver.', 'gdpr-content-blocker' ) );
}
$data['_http_code'] = $code;
return $data;
}
/* ───────────────────────── actions ───────────────────────── */
public static function handle_activate(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Keine Berechtigung.', 'gdpr-content-blocker' ) );
}
check_admin_referer( 'cb_license', 'cb_license_nonce' );
$key = isset( $_POST['cb_license_key'] )
? sanitize_text_field( wp_unslash( $_POST['cb_license_key'] ) )
: '';
$key = trim( $key );
if ( $key === '' ) {
self::store( '', 'inactive', __( 'Bitte einen Lizenzschlüssel eingeben.', 'gdpr-content-blocker' ) );
self::redirect();
}
$result = self::remote( '/api/v1/activate', [
'key' => $key,
'product' => CB_PRODUCT_SLUG,
'domain' => self::domain(),
] );
if ( is_wp_error( $result ) ) {
self::store( $key, 'inactive', $result->get_error_message() );
self::redirect();
}
if ( ! empty( $result['ok'] ) && ( $result['status'] ?? '' ) === 'valid' ) {
self::store( $key, 'active', __( 'Lizenz erfolgreich aktiviert.', 'gdpr-content-blocker' ) );
} elseif ( ( $result['code'] ?? '' ) === 'limit_reached' ) {
// No free slots: keep the key and offer to release one of the bound domains.
$domains = is_array( $result['domains'] ?? null ) ? $result['domains'] : [];
self::store(
$key,
'limit',
__( 'Alle Plätze dieser Lizenz sind belegt. Geben Sie eine Domain frei, um diese Seite zu aktivieren.', 'gdpr-content-blocker' ),
$domains
);
} else {
$msg = $result['error'] ?? __( 'Aktivierung fehlgeschlagen.', 'gdpr-content-blocker' );
self::store( $key, 'invalid', $msg );
}
self::redirect();
}
/**
* Free a chosen domain's slot on the backend, then activate the current site.
* Triggered from the "no free slots" selection UI.
*/
public static function handle_swap(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Keine Berechtigung.', 'gdpr-content-blocker' ) );
}
check_admin_referer( 'cb_license', 'cb_license_nonce' );
$lic = self::get_license();
$key = $lic['key'];
if ( $key === '' ) {
self::redirect();
}
$release = isset( $_POST['cb_release_domain'] )
? sanitize_text_field( wp_unslash( $_POST['cb_release_domain'] ) )
: '';
// Only allow releasing a domain that was actually reported as occupied.
if ( $release === '' || ! in_array( $release, $lic['pending_domains'], true ) ) {
self::store( $key, 'limit', __( 'Bitte eine gültige Domain zum Freigeben auswählen.', 'gdpr-content-blocker' ), $lic['pending_domains'] );
self::redirect();
}
// 1) Release the chosen domain.
$deact = self::remote( '/api/v1/deactivate', [
'key' => $key,
'product' => CB_PRODUCT_SLUG,
'domain' => $release,
] );
if ( is_wp_error( $deact ) ) {
self::store( $key, 'limit', $deact->get_error_message(), $lic['pending_domains'] );
self::redirect();
}
// 2) Activate this site.
$result = self::remote( '/api/v1/activate', [
'key' => $key,
'product' => CB_PRODUCT_SLUG,
'domain' => self::domain(),
] );
if ( is_wp_error( $result ) ) {
self::store( $key, 'limit', $result->get_error_message(), $lic['pending_domains'] );
} elseif ( ! empty( $result['ok'] ) && ( $result['status'] ?? '' ) === 'valid' ) {
self::store( $key, 'active', sprintf(
/* translators: %s: released domain */
__( 'Domain %s freigegeben und diese Seite aktiviert.', 'gdpr-content-blocker' ),
$release
) );
} else {
$domains = is_array( $result['domains'] ?? null ) ? $result['domains'] : $lic['pending_domains'];
$msg = $result['error'] ?? __( 'Aktivierung fehlgeschlagen.', 'gdpr-content-blocker' );
self::store( $key, 'limit', $msg, $domains );
}
self::redirect();
}
public static function handle_deactivate(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Keine Berechtigung.', 'gdpr-content-blocker' ) );
}
check_admin_referer( 'cb_license', 'cb_license_nonce' );
$lic = self::get_license();
if ( $lic['key'] !== '' ) {
// Free the activation slot on the backend (best effort).
self::remote( '/api/v1/deactivate', [
'key' => $lic['key'],
'product' => CB_PRODUCT_SLUG,
'domain' => self::domain(),
] );
}
delete_option( CB_LICENSE_OPTION );
CB_Updater::flush_cache();
delete_site_transient( 'update_plugins' );
self::redirect();
}
public static function cron_check(): void {
$lic = self::get_license();
// Only re-validate licenses that are bound to THIS domain. In the
// 'inactive' and 'limit' states this site isn't activated, so /validate
// would always 403 and wrongly overwrite the (recoverable) state.
if ( $lic['key'] === '' || ! in_array( $lic['status'], [ 'active', 'invalid' ], true ) ) {
return;
}
$result = self::remote( '/api/v1/validate', [
'key' => $lic['key'],
'product' => CB_PRODUCT_SLUG,
'domain' => self::domain(),
] );
// On network error: keep the previous status (grace), just record the time.
if ( is_wp_error( $result ) ) {
self::store( $lic['key'], $lic['status'], $lic['message'] );
return;
}
if ( ! empty( $result['ok'] ) && ( $result['status'] ?? '' ) === 'valid' ) {
self::store( $lic['key'], 'active', '' );
} else {
$msg = $result['error'] ?? __( 'Lizenz nicht mehr gültig.', 'gdpr-content-blocker' );
self::store( $lic['key'], 'invalid', $msg );
}
}
private static function store( string $key, string $status, string $message, array $pending_domains = [] ): void {
update_option( CB_LICENSE_OPTION, [
'key' => $key,
'status' => $status,
'domain' => self::domain(),
'last_check' => time(),
'message' => $message,
'pending_domains' => array_values( array_filter( array_map( 'strval', $pending_domains ) ) ),
] );
// License state changed → invalidate cached update info and force WP to
// re-evaluate available updates (so the gate takes effect immediately).
if ( class_exists( 'CB_Updater' ) ) {
CB_Updater::flush_cache();
}
delete_site_transient( 'update_plugins' );
}
private static function redirect(): void {
wp_safe_redirect( admin_url( 'options-general.php?page=gdpr-content-blocker&cb_tab=license' ) );
exit;
}
/* ───────────────────────── UI ───────────────────────── */
public static function maybe_notice(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$screen = get_current_screen();
if ( $screen && $screen->id === 'settings_page_gdpr-content-blocker' ) {
return; // The tab already shows the status; don't double up.
}
if ( self::is_active() ) {
return;
}
$url = admin_url( 'options-general.php?page=gdpr-content-blocker&cb_tab=license' );
echo '<div class="notice notice-warning"><p>';
printf(
/* translators: %s: link to the license settings */
esc_html__( 'GDPR Content Blocker ist nicht lizenziert. Der Schutz bleibt aktiv, aber für Updates und Support bitte %s hinterlegen.', 'gdpr-content-blocker' ),
'<a href="' . esc_url( $url ) . '">' . esc_html__( 'Lizenzschlüssel', 'gdpr-content-blocker' ) . '</a>'
);
echo '</p></div>';
}
public static function render_tab(): void {
$lic = self::get_license();
// Keep the shown status honest: if this site was deactivated elsewhere
// (e.g. another site freed this slot), re-validate live when the tab is
// opened — throttled to at most once every 5 minutes.
if ( in_array( $lic['status'], [ 'active', 'invalid' ], true )
&& ( time() - (int) $lic['last_check'] ) > 5 * MINUTE_IN_SECONDS ) {
self::cron_check();
$lic = self::get_license();
}
$status = $lic['status'];
$badge = match ( $status ) {
'active' => '<span style="color:#1a7f37;font-weight:600;">● ' . esc_html__( 'Aktiv', 'gdpr-content-blocker' ) . '</span>',
'invalid' => '<span style="color:#b32d2e;font-weight:600;">● ' . esc_html__( 'Ungültig', 'gdpr-content-blocker' ) . '</span>',
'limit' => '<span style="color:#b32d2e;font-weight:600;">● ' . esc_html__( 'Alle Plätze belegt', 'gdpr-content-blocker' ) . '</span>',
default => '<span style="color:#8a6d3b;font-weight:600;">● ' . esc_html__( 'Nicht aktiviert', 'gdpr-content-blocker' ) . '</span>',
};
?>
<table class="form-table" role="presentation"><tbody>
<tr>
<th scope="row"><?php esc_html_e( 'Status', 'gdpr-content-blocker' ); ?></th>
<td><?php echo wp_kses_post( $badge ); ?>
<?php if ( $lic['message'] !== '' ) : ?>
<p class="description"><?php echo esc_html( $lic['message'] ); ?></p>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Domain', 'gdpr-content-blocker' ); ?></th>
<td><code><?php echo esc_html( self::domain() ); ?></code></td>
</tr>
</tbody></table>
<?php if ( $status === 'limit' && ! empty( $lic['pending_domains'] ) ) : ?>
<!-- No free slots: list every bound domain with a trash button to free it. -->
<h2><?php esc_html_e( 'Belegte Domains', 'gdpr-content-blocker' ); ?></h2>
<p class="description" style="max-width:640px;">
<?php esc_html_e( 'Diese Lizenz ist bereits auf folgenden Domains aktiv. Entfernen Sie eine, um den Platz freizugeben diese Seite wird anschließend automatisch aktiviert.', 'gdpr-content-blocker' ); ?>
</p>
<ul class="cb-domain-list">
<?php
$confirm = esc_attr__( 'Diese Domain wirklich aus der Lizenz entfernen?', 'gdpr-content-blocker' );
foreach ( $lic['pending_domains'] as $d ) :
?>
<li>
<code><?php echo esc_html( $d ); ?></code>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>"
onsubmit="return confirm('<?php echo $confirm; ?>');">
<?php wp_nonce_field( 'cb_license', 'cb_license_nonce' ); ?>
<input type="hidden" name="action" value="cb_license_swap">
<input type="hidden" name="cb_release_domain" value="<?php echo esc_attr( $d ); ?>">
<button type="submit" class="cb-remove-service"
aria-label="<?php esc_attr_e( 'Domain entfernen', 'gdpr-content-blocker' ); ?>"
title="<?php esc_attr_e( 'Domain entfernen', 'gdpr-content-blocker' ); ?>">
<span class="dashicons dashicons-trash"></span>
</button>
</form>
</li>
<?php endforeach; ?>
</ul>
<hr>
<?php endif; ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'cb_license', 'cb_license_nonce' ); ?>
<table class="form-table" role="presentation"><tbody>
<tr>
<th scope="row"><label for="cb_license_key"><?php esc_html_e( 'Lizenzschlüssel', 'gdpr-content-blocker' ); ?></label></th>
<td>
<?php if ( $status === 'active' ) : ?>
<input type="text" id="cb_license_key" class="regular-text code"
value="<?php echo esc_attr( self::mask_key( $lic['key'] ) ); ?>" readonly disabled>
<p class="description"><?php esc_html_e( 'Aus Sicherheitsgründen verdeckt. Zum Ändern bitte zuerst deaktivieren.', 'gdpr-content-blocker' ); ?></p>
<?php else : ?>
<input type="text" id="cb_license_key" name="cb_license_key" class="regular-text code"
value="<?php echo esc_attr( $lic['key'] ); ?>"
placeholder="XXXX-XXXX-XXXX-XXXX">
<?php endif; ?>
</td>
</tr>
</tbody></table>
<?php if ( $status === 'active' ) : ?>
<input type="hidden" name="action" value="cb_license_deactivate">
<?php submit_button( __( 'Lizenz deaktivieren', 'gdpr-content-blocker' ), 'secondary' ); ?>
<?php else : ?>
<input type="hidden" name="action" value="cb_license_activate">
<?php submit_button( $status === 'limit' ? __( 'Erneut versuchen', 'gdpr-content-blocker' ) : __( 'Lizenz aktivieren', 'gdpr-content-blocker' ), 'secondary' ); ?>
<?php endif; ?>
</form>
<?php
}
}