chore: monorepo - plugin, backend und hilfsdaten in einem repo

- 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>
This commit is contained in:
s4luorth
2026-06-07 14:41:38 +02:00
commit ecb5e1bd22
37 changed files with 4390 additions and 0 deletions

View File

@@ -0,0 +1,405 @@
<?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
}
}