- Eltern-Ordner ist jetzt EIN Git-Repo (statt getrennter Repos). - root .gitignore haelt Secrets (.env), node_modules, DB und Build-Artefakte raus. - release.ps1: manueller Release (ZIP bauen + ans Backend laden). - root README mit Struktur und Release-Ablauf. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
406 lines
15 KiB
PHP
406 lines
15 KiB
PHP
<?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();
|
||
$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: let the user release one bound domain and activate here. -->
|
||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
|
||
<?php wp_nonce_field( 'cb_license', 'cb_license_nonce' ); ?>
|
||
<input type="hidden" name="action" value="cb_license_swap">
|
||
<table class="form-table" role="presentation"><tbody>
|
||
<tr>
|
||
<th scope="row"><label for="cb_release_domain"><?php esc_html_e( 'Domain freigeben', 'gdpr-content-blocker' ); ?></label></th>
|
||
<td>
|
||
<select id="cb_release_domain" name="cb_release_domain">
|
||
<?php foreach ( $lic['pending_domains'] as $d ) : ?>
|
||
<option value="<?php echo esc_attr( $d ); ?>"><?php echo esc_html( $d ); ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<p class="description">
|
||
<?php esc_html_e( 'Diese Lizenz ist bereits auf den genannten Domains aktiv. Wählen Sie eine zum Freigeben aus – sie wird deaktiviert und diese Seite stattdessen aktiviert.', 'gdpr-content-blocker' ); ?>
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<?php submit_button( __( 'Freigeben und diese Seite aktivieren', 'gdpr-content-blocker' ), 'primary' ); ?>
|
||
</form>
|
||
<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
|
||
}
|
||
}
|