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

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# ── Secrets / Umgebung ──
**/.env
*.env
!**/.env.example
# ── Node ──
**/node_modules/
# ── Backend-Daten / DB (laufen nur auf dem Server) ──
license-backend/data/
**/*.db
**/*.db-shm
**/*.db-wal
**/.testdata/
# ── Build-Artefakte ──
**/build/
*.zip
# ── Scratch ──
**/.commitmsg.tmp
.expr/
.expressonly*/

29
README.md Normal file
View File

@@ -0,0 +1,29 @@
# GDPR Content Blocker — Monorepo
DSGVO-konformer Consent-Blocker für externe iframes + selbst-gehosteter
Lizenz-/Update-Server.
## Struktur
- **`gdpr-content-blocker/`** — das WordPress-Plugin. Aus diesem Ordner wird die
Release-ZIP gebaut (Top-Level-Ordner `gdpr-content-blocker/`).
- **`license-backend/`** — Node/Express + SQLite Lizenz- & Update-Backend (Docker).
- **`hilfsdaten/`** — interne Notizen, Spickzettel, Gitea-Runner-Setup (nicht Teil
des Plugins).
- **`release.ps1`** — baut die Plugin-ZIP und lädt sie ins Backend (manueller Release).
## Manueller Release (Update verteilen)
1. Version in `gdpr-content-blocker/gdpr-content-blocker.php` bumpen
(Header `Version:` **und** `define( 'CB_VERSION', … )`), committen.
2. ZIP bauen + hochladen:
```powershell
.\release.ps1 -Version 1.0.1 -Token DEIN_ADMIN_API_TOKEN
```
3. Lizenzierte Seiten sehen das Update unter Plugins.
Details: siehe `hilfsdaten/SPICKZETTEL.md`.
## Backend starten
Siehe `license-backend/README.md`.
## Hinweis
Secrets (`.env`) sind per `.gitignore` ausgeschlossen und gehören nicht ins Repo.

View File

@@ -0,0 +1,51 @@
name: Release
# Tag a version (e.g. `git tag v1.1.0 && git push --tags`) to build the plugin
# ZIP and publish it to the license backend, which then serves it to licensed
# sites as an update.
on:
push:
tags:
- 'v*'
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive version from tag
id: v
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Stamp version into the plugin header
env:
V: ${{ steps.v.outputs.version }}
run: |
sed -i "s/^ \* Version:.*/ * Version: ${V}/" gdpr-content-blocker.php
sed -i "s/define( 'CB_VERSION', *'[^']*' )/define( 'CB_VERSION', '${V}' )/" gdpr-content-blocker.php
- name: Build ZIP (top-level gdpr-content-blocker/ folder)
run: |
mkdir -p build/gdpr-content-blocker
rsync -a \
--exclude '.git' --exclude '.gitea' --exclude 'build' \
--exclude 'node_modules' --exclude '*.zip' \
./ build/gdpr-content-blocker/
(cd build && zip -r ../gdpr-content-blocker.zip gdpr-content-blocker)
- name: Publish to license backend
env:
BACKEND: ${{ secrets.LICENSE_BACKEND_URL }}
TOKEN: ${{ secrets.LICENSE_ADMIN_TOKEN }}
V: ${{ steps.v.outputs.version }}
run: |
curl -fSs -X POST \
"${BACKEND}/api/v1/releases?product=gdpr-content-blocker&version=${V}" \
-H "X-Admin-Token: ${TOKEN}" \
-H "Content-Type: application/zip" \
-H "X-Tested: 6.7" \
-H "X-Requires-PHP: 8.1" \
--data-binary @gdpr-content-blocker.zip

7
gdpr-content-blocker/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# CI build artifacts
build/
*.zip
# scratch
.commitmsg.tmp
node_modules/

View File

@@ -0,0 +1,273 @@
/**
* Content Blocker admin.js
* Color-picker init + service repeater (add / remove rows).
*/
( function ( $ ) {
'use strict';
/* ── Color pickers ── */
function initColorPickers( ctx ) {
$( '.cb-color-picker', ctx ).wpColorPicker();
}
/* ── Live header update: "interner-name — Anbietername" ── */
function syncTitle( box ) {
const id = ( $( '.cb-input-id', box ).val() || '' ).trim();
const name = ( $( '.cb-input-name', box ).val() || '' ).trim();
const head = ( id || ( cbAdmin.newServiceLbl || 'neu' ) ) + ( name ? ' — ' + name : '' );
$( '.cb-service-title', box ).first().text( head );
}
function bindNameSync( box ) {
$( '.cb-input-name, .cb-input-id', box ).on( 'input', function () {
syncTitle( box );
} );
}
/* ── Expand / collapse a service box ── */
function setExpanded( box, open ) {
$( '.cb-service-grid', box ).toggle( open );
const $btn = $( '.cb-service-toggle', box );
$btn.attr( 'aria-expanded', open ? 'true' : 'false' ).text( open ? '▾' : '▸' );
}
function bindToggle( box ) {
$( '.cb-service-toggle', box ).on( 'click', function () {
const open = $( this ).attr( 'aria-expanded' ) !== 'true';
setExpanded( box, open );
} );
// Clicking the title also expands/collapses.
$( '.cb-service-title', box ).on( 'click', function () {
const open = $( '.cb-service-toggle', box ).attr( 'aria-expanded' ) !== 'true';
setExpanded( box, open );
} );
}
/* ── Re-index a row's field names after a remove ── */
function reindexRows() {
$( '#cb-services-list .cb-service-box' ).each( function ( i ) {
$( this ).attr( 'data-cb-index', i );
$( this ).find( '[name]' ).each( function () {
const n = $( this ).attr( 'name' );
$( this ).attr( 'name', n.replace( /cb_services\[\d+\]/, 'cb_services[' + i + ']' ) );
} );
} );
}
/* ── Add a new (optionally prefilled) service row ── */
function addServiceRow( preset ) {
const index = $( '#cb-services-list .cb-service-box' ).length;
const template = $( '#cb-service-template' ).html();
const newHtml = template.replace( /__INDEX__/g, index );
const $box = $( newHtml );
$( '#cb-services-list' ).append( $box );
bindNameSync( $box );
bindToggle( $box );
$box.find( '.cb-remove-service' ).on( 'click', handleRemove );
if ( preset ) {
fillPreset( $box, preset );
}
setExpanded( $box, true ); // new rows start expanded for editing
syncTitle( $box );
return $box;
}
/* ── Fill a row from a preset object ── */
function fillPreset( $box, p ) {
const setText = function ( field, val ) {
$box.find( '[name$="[' + field + ']"]' ).val( val != null ? val : '' );
};
const setBool = function ( field, val ) {
$box.find( '[name$="[' + field + ']"]' ).prop( 'checked', !! val );
};
setText( 'id', p.id );
setText( 'name', p.name );
setText( 'match_pattern', p.match_pattern );
setText( 'recipient', p.recipient );
setText( 'privacy_url', p.privacy_url );
setText( 'purpose', p.purpose );
setBool( 'third_country', p.third_country );
setBool( 'sets_cookie', p.sets_cookie );
setBool( 'loads_script', p.loads_script );
$box.find( '.cb-service-title' ).text( p.name || cbAdmin.newServiceLbl );
}
/* ── Add empty service ── */
$( '#cb-add-service' ).on( 'click', function () {
addServiceRow( null );
} );
/* ── Insert preset ── */
$( '#cb-preset-select' ).on( 'change', function () {
const key = $( this ).val();
if ( ! key || ! cbAdmin.presets || ! cbAdmin.presets[ key ] ) {
return;
}
addServiceRow( cbAdmin.presets[ key ] );
$( this ).val( '' ); // reset so the same preset can be added again
} );
/* ── Remove service ── */
function handleRemove() {
if ( ! window.confirm( cbAdmin.confirmRemove ) ) {
return;
}
$( this ).closest( '.cb-service-box' ).remove();
reindexRows();
}
/* ── Tab switching ── */
$( '[data-cb-tab]' ).on( 'click', function ( e ) {
e.preventDefault();
activateTab( $( this ).data( 'cb-tab' ) );
} );
/* ── Website scan ── */
$( '#cb-scan-btn' ).on( 'click', function () {
const $btn = $( this ).prop( 'disabled', true );
const i18n = cbAdmin.i18n || {};
$( '#cb-scan-status' ).text( i18n.scanning || 'Scanning…' );
$( '#cb-scan-results' ).empty();
$.post( cbAdmin.ajaxUrl, { action: 'cb_scan', nonce: cbAdmin.scanNonce } )
.done( function ( resp ) {
if ( ! resp || ! resp.success ) {
const msg = resp && resp.data && resp.data.message ? resp.data.message : 'Error';
$( '#cb-scan-status' ).text( ( i18n.scanError || 'Scan failed:' ) + ' ' + msg );
return;
}
$( '#cb-scan-status' ).text( '' );
renderScan( resp.data );
} )
.fail( function ( xhr ) {
$( '#cb-scan-status' ).text( ( i18n.scanError || 'Scan failed:' ) + ' ' + xhr.status );
} )
.always( function () {
$btn.prop( 'disabled', false );
} );
} );
function renderScan( data ) {
const i18n = cbAdmin.i18n || {};
const findings = ( data && data.findings ) || [];
const $out = $( '#cb-scan-results' ).empty();
// Scanned pages summary
if ( data && data.scanned && data.scanned.length ) {
const $p = $( '<p class="description"></p>' ).text( ( i18n.scannedPages || 'Scanned:' ) + ' ' );
data.scanned.forEach( function ( s, i ) {
if ( i > 0 ) $p.append( ', ' );
$p.append( $( '<code></code>' ).text( s.url + ( s.error ? ' (!)' : '' ) ) );
} );
$out.append( $p );
}
if ( ! findings.length ) {
$out.append( $( '<p></p>' ).text( i18n.noFindings || 'No external resources found.' ) );
return;
}
const $table = $( '<table class="widefat striped"></table>' );
const $head = $( '<tr></tr>' );
[ i18n.host || 'Host', i18n.type || 'Type', i18n.count || 'Count', i18n.foundOn || 'Found on', i18n.example || 'Example', i18n.status || 'Status', i18n.action || 'Action' ]
.forEach( function ( h ) { $head.append( $( '<th></th>' ).text( h ) ); } );
$table.append( $( '<thead></thead>' ).append( $head ) );
const $body = $( '<tbody></tbody>' );
findings.forEach( function ( f ) {
const $tr = $( '<tr></tr>' );
// Host (+ party badge)
const $host = $( '<td></td>' );
$host.append( $( '<strong></strong>' ).text( f.host ) );
$host.append( document.createElement( 'br' ) );
$host.append( $( '<span></span>' )
.css( { fontSize: '11px', color: f.third_party ? '#b32d2e' : '#1a7f37' } )
.text( f.third_party ? ( i18n.thirdParty || 'third-party' ) : ( i18n.firstParty || 'first-party' ) ) );
$tr.append( $host );
// Types
$tr.append( $( '<td></td>' ).text( ( f.types || [] ).join( ', ' ) ) );
// Count
$tr.append( $( '<td></td>' ).text( f.count ) );
// Found on which pages
const $pages = $( '<td></td>' ).css( { fontSize: '11px' } );
( f.pages || [] ).forEach( function ( pageUrl, i ) {
if ( i > 0 ) $pages.append( document.createElement( 'br' ) );
let label = pageUrl;
try { label = new URL( pageUrl ).pathname || pageUrl; } catch ( e ) {}
$pages.append( $( '<a></a>' )
.attr( { href: pageUrl, target: '_blank', rel: 'noopener noreferrer', title: pageUrl } )
.text( label ) );
} );
$tr.append( $pages );
// Example URL
const $ex = $( '<td></td>' );
const sample = ( f.sample_urls || [] )[ 0 ] || '';
$ex.append( $( '<code></code>' ).css( { fontSize: '11px', wordBreak: 'break-all' } ).text( sample ) );
$tr.append( $ex );
// Status
const $st = $( '<td></td>' );
if ( f.covered ) {
$st.append( $( '<span></span>' ).css( { color: '#1a7f37', fontWeight: '600' } ).text( '✓ ' + ( i18n.covered || 'covered' ) ) );
}
$tr.append( $st );
// Action: take over as a new service (only useful for uncovered third parties)
const $act = $( '<td></td>' );
if ( f.third_party && ! f.covered ) {
$( '<button type="button" class="button button-small"></button>' )
.text( i18n.addService || 'Add as service' )
.on( 'click', function () {
activateTab( 'services' );
const $box = addServiceRow( { name: f.host, match_pattern: f.suggested_pattern || f.host, third_country: true } );
if ( $box && $box.length ) {
$box[ 0 ].scrollIntoView( { behavior: 'smooth', block: 'center' } );
$box.find( '.cb-input-id' ).trigger( 'focus' );
}
} )
.appendTo( $act );
}
$tr.append( $act );
$body.append( $tr );
} );
$table.append( $body );
$out.append( $table );
}
/* ── Activate a tab by key ── */
function activateTab( tab ) {
$( '.cb-tab-content' ).hide();
$( '#cb-tab-' + tab ).show();
$( '[data-cb-tab]' ).removeClass( 'nav-tab-active' );
$( '[data-cb-tab="' + tab + '"]' ).addClass( 'nav-tab-active' );
}
/* ── Bootstrap ── */
$( document ).ready( function () {
initColorPickers( document );
$( '#cb-services-list .cb-service-box' ).each( function () {
bindNameSync( this );
bindToggle( this );
$( '.cb-remove-service', this ).on( 'click', handleRemove );
} );
// Honor ?cb_tab=… so license redirects land on the right tab.
const params = new URLSearchParams( window.location.search );
const tab = params.get( 'cb_tab' );
if ( tab && $( '#cb-tab-' + tab ).length ) {
activateTab( tab );
}
} );
// Expose strings from wp_localize_script if needed.
window.cbAdmin = window.cbAdmin || { confirmRemove: 'Dienst wirklich entfernen?' };
} )( jQuery );

View File

@@ -0,0 +1,198 @@
/**
* Content Blocker frontend.css
* Design: minimalist black/white with accent #2043B7
* Colors come from CSS custom properties set inline by PHP.
*
* Note on specificity: every visible rule is scoped under `.cb-blocker`
* (specificity 0,2,0) so theme button/link styles (typically 0,1,1) cannot
* override the plugin. Your Custom-CSS is injected AFTER this file, so it can
* still override everything — use the same `.cb-blocker …` prefix to be safe.
*/
:root {
--cb-text: #ffffff;
--cb-bg: #111111;
--cb-btn-bg: #2043B7;
--cb-btn-text: #ffffff;
--cb-btn-hover-bg: #1a369a;
--cb-btn-hover-text: #ffffff;
}
/* ── Wrapper / placeholder ── */
.cb-blocker {
display: flex;
align-items: flex-start; /* pin content to the top */
justify-content: center;
min-height: 180px; /* fallback only; real height is set inline */
width: 100%;
background-color: var(--cb-bg);
color: var(--cb-text);
box-sizing: border-box;
padding: 24px 8px 20px; /* min 20px top/bottom, 8px sides */
font-family: inherit;
border-radius: 4px;
overflow: auto; /* if text is taller than the embed, scroll, don't clip */
}
/* ── Inner content ── */
.cb-blocker .cb-blocker__inner {
width: 100%;
max-width: 520px;
text-align: center;
}
/* ── Text elements ── */
.cb-blocker .cb-blocker__text,
.cb-blocker .cb-blocker__recipient,
.cb-blocker .cb-blocker__purpose {
margin: 0 0 0.65em;
font-size: 0.875rem;
line-height: 1.6;
color: var(--cb-text);
}
.cb-blocker .cb-blocker__text {
font-size: 0.9375rem;
}
.cb-blocker .cb-blocker__third-country {
display: inline-block;
margin-top: 0.25em;
font-size: 0.8rem;
color: #f0b429; /* amber — universal caution, brand-neutral */
font-weight: 600;
}
/* ── Privacy link ── */
.cb-blocker .cb-blocker__privacy-link {
display: inline-block;
margin-bottom: 0.75em;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--cb-btn-bg);
text-decoration: underline;
text-underline-offset: 3px;
transform: none; /* neutralise theme hover scaling */
}
.cb-blocker .cb-blocker__privacy-link:hover,
.cb-blocker .cb-blocker__privacy-link:focus {
color: var(--cb-btn-hover-bg);
transform: none; /* link must not grow on hover */
font-size: 0.8125rem;
}
/* ── Button ── */
.cb-blocker .cb-blocker__button {
display: inline-block;
margin-top: 1em;
padding: 0.6em 1.4em;
background-color: var(--cb-btn-bg);
color: var(--cb-btn-text);
border: none; /* never a border/frame */
outline: none;
box-shadow: none;
transform: none; /* no size change, even with theme hover styles */
border-radius: 3px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease;
letter-spacing: 0.01em;
}
/* Hover: only the colour changes — no frame, no resize. */
.cb-blocker .cb-blocker__button:hover {
background-color: var(--cb-btn-hover-bg);
color: var(--cb-btn-hover-text);
border: none;
outline: none;
box-shadow: none;
transform: none;
}
/* Keyboard focus keeps a subtle, accessible ring (not shown on mouse hover). */
.cb-blocker .cb-blocker__button:focus-visible {
outline: 2px solid var(--cb-btn-hover-bg);
outline-offset: 2px;
}
/* ── Revoke button ── */
.cb-revoke-btn {
display: inline-block;
padding: 0.5em 1.2em;
background-color: #111111;
color: #ffffff;
border: 2px solid #111111;
border-radius: 3px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease;
}
.cb-revoke-btn:hover,
.cb-revoke-btn:focus-visible {
background-color: #ffffff;
color: #111111;
}
/* ── Revoke as a visible text link (default) ── */
.cb-revoke-link {
display: inline-block;
color: var(--cb-btn-bg);
font-weight: 600;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.cb-revoke-link:hover,
.cb-revoke-link:focus-visible {
color: var(--cb-btn-hover-bg);
text-decoration: underline;
}
.cb-revoke-note {
display: block;
margin-top: 6px;
font-size: 0.8125rem;
opacity: 0.8;
}
/* ── Services overview (for the privacy policy) ── */
.cb-services-list {
list-style: none;
margin: 1em 0;
padding: 0;
}
.cb-services-list .cb-service-entry {
margin: 0 0 1.1em;
padding: 0 0 1.1em;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.cb-services-list .cb-service-entry:last-child {
border-bottom: none;
}
.cb-services-list .cb-service-entry__name {
font-weight: 700;
font-size: 1.02em;
}
.cb-services-list .cb-service-entry dl {
margin: 0.4em 0 0;
display: grid;
grid-template-columns: max-content 1fr;
gap: 2px 14px;
}
.cb-services-list .cb-service-entry dt {
font-weight: 600;
}
.cb-services-list .cb-service-entry dd {
margin: 0;
}

View File

@@ -0,0 +1,115 @@
/**
* Content Blocker frontend.js
* Click-to-load: the real iframe is CREATED only after the user actively
* consents per service. Consent is stored in localStorage per service id.
*/
( function () {
'use strict';
const STORAGE_PREFIX = 'cb_consent_';
/* ───────────────────────── consent storage ───────────────────────── */
function hasConsent( serviceId ) {
try {
return localStorage.getItem( STORAGE_PREFIX + serviceId ) === '1';
} catch ( e ) {
return false;
}
}
function grantConsent( serviceId ) {
try {
localStorage.setItem( STORAGE_PREFIX + serviceId, '1' );
} catch ( e ) {
// localStorage unavailable; allow the load for this session only.
}
}
/* ───────────────────────── iframe loading ────────────────────────── */
/** Replace a .cb-blocker element with the real iframe. src comes from data-src. */
function loadContent( blockerEl ) {
const src = blockerEl.dataset.src;
if ( ! src ) {
blockerEl.remove();
return;
}
const iframe = document.createElement( 'iframe' );
if ( blockerEl.dataset.width ) iframe.width = blockerEl.dataset.width;
if ( blockerEl.dataset.height ) iframe.height = blockerEl.dataset.height;
iframe.setAttribute( 'loading', 'lazy' );
iframe.setAttribute( 'allowfullscreen', '' );
iframe.setAttribute( 'referrerpolicy', 'no-referrer-when-downgrade' );
// Set src last — this is the moment the network request is made.
iframe.src = src;
blockerEl.parentNode.replaceChild( iframe, blockerEl );
}
function loadPreConsented() {
document.querySelectorAll( '.cb-blocker[data-cb-id]' ).forEach( function ( el ) {
const id = el.dataset.cbId;
if ( id && hasConsent( id ) ) {
loadContent( el );
}
} );
}
/* ───────────────────────── consent buttons ───────────────────────── */
function attachButtons() {
document.addEventListener( 'click', function ( e ) {
const btn = e.target.closest( '.cb-blocker__button' );
if ( ! btn ) return;
const serviceId = btn.dataset.cbId;
if ( ! serviceId ) return;
grantConsent( serviceId );
const wrapper = btn.closest( '.cb-blocker' );
if ( wrapper ) {
loadContent( wrapper );
}
} );
}
/* ───────────────────────── revoke (Art. 7 (3)) ───────────────────── */
window.cbRevokeAll = function () {
try {
const keysToRemove = [];
for ( let i = 0; i < localStorage.length; i++ ) {
const key = localStorage.key( i );
if ( key && key.indexOf( STORAGE_PREFIX ) === 0 ) {
keysToRemove.push( key );
}
}
keysToRemove.forEach( function ( k ) {
localStorage.removeItem( k );
} );
} catch ( e ) {
// Silently ignore if localStorage is unavailable.
}
window.location.reload();
};
/* ───────────────────────── bootstrap ─────────────────────────────── */
function init() {
loadPreConsented();
attachButtons();
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
} )();

View File

@@ -0,0 +1,55 @@
<?php
/**
* Plugin Name: GDPR Content Blocker
* Plugin URI: https://lucas-orth.de
* Description: DSGVO-konformer Consent-Blocker für externe iframes. Lädt Drittinhalte erst nach aktiver Einwilligung.
* Version: 1.0.0
* Author: Lucas Orth
* Author URI: https://lucas-orth.de
* Text Domain: gdpr-content-blocker
* Domain Path: /languages
* Requires PHP: 8.1
* Requires at least: 6.0
* License: GPL-2.0-or-later
*/
defined( 'ABSPATH' ) || exit;
define( 'CB_VERSION', '1.0.0' );
define( 'CB_FILE', __FILE__ );
define( 'CB_DIR', plugin_dir_path( __FILE__ ) );
define( 'CB_URL', plugin_dir_url( __FILE__ ) );
define( 'CB_OPTION', 'cb_services' );
define( 'CB_STYLE_OPTION', 'cb_style' );
define( 'CB_LICENSE_OPTION', 'cb_license' );
// Product slug used by the license backend (extensible: one slug per plugin).
define( 'CB_PRODUCT_SLUG', 'gdpr-content-blocker' );
// License backend base URL. Override via the cb_license_api_url filter or by
// defining CB_LICENSE_API_URL in wp-config.php before this file loads.
if ( ! defined( 'CB_LICENSE_API_URL' ) ) {
define( 'CB_LICENSE_API_URL', 'https://hub.lucas-orth.de' );
}
require_once CB_DIR . 'includes/class-settings.php';
require_once CB_DIR . 'includes/class-renderer.php';
require_once CB_DIR . 'includes/class-autodetect.php';
require_once CB_DIR . 'includes/class-license.php';
require_once CB_DIR . 'includes/class-updater.php';
add_action( 'plugins_loaded', 'cb_init' );
function cb_init(): void {
load_plugin_textdomain( 'gdpr-content-blocker', false, dirname( plugin_basename( CB_FILE ) ) . '/languages' );
CB_Settings::init();
CB_Renderer::init();
CB_Autodetect::init();
CB_License::init();
CB_Updater::init();
}
// Schedule / clear the daily license re-check.
register_activation_hook( __FILE__, [ 'CB_License', 'on_activation' ] );
register_deactivation_hook( __FILE__, [ 'CB_License', 'on_deactivation' ] );

View File

@@ -0,0 +1,109 @@
<?php
defined( 'ABSPATH' ) || exit;
/**
* Auto-detects iframes in the rendered HTML and replaces them with consent
* placeholders based on configured match patterns.
*
* Strategy (safe by design):
* 1. A regex LOCATES each <iframe>…</iframe> block (it does NOT parse attributes).
* 2. Each block is parsed individually with DOMDocument to read the src reliably.
* 3. Only the matched iframe block is replaced in the original HTML via callback.
*
* The rest of the page HTML is never re-serialized, so themes, page builders,
* inline JS and JSON-LD stay byte-for-byte intact.
*
* Known limitation: Only iframes present in the initial server-rendered HTML are
* covered here. For iframes injected later by JavaScript, use the manual shortcode
* [content_blocker id="…"] around the embed.
*/
class CB_Autodetect {
public static function init(): void {
add_action( 'template_redirect', [ __CLASS__, 'start_buffer' ] );
}
public static function start_buffer(): void {
if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
return;
}
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return;
}
if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
return;
}
$services = CB_Settings::get_services();
$active = array_values( array_filter(
$services,
fn( $s ) => ! empty( $s['match_pattern'] ) && ( $s['enabled'] ?? true )
) );
if ( empty( $active ) ) {
return;
}
ob_start( fn( string $html ) => self::process( $html, $active ) );
}
public static function process( string $html, array $services ): string {
if ( $html === '' || stripos( $html, '<iframe' ) === false ) {
return $html;
}
// Locate iframe blocks only. Attribute parsing happens via DOMDocument below.
return (string) preg_replace_callback(
'#<iframe\b[^>]*>.*?</iframe>#is',
function ( array $m ) use ( $services ): string {
return self::maybe_replace_iframe( $m[0], $services );
},
$html
);
}
/**
* Parse a single iframe block with DOMDocument, read its src, and return either
* the consent placeholder (on match) or the unchanged original block.
*/
private static function maybe_replace_iframe( string $iframe_html, array $services ): string {
$src = self::get_src( $iframe_html );
if ( $src === '' ) {
return $iframe_html;
}
foreach ( $services as $svc ) {
$pattern = $svc['match_pattern'] ?? '';
if ( $pattern !== '' && str_contains( $src, $pattern ) ) {
$attrs = CB_Renderer::extract_iframe_attrs( $iframe_html );
$dims = [ 'width' => $attrs['width'], 'height' => $attrs['height'] ];
return CB_Renderer::render_placeholder( $svc, $src, $dims );
}
}
return $iframe_html;
}
/** Reliably extract the src attribute of a single iframe via DOMDocument. */
private static function get_src( string $iframe_html ): string {
$dom = new DOMDocument();
libxml_use_internal_errors( true );
// Wrap so loadHTML has a clean context; force UTF-8 so umlauts survive.
$dom->loadHTML(
'<?xml encoding="utf-8"?><div>' . $iframe_html . '</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
$node = $dom->getElementsByTagName( 'iframe' )->item( 0 );
if ( ! $node instanceof DOMElement ) {
return '';
}
$src = trim( $node->getAttribute( 'src' ) );
// Normalise protocol-relative and HTML-entity-encoded ampersands.
$src = str_replace( '&amp;', '&', $src );
return $src;
}
}

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
}
}

View File

@@ -0,0 +1,285 @@
<?php
defined( 'ABSPATH' ) || exit;
class CB_Renderer {
public static function init(): void {
add_shortcode( 'content_blocker', [ __CLASS__, 'shortcode' ] );
add_shortcode( 'content_blocker_revoke', [ __CLASS__, 'revoke_shortcode' ] );
add_shortcode( 'content_blocker_services', [ __CLASS__, 'services_shortcode' ] );
add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_assets' ] );
}
public static function enqueue_assets(): void {
wp_enqueue_style(
'cb-frontend',
CB_URL . 'assets/frontend.css',
[],
CB_VERSION
);
$style = CB_Settings::get_style();
// Build one inline block: CSS variables first, the user's Custom-CSS LAST
// so it always overrides. Emitted as a single wp_add_inline_style call.
$inline = sprintf(
':root{--cb-text:%s;--cb-bg:%s;--cb-btn-bg:%s;--cb-btn-text:%s;--cb-btn-hover-bg:%s;--cb-btn-hover-text:%s;}',
esc_attr( $style['text_color'] ),
esc_attr( $style['bg_color'] ),
esc_attr( $style['button_bg'] ),
esc_attr( $style['button_text'] ),
esc_attr( $style['button_hover_bg'] ),
esc_attr( $style['button_hover_text'] )
);
if ( trim( (string) $style['custom_css'] ) !== '' ) {
$inline .= "\n/* gdpr-content-blocker custom css */\n" . $style['custom_css'];
}
wp_add_inline_style( 'cb-frontend', $inline );
wp_enqueue_script(
'cb-frontend',
CB_URL . 'assets/frontend.js',
[],
CB_VERSION,
true
);
}
/**
* Shortcode: [content_blocker id="google-maps"]<iframe ...></iframe>[/content_blocker]
*/
public static function shortcode( array $atts, ?string $content = null ): string {
$atts = shortcode_atts( [ 'id' => '' ], $atts, 'content_blocker' );
$id = sanitize_key( $atts['id'] );
if ( $id === '' ) {
return '';
}
$svc = CB_Settings::get_service( $id );
if ( $svc === null ) {
return '<!-- gdpr-content-blocker: unknown service id "' . esc_html( $id ) . '" -->';
}
// Disabled blocker → let the embedded content load normally (no blocking).
if ( ! ( $svc['enabled'] ?? true ) ) {
return $content ?? '';
}
// Extract src + dimensions from the inner iframe
$src = '';
$dims = [];
if ( $content !== null && $content !== '' ) {
$attrs = self::extract_iframe_attrs( $content );
$src = $attrs['src'];
$dims = [ 'width' => $attrs['width'], 'height' => $attrs['height'] ];
}
return self::render_placeholder( $svc, $src, $dims );
}
/**
* Shortcode: [content_blocker_revoke]
* Renders, by default, a visible text link (not a button) so it doesn't get
* confused with a cookie banner's revoke control. Attributes:
* text="…" custom link label
* style="link|button" default "link"
* note="yes|no" show the clarifying hint (default "yes")
*/
public static function revoke_shortcode( array|string $atts = [] ): string {
$atts = shortcode_atts( [
'text' => __( 'Einwilligung für externe Inhalte widerrufen', 'gdpr-content-blocker' ),
'style' => 'link',
'note' => 'yes',
], $atts, 'content_blocker_revoke' );
$class = $atts['style'] === 'button' ? 'cb-revoke-btn' : 'cb-revoke-link';
$out = '<a href="#" class="' . esc_attr( $class ) . '" role="button" '
. 'onclick="cbRevokeAll();return false;">'
. esc_html( $atts['text'] )
. '</a>';
if ( $atts['note'] === 'yes' ) {
$out .= '<span class="cb-revoke-note">'
. esc_html__( 'Betrifft nur die Freigabe externer Einbettungen (z. B. Karten, Videos). Cookie-Einstellungen werden separat verwaltet.', 'gdpr-content-blocker' )
. '</span>';
}
return $out;
}
/**
* Shortcode: [content_blocker_services]
* Lists every configured third-party service with the Art. 13 details
* (provider, recipient, third-country note, purpose, privacy link).
* Meant for embedding in the privacy policy.
*/
public static function services_shortcode(): string {
$services = CB_Settings::get_services();
if ( empty( $services ) ) {
return '';
}
$out = '<ul class="cb-services-list">';
foreach ( $services as $svc ) {
// Only list active blockers — disabled ones aren't being managed.
if ( ! ( $svc['enabled'] ?? true ) ) {
continue;
}
$name = esc_html( $svc['name'] ?? '' );
if ( $name === '' ) {
continue;
}
$out .= '<li class="cb-service-entry">';
$out .= '<span class="cb-service-entry__name">' . $name . '</span>';
$out .= '<dl>';
if ( ! empty( $svc['recipient'] ) ) {
$recipient = esc_html( $svc['recipient'] );
if ( ! empty( $svc['third_country'] ) ) {
$recipient .= ' (' . esc_html__( 'Übermittlung in ein Drittland außerhalb der EU/des EWR', 'gdpr-content-blocker' ) . ')';
}
$out .= '<dt>' . esc_html__( 'Empfänger', 'gdpr-content-blocker' ) . '</dt><dd>' . $recipient . '</dd>';
}
if ( ! empty( $svc['purpose'] ) ) {
$out .= '<dt>' . esc_html__( 'Zweck', 'gdpr-content-blocker' ) . '</dt><dd>' . esc_html( $svc['purpose'] ) . '</dd>';
}
$out .= '<dt>' . esc_html__( 'Setzt Cookies', 'gdpr-content-blocker' ) . '</dt><dd>'
. ( ! empty( $svc['sets_cookie'] ) ? esc_html__( 'Ja', 'gdpr-content-blocker' ) : esc_html__( 'Nein', 'gdpr-content-blocker' ) )
. '</dd>';
if ( ! empty( $svc['privacy_url'] ) ) {
$url = esc_url( $svc['privacy_url'] );
$out .= '<dt>' . esc_html__( 'Datenschutz', 'gdpr-content-blocker' ) . '</dt>'
. '<dd><a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a></dd>';
}
$out .= '</dl></li>';
}
$out .= '</ul>';
return $out;
}
/**
* Build the placeholder HTML for a given service.
* $src is the real iframe URL (empty string if unknown).
* $dims may contain 'width' and 'height' (numbers or e.g. "100%") taken
* from the original iframe so the placeholder reserves the same height.
*/
public static function render_placeholder( array $svc, string $src = '', array $dims = [] ): string {
$id = esc_attr( $svc['id'] );
$name = esc_html( $svc['name'] );
$recipient = esc_html( $svc['recipient'] );
$purpose = esc_html( $svc['purpose'] );
$privacy_url = esc_url( $svc['privacy_url'] ?? '' );
$third = ! empty( $svc['third_country'] );
$custom_text = $svc['placeholder_text'] ?? '';
if ( $custom_text !== '' ) {
$info_text = esc_html( $custom_text );
} else {
$info_text = sprintf(
/* translators: %s: provider name */
esc_html__( 'Um diesen Inhalt von %s zu laden, ist Ihre Einwilligung erforderlich. Dabei werden personenbezogene Daten (z. B. Ihre IP-Adresse) an den Anbieter übertragen.', 'gdpr-content-blocker' ),
'<strong>' . $name . '</strong>'
);
}
$third_note = '';
if ( $third ) {
$third_note = '<span class="cb-blocker__third-country">'
. esc_html__( '⚠ Datenübermittlung in ein Drittland außerhalb der EU/des EWR', 'gdpr-content-blocker' )
. '</span>';
}
$privacy_link = '';
if ( $privacy_url !== '' ) {
$privacy_link = '<a href="' . $privacy_url . '" target="_blank" rel="noopener noreferrer" class="cb-blocker__privacy-link">'
. esc_html__( 'Datenschutzerklärung des Anbieters', 'gdpr-content-blocker' )
. '</a>';
}
$data_src = $src !== '' ? ' data-src="' . esc_attr( $src ) . '"' : '';
// Reserve the embed's height so the layout doesn't jump, and remember the
// original dimensions for the iframe that gets created on consent.
$width = isset( $dims['width'] ) ? (string) $dims['width'] : '';
$height = isset( $dims['height'] ) ? (string) $dims['height'] : '';
$style_at = '';
if ( $height !== '' && ctype_digit( $height ) ) {
$style_at = ' style="min-height:' . esc_attr( $height ) . 'px"';
}
$data_dims = '';
if ( $width !== '' ) {
$data_dims .= ' data-width="' . esc_attr( $width ) . '"';
}
if ( $height !== '' ) {
$data_dims .= ' data-height="' . esc_attr( $height ) . '"';
}
return '<div class="cb-blocker" data-cb-id="' . $id . '"' . $data_src . $data_dims . $style_at . '>'
. '<div class="cb-blocker__inner">'
. '<p class="cb-blocker__text">' . $info_text . '</p>'
. '<p class="cb-blocker__recipient">'
. '<strong>' . esc_html__( 'Empfänger:', 'gdpr-content-blocker' ) . '</strong> '
. $recipient
. ( $third_note !== '' ? ' &mdash; ' . $third_note : '' )
. '</p>'
. '<p class="cb-blocker__purpose">'
. '<strong>' . esc_html__( 'Zweck:', 'gdpr-content-blocker' ) . '</strong> '
. $purpose
. '</p>'
. ( $privacy_link !== '' ? '<p>' . $privacy_link . '</p>' : '' )
. '<button type="button" class="cb-blocker__button" data-cb-id="' . $id . '">'
. sprintf(
/* translators: %s: provider name */
esc_html__( '%s jetzt laden', 'gdpr-content-blocker' ),
$name
)
. '</button>'
. '</div>'
. '</div>';
}
/** Pull the src attribute out of an iframe string. */
public static function extract_iframe_src( string $html ): string {
return self::extract_iframe_attrs( $html )['src'];
}
/**
* Pull src + width + height out of an iframe string.
* Height is also read from an inline style="height:NNNpx" if no attribute.
* Returns [ 'src' => string, 'width' => string, 'height' => string ].
*/
public static function extract_iframe_attrs( string $html ): array {
$src = '';
if ( preg_match( '/\bsrc=["\']([^"\']+)["\']/i', $html, $m ) ) {
$src = esc_url_raw( $m[1] );
}
$width = '';
if ( preg_match( '/\bwidth=["\']?(\d+(?:%|px)?)["\']?/i', $html, $m ) ) {
$width = $m[1];
}
$height = '';
if ( preg_match( '/\bheight=["\']?(\d+(?:%|px)?)["\']?/i', $html, $m ) ) {
$height = $m[1];
} elseif ( preg_match( '/height\s*:\s*(\d+)px/i', $html, $m ) ) {
$height = $m[1];
}
// Normalise "450px" → "450" so it can be used as a numeric attribute.
$width = preg_replace( '/px$/', '', $width );
$height = preg_replace( '/px$/', '', $height );
return [ 'src' => $src, 'width' => $width, 'height' => $height ];
}
}

View File

@@ -0,0 +1,746 @@
<?php
defined( 'ABSPATH' ) || exit;
class CB_Settings {
public static function init(): void {
add_action( 'admin_menu', [ __CLASS__, 'add_menu' ] );
add_action( 'admin_init', [ __CLASS__, 'register_settings' ] );
add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_admin_assets' ] );
add_action( 'admin_post_cb_save_services', [ __CLASS__, 'save_services' ] );
add_action( 'wp_ajax_cb_scan', [ __CLASS__, 'ajax_scan' ] );
}
public static function add_menu(): void {
add_options_page(
__( 'GDPR Content Blocker', 'gdpr-content-blocker' ),
__( 'GDPR Content Blocker', 'gdpr-content-blocker' ),
'manage_options',
'gdpr-content-blocker',
[ __CLASS__, 'render_page' ]
);
}
public static function register_settings(): void {
register_setting(
'cb_style_group',
CB_STYLE_OPTION,
[ 'sanitize_callback' => [ __CLASS__, 'sanitize_style' ] ]
);
}
public static function sanitize_style( mixed $input ): array {
$defaults = self::get_style_defaults();
if ( ! is_array( $input ) ) {
return $defaults;
}
$clean = [];
$color_keys = [
'text_color',
'bg_color',
'button_bg',
'button_text',
'button_hover_bg',
'button_hover_text',
];
foreach ( $color_keys as $key ) {
$val = isset( $input[ $key ] ) ? sanitize_text_field( $input[ $key ] ) : '';
$clean[ $key ] = self::sanitize_hex_color( $val ) ?? $defaults[ $key ];
}
$clean['custom_css'] = isset( $input['custom_css'] )
? wp_strip_all_tags( $input['custom_css'] )
: '';
return $clean;
}
private static function sanitize_hex_color( string $color ): ?string {
if ( preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color ) ) {
return $color;
}
return null;
}
public static function get_style_defaults(): array {
return [
'text_color' => '#ffffff',
'bg_color' => '#111111',
'button_bg' => '#2043B7',
'button_text' => '#ffffff',
'button_hover_bg' => '#1a369a',
'button_hover_text' => '#ffffff',
'custom_css' => '',
];
}
public static function get_style(): array {
$saved = get_option( CB_STYLE_OPTION, [] );
return wp_parse_args( is_array( $saved ) ? $saved : [], self::get_style_defaults() );
}
/** Returns all services from the option, always as a list. */
public static function get_services(): array {
$raw = get_option( CB_OPTION, [] );
return is_array( $raw ) ? array_values( $raw ) : [];
}
/** Returns a single service by id, or null. */
public static function get_service( string $id ): ?array {
foreach ( self::get_services() as $svc ) {
if ( isset( $svc['id'] ) && $svc['id'] === $id ) {
return $svc;
}
}
return null;
}
public static function save_services(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Keine Berechtigung.', 'gdpr-content-blocker' ) );
}
check_admin_referer( 'cb_save_services', 'cb_nonce' );
$raw_services = isset( $_POST['cb_services'] ) && is_array( $_POST['cb_services'] )
? $_POST['cb_services']
: [];
$services = [];
foreach ( $raw_services as $item ) {
if ( ! is_array( $item ) ) {
continue;
}
$id = sanitize_key( $item['id'] ?? '' );
if ( $id === '' ) {
continue;
}
$services[] = [
'id' => $id,
'name' => sanitize_text_field( $item['name'] ?? '' ),
'enabled' => ! empty( $item['enabled'] ),
'match_pattern' => sanitize_text_field( $item['match_pattern'] ?? '' ),
'recipient' => sanitize_text_field( $item['recipient'] ?? '' ),
'third_country' => ! empty( $item['third_country'] ),
'sets_cookie' => ! empty( $item['sets_cookie'] ),
'loads_script' => ! empty( $item['loads_script'] ),
'purpose' => sanitize_textarea_field( $item['purpose'] ?? '' ),
'privacy_url' => esc_url_raw( $item['privacy_url'] ?? '' ),
'placeholder_text' => sanitize_textarea_field( $item['placeholder_text'] ?? '' ),
];
}
update_option( CB_OPTION, $services );
wp_safe_redirect( admin_url( 'options-general.php?page=gdpr-content-blocker&cb_saved=1' ) );
exit;
}
public static function enqueue_admin_assets( string $hook ): void {
if ( $hook !== 'settings_page_gdpr-content-blocker' ) {
return;
}
wp_enqueue_style( 'wp-color-picker' );
wp_enqueue_script(
'cb-admin',
CB_URL . 'assets/admin.js',
[ 'wp-color-picker', 'jquery' ],
CB_VERSION,
true
);
wp_localize_script( 'cb-admin', 'cbAdmin', [
'confirmRemove' => __( 'Dienst wirklich entfernen?', 'gdpr-content-blocker' ),
'presets' => self::get_presets(),
'newServiceLbl' => __( 'Neuer Dienst', 'gdpr-content-blocker' ),
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'scanNonce' => wp_create_nonce( 'cb_scan' ),
'i18n' => [
'scanning' => __( 'Scanne Webseite …', 'gdpr-content-blocker' ),
'scanError' => __( 'Scan fehlgeschlagen:', 'gdpr-content-blocker' ),
'noFindings' => __( 'Keine externen Einbindungen gefunden.', 'gdpr-content-blocker' ),
'host' => __( 'Anbieter / Host', 'gdpr-content-blocker' ),
'type' => __( 'Typ', 'gdpr-content-blocker' ),
'count' => __( 'Anzahl', 'gdpr-content-blocker' ),
'example' => __( 'Beispiel-URL', 'gdpr-content-blocker' ),
'status' => __( 'Status', 'gdpr-content-blocker' ),
'action' => __( 'Aktion', 'gdpr-content-blocker' ),
'covered' => __( 'abgedeckt', 'gdpr-content-blocker' ),
'thirdParty' => __( 'Drittanbieter', 'gdpr-content-blocker' ),
'firstParty' => __( 'eigene Domain', 'gdpr-content-blocker' ),
'addService' => __( 'Als Dienst übernehmen', 'gdpr-content-blocker' ),
'scannedPages'=> __( 'Gescannte Seiten:', 'gdpr-content-blocker' ),
'foundOn' => __( 'Gefunden auf', 'gdpr-content-blocker' ),
],
] );
}
/**
* Ready-made templates for the most common third-party iframes.
* Everything stays editable after insertion.
*/
public static function get_presets(): array {
return [
'google-maps' => [
'id' => 'google-maps',
'name' => 'Google Maps',
'match_pattern' => 'google.com/maps',
'recipient' => 'Google Ireland Ltd., Irland / Google LLC, USA',
'third_country' => true,
'sets_cookie' => true,
'loads_script' => true,
'purpose' => __( 'Darstellung interaktiver Karten und Standortinformationen.', 'gdpr-content-blocker' ),
'privacy_url' => 'https://policies.google.com/privacy',
],
'youtube' => [
'id' => 'youtube',
'name' => 'YouTube',
'match_pattern' => 'youtube',
'recipient' => 'Google Ireland Ltd., Irland / Google LLC, USA',
'third_country' => true,
'sets_cookie' => true,
'loads_script' => true,
'purpose' => __( 'Einbettung und Wiedergabe von Videos.', 'gdpr-content-blocker' ),
'privacy_url' => 'https://policies.google.com/privacy',
],
'openstreetmap' => [
'id' => 'openstreetmap',
'name' => 'OpenStreetMap',
'match_pattern' => 'openstreetmap.org',
'recipient' => 'OpenStreetMap Foundation, Großbritannien',
'third_country' => true,
'sets_cookie' => false,
'loads_script' => true,
'purpose' => __( 'Darstellung interaktiver Karten.', 'gdpr-content-blocker' ),
'privacy_url' => 'https://wiki.osmfoundation.org/wiki/Privacy_Policy',
],
'vimeo' => [
'id' => 'vimeo',
'name' => 'Vimeo',
'match_pattern' => 'player.vimeo.com',
'recipient' => 'Vimeo LLC, USA',
'third_country' => true,
'sets_cookie' => true,
'loads_script' => true,
'purpose' => __( 'Einbettung und Wiedergabe von Videos.', 'gdpr-content-blocker' ),
'privacy_url' => 'https://vimeo.com/privacy',
],
];
}
public static function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$services = self::get_services();
$style = self::get_style();
$saved = isset( $_GET['cb_saved'] ) && $_GET['cb_saved'] === '1';
$settings_saved = isset( $_GET['settings-updated'] ) && $_GET['settings-updated'] === 'true';
?>
<div class="wrap cb-admin-wrap">
<h1><?php esc_html_e( 'GDPR Content Blocker', 'gdpr-content-blocker' ); ?></h1>
<?php if ( $saved || $settings_saved ) : ?>
<div class="notice notice-success is-dismissible">
<p><?php esc_html_e( 'Einstellungen gespeichert.', 'gdpr-content-blocker' ); ?></p>
</div>
<?php endif; ?>
<nav class="nav-tab-wrapper">
<a href="#cb-tab-services" class="nav-tab nav-tab-active" data-cb-tab="services">
<?php esc_html_e( 'Dienste', 'gdpr-content-blocker' ); ?>
</a>
<a href="#cb-tab-scan" class="nav-tab" data-cb-tab="scan">
<?php esc_html_e( 'Scan', 'gdpr-content-blocker' ); ?>
</a>
<a href="#cb-tab-style" class="nav-tab" data-cb-tab="style">
<?php esc_html_e( 'Darstellung', 'gdpr-content-blocker' ); ?>
</a>
<a href="#cb-tab-license" class="nav-tab" data-cb-tab="license">
<?php esc_html_e( 'Lizenz', 'gdpr-content-blocker' ); ?>
</a>
<a href="#cb-tab-about" class="nav-tab" data-cb-tab="about">
<?php esc_html_e( 'Über das Plugin', 'gdpr-content-blocker' ); ?>
</a>
</nav>
<!-- Services Tab -->
<div id="cb-tab-services" class="cb-tab-content">
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="cb_save_services">
<?php wp_nonce_field( 'cb_save_services', 'cb_nonce' ); ?>
<div id="cb-services-list">
<?php foreach ( $services as $i => $svc ) : ?>
<?php self::render_service_row( $i, $svc ); ?>
<?php endforeach; ?>
</div>
<div class="cb-add-row">
<button type="button" id="cb-add-service" class="button">
<?php esc_html_e( '+ Leeren Dienst hinzufügen', 'gdpr-content-blocker' ); ?>
</button>
<select id="cb-preset-select">
<option value=""><?php esc_html_e( '— Vorlage einfügen —', 'gdpr-content-blocker' ); ?></option>
<?php foreach ( self::get_presets() as $key => $preset ) : ?>
<option value="<?php echo esc_attr( $key ); ?>">
<?php echo esc_html( $preset['name'] ); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php submit_button( __( 'Dienste speichern', 'gdpr-content-blocker' ) ); ?>
</form>
<!-- Hidden template for JS -->
<template id="cb-service-template">
<?php self::render_service_row( '__INDEX__', [] ); ?>
</template>
</div>
<!-- Scan Tab -->
<div id="cb-tab-scan" class="cb-tab-content" style="display:none;">
<p class="description" style="max-width:760px;">
<?php esc_html_e( 'Der Lizenzserver besucht Ihre Webseite und listet alle eingebundenen Drittanbieter-Ressourcen (iframes, Skripte, Schriften, Bilder, …) auf. So sehen Sie auf einen Blick, welche externen Dienste Sie blockieren sollten. Erfordert eine aktive Lizenz.', 'gdpr-content-blocker' ); ?>
</p>
<p>
<button type="button" id="cb-scan-btn" class="button button-primary">
<?php esc_html_e( 'Webseite scannen', 'gdpr-content-blocker' ); ?>
</button>
<span id="cb-scan-status" style="margin-left:10px;"></span>
</p>
<div id="cb-scan-results"></div>
</div>
<!-- Style Tab -->
<div id="cb-tab-style" class="cb-tab-content" style="display:none;">
<form method="post" action="<?php echo esc_url( admin_url( 'options.php' ) ); ?>">
<?php
settings_fields( 'cb_style_group' );
self::render_style_fields( $style );
submit_button( __( 'Darstellung speichern', 'gdpr-content-blocker' ) );
?>
</form>
<?php self::render_usage_help(); ?>
</div>
<!-- License Tab -->
<div id="cb-tab-license" class="cb-tab-content" style="display:none;">
<?php CB_License::render_tab(); ?>
</div>
<!-- About Tab -->
<div id="cb-tab-about" class="cb-tab-content" style="display:none;">
<?php self::render_about(); ?>
</div>
</div>
<style>
.cb-admin-wrap .cb-service-box {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px 16px;
margin: 8px 0;
}
.cb-admin-wrap .cb-service-head {
display: flex;
align-items: center;
gap: 10px;
}
.cb-admin-wrap .cb-service-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 4px 6px;
color: #50575e;
}
.cb-admin-wrap .cb-service-title {
flex: 1;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.cb-admin-wrap .cb-service-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 24px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #eee;
}
/* toggle switch */
.cb-admin-wrap .cb-switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex: 0 0 auto;
}
.cb-admin-wrap .cb-switch input {
opacity: 0;
width: 0;
height: 0;
}
.cb-admin-wrap .cb-switch__slider {
position: absolute;
inset: 0;
background: #c3c4c7;
border-radius: 22px;
transition: background 0.2s;
}
.cb-admin-wrap .cb-switch__slider::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 3px;
top: 3px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.cb-admin-wrap .cb-switch input:checked + .cb-switch__slider {
background: #2043B7;
}
.cb-admin-wrap .cb-switch input:checked + .cb-switch__slider::before {
transform: translateX(18px);
}
.cb-admin-wrap .cb-field label {
display: block;
font-weight: 600;
margin-bottom: 4px;
}
.cb-admin-wrap .cb-field input[type="text"],
.cb-admin-wrap .cb-field input[type="url"],
.cb-admin-wrap .cb-field textarea {
width: 100%;
}
.cb-admin-wrap .cb-field-full {
grid-column: 1 / -1;
}
.cb-admin-wrap .cb-checks {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.cb-admin-wrap .cb-add-row {
display: flex;
gap: 10px;
align-items: center;
margin: 12px 0;
}
.cb-admin-wrap .cb-add-row select {
max-width: 240px;
}
</style>
<?php
}
public static function render_service_row( int|string $index, array $svc ): void {
$f = fn( string $key, string $default = '' ) => esc_attr( $svc[ $key ] ?? $default );
$b = fn( string $key ) => ! empty( $svc[ $key ] );
$idx = esc_attr( (string) $index );
$name = $svc['name'] ?? '';
$id = $svc['id'] ?? '';
$enabled = $svc['enabled'] ?? true; // default ON (new + legacy services)
// Header label: "interner-name — Anbietername"
$headname = trim( ( $id !== '' ? $id : __( 'neu', 'gdpr-content-blocker' ) ) . ( $name !== '' ? ' — ' . $name : '' ) );
?>
<div class="cb-service-box" data-cb-index="<?php echo $idx; ?>">
<div class="cb-service-head">
<button type="button" class="cb-service-toggle" aria-expanded="false" title="<?php esc_attr_e( 'Details anzeigen/ausblenden', 'gdpr-content-blocker' ); ?>">▸</button>
<span class="cb-service-title"><?php echo esc_html( $headname ); ?></span>
<label class="cb-switch" title="<?php esc_attr_e( 'Blocker aktiv/inaktiv', 'gdpr-content-blocker' ); ?>">
<input type="checkbox" class="cb-input-enabled" name="cb_services[<?php echo $idx; ?>][enabled]"
value="1" <?php checked( $enabled ); ?>>
<span class="cb-switch__slider"></span>
</label>
<button type="button" class="button-link-delete cb-remove-service">
<?php esc_html_e( 'Entfernen', 'gdpr-content-blocker' ); ?>
</button>
</div>
<div class="cb-service-grid" style="display:none;">
<div class="cb-field">
<label><?php esc_html_e( 'Interner Name (Slug)', 'gdpr-content-blocker' ); ?> *</label>
<input type="text" name="cb_services[<?php echo $idx; ?>][id]"
value="<?php echo $f( 'id' ); ?>" required
placeholder="google-maps" pattern="[a-z0-9\-]+" class="cb-input-id">
</div>
<div class="cb-field">
<label><?php esc_html_e( 'Anbietername', 'gdpr-content-blocker' ); ?> *</label>
<input type="text" name="cb_services[<?php echo $idx; ?>][name]"
value="<?php echo $f( 'name' ); ?>" required class="cb-input-name">
</div>
<div class="cb-field">
<label><?php esc_html_e( 'Erkennungsmuster (Domain/Pfad)', 'gdpr-content-blocker' ); ?></label>
<input type="text" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][match_pattern]"
value="<?php echo $f( 'match_pattern' ); ?>"
placeholder="google.com/maps">
</div>
<div class="cb-field">
<label><?php esc_html_e( 'Empfänger (inkl. Land)', 'gdpr-content-blocker' ); ?> *</label>
<input type="text" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][recipient]"
value="<?php echo $f( 'recipient' ); ?>"
placeholder="Google Ireland Ltd. / USA" required>
</div>
<div class="cb-field">
<label><?php esc_html_e( 'Datenschutz-URL des Anbieters', 'gdpr-content-blocker' ); ?></label>
<input type="url" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][privacy_url]"
value="<?php echo esc_attr( esc_url( $svc['privacy_url'] ?? '' ) ); ?>"
placeholder="https://policies.google.com/privacy">
</div>
<div class="cb-field cb-field-full">
<label><?php esc_html_e( 'Verarbeitungszweck', 'gdpr-content-blocker' ); ?> *</label>
<textarea name="cb_services[<?php echo esc_attr( (string) $index ); ?>][purpose]"
rows="2" required><?php echo esc_textarea( $svc['purpose'] ?? '' ); ?></textarea>
</div>
<div class="cb-field cb-field-full">
<label><?php esc_html_e( 'Individueller Platzhaltertext (leer = Standard)', 'gdpr-content-blocker' ); ?></label>
<textarea name="cb_services[<?php echo esc_attr( (string) $index ); ?>][placeholder_text]"
rows="2"><?php echo esc_textarea( $svc['placeholder_text'] ?? '' ); ?></textarea>
</div>
<div class="cb-field cb-field-full cb-checks">
<label>
<input type="checkbox" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][third_country]"
value="1" <?php checked( $b( 'third_country' ) ); ?>>
<?php esc_html_e( 'Datenübermittlung in Drittland (außerhalb EU/EWR)', 'gdpr-content-blocker' ); ?>
</label>
<label>
<input type="checkbox" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][sets_cookie]"
value="1" <?php checked( $b( 'sets_cookie' ) ); ?>>
<?php esc_html_e( 'Setzt Cookies', 'gdpr-content-blocker' ); ?>
</label>
<label>
<input type="checkbox" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][loads_script]"
value="1" <?php checked( $b( 'loads_script' ) ); ?>>
<?php esc_html_e( 'Lädt externe Skripte', 'gdpr-content-blocker' ); ?>
</label>
</div>
</div>
</div>
<?php
}
private static function render_style_fields( array $style ): void {
$fields = [
'text_color' => __( 'Textfarbe Platzhalter', 'gdpr-content-blocker' ),
'bg_color' => __( 'Hintergrundfarbe Platzhalter', 'gdpr-content-blocker' ),
'button_bg' => __( 'Button: Hintergrundfarbe', 'gdpr-content-blocker' ),
'button_text' => __( 'Button: Textfarbe', 'gdpr-content-blocker' ),
'button_hover_bg' => __( 'Button Hover: Hintergrundfarbe', 'gdpr-content-blocker' ),
'button_hover_text' => __( 'Button Hover: Textfarbe', 'gdpr-content-blocker' ),
];
echo '<table class="form-table" role="presentation"><tbody>';
foreach ( $fields as $key => $label ) {
$val = esc_attr( $style[ $key ] );
echo '<tr><th scope="row"><label>' . esc_html( $label ) . '</label></th>';
echo '<td><input type="text" class="cb-color-picker" name="' . esc_attr( CB_STYLE_OPTION . '[' . $key . ']' ) . '" value="' . $val . '" data-default-color="' . $val . '"></td></tr>';
}
echo '<tr><th scope="row"><label>' . esc_html__( 'Custom CSS', 'gdpr-content-blocker' ) . '</label></th>';
echo '<td><textarea name="' . esc_attr( CB_STYLE_OPTION . '[custom_css]' ) . '" rows="8" class="large-text code">' . esc_textarea( $style['custom_css'] ) . '</textarea>';
echo '<p class="description">' . esc_html__( 'Wird nach den CSS-Variablen eingebunden und kann diese überschreiben. Tipp: denselben Präfix verwenden, z. B. .cb-blocker .cb-blocker__button { … }', 'gdpr-content-blocker' ) . '</p></td></tr>';
echo '</tbody></table>';
}
/**
* Help box explaining shortcodes, revoke, auto-detection and CSS classes.
* Shown below "Darstellung speichern".
*/
private static function render_usage_help(): void {
$code = static fn( string $s ): string => '<code>' . esc_html( $s ) . '</code>';
?>
<hr style="margin:28px 0 18px;">
<h2><?php esc_html_e( 'Funktionen & Shortcodes', 'gdpr-content-blocker' ); ?></h2>
<table class="widefat striped" style="max-width:900px;">
<tbody>
<tr>
<td style="width:280px;vertical-align:top;"><strong><?php esc_html_e( 'Widerruf-Button (Pflicht in der Datenschutzerklärung)', 'gdpr-content-blocker' ); ?></strong><br>
<?php echo $code( '[content_blocker_revoke]' ); ?></td>
<td><?php esc_html_e( 'Rendert einen gut sichtbaren Link, der die Einwilligung für externe Einbettungen widerruft und die Seite neu lädt (Art. 7 Abs. 3 DSGVO). Betrifft NICHT die Cookie-Einwilligung eines separaten Cookie-Plugins. Optionen: text="…", style="link|button", note="yes|no". In die Datenschutzerklärung einfügen.', 'gdpr-content-blocker' ); ?></td>
</tr>
<tr>
<td style="vertical-align:top;"><strong><?php esc_html_e( 'Inhalt manuell blockieren', 'gdpr-content-blocker' ); ?></strong><br>
<?php echo $code( '[content_blocker id="google-maps"]…iframe…[/content_blocker]' ); ?></td>
<td><?php esc_html_e( 'Umschließt ein iframe und ersetzt es durch den Platzhalter des angegebenen Dienstes. Verwenden Sie den internen Namen aus dem Tab „Dienste". Ideal für Inhalte, die nicht automatisch erkannt werden (z. B. per JavaScript nachgeladene iframes).', 'gdpr-content-blocker' ); ?></td>
</tr>
<tr>
<td style="vertical-align:top;"><strong><?php esc_html_e( 'Dienste-Übersicht (für die Datenschutzerklärung)', 'gdpr-content-blocker' ); ?></strong><br>
<?php echo $code( '[content_blocker_services]' ); ?></td>
<td><?php esc_html_e( 'Listet alle konfigurierten Dienste mit Empfänger, Drittland-Hinweis, Zweck und Datenschutz-Link auf. Ideal zum Einbinden in die Datenschutzerklärung (Art. 13 DSGVO).', 'gdpr-content-blocker' ); ?></td>
</tr>
<tr>
<td style="vertical-align:top;"><strong><?php esc_html_e( 'Automatische Erkennung', 'gdpr-content-blocker' ); ?></strong></td>
<td><?php esc_html_e( 'iframes im Server-HTML, deren URL auf das „Erkennungsmuster" eines Dienstes passt, werden automatisch blockiert. Greift nur bei statischem HTML für JS-iframes den Shortcode oben nutzen.', 'gdpr-content-blocker' ); ?></td>
</tr>
<tr>
<td style="vertical-align:top;"><strong><?php esc_html_e( 'Webseiten-Scan', 'gdpr-content-blocker' ); ?></strong></td>
<td><?php esc_html_e( 'Im Tab „Scan" listet der Lizenzserver alle eingebundenen Drittanbieter auf. Erfordert eine aktive Lizenz.', 'gdpr-content-blocker' ); ?></td>
</tr>
<tr>
<td style="vertical-align:top;"><strong><?php esc_html_e( 'CSS-Klassen', 'gdpr-content-blocker' ); ?></strong></td>
<td>
<?php echo $code( '.cb-blocker' ); ?>,
<?php echo $code( '.cb-blocker__text' ); ?>,
<?php echo $code( '.cb-blocker__recipient' ); ?>,
<?php echo $code( '.cb-blocker__purpose' ); ?>,
<?php echo $code( '.cb-blocker__privacy-link' ); ?>,
<?php echo $code( '.cb-blocker__button' ); ?>,
<?php echo $code( '.cb-revoke-btn' ); ?>
<p class="description" style="margin-top:6px;"><?php esc_html_e( 'Eigene Regeln im Custom-CSS mit Präfix .cb-blocker schreiben, damit sie das Theme überschreiben.', 'gdpr-content-blocker' ); ?></p>
</td>
</tr>
</tbody>
</table>
<?php
}
/** "Über das Plugin" tab: description + legal links. */
private static function render_about(): void {
?>
<div style="max-width:760px;">
<h2>GDPR Content Blocker</h2>
<p class="description" style="font-size:13px;">
<?php
printf(
/* translators: %s: version */
esc_html__( 'Version %s', 'gdpr-content-blocker' ),
esc_html( CB_VERSION )
);
?>
</p>
<p>
<?php esc_html_e( 'GDPR Content Blocker lädt externe Einbindungen (z. B. Google Maps, YouTube, OpenStreetMap, Vimeo) erst nach aktiver Einwilligung der Besucher. Vor dem Klick wird keine Verbindung zum Drittanbieter aufgebaut es werden also keine personenbezogenen Daten (z. B. die IP-Adresse) übertragen. So lassen sich externe Inhalte DSGVO-konform einbinden, ohne ein schweres Consent-Tool.', 'gdpr-content-blocker' ); ?>
</p>
<h3><?php esc_html_e( 'Funktionen', 'gdpr-content-blocker' ); ?></h3>
<ul style="list-style:disc;padding-left:20px;">
<li><?php esc_html_e( 'Echtes Click-to-Load (kein vorab geladenes iframe)', 'gdpr-content-blocker' ); ?></li>
<li><?php esc_html_e( 'Granulare Einwilligung pro Dienst', 'gdpr-content-blocker' ); ?></li>
<li><?php esc_html_e( 'Art.-13-konformer Platzhalter mit Empfänger, Zweck, Drittland-Hinweis und Datenschutz-Link', 'gdpr-content-blocker' ); ?></li>
<li><?php esc_html_e( 'Einfacher Widerruf per Shortcode (Art. 7 Abs. 3 DSGVO)', 'gdpr-content-blocker' ); ?></li>
<li><?php esc_html_e( 'Automatische Erkennung gängiger Anbieter + manueller Shortcode', 'gdpr-content-blocker' ); ?></li>
</ul>
<h3><?php esc_html_e( 'Rechtliches', 'gdpr-content-blocker' ); ?></h3>
<p>
<a href="https://lucas-orth.de/impressum" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Impressum', 'gdpr-content-blocker' ); ?></a><br>
<a href="https://lucas-orth.de/datenschutz" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Datenschutzerklärung', 'gdpr-content-blocker' ); ?></a>
</p>
<p class="description">
<?php esc_html_e( 'Hinweis: Dieses Plugin ist ein technisches Hilfsmittel und ersetzt keine Rechtsberatung. Für die rechtskonforme Konfiguration (Empfänger, Zwecke, Datenschutzerklärung) ist der Seitenbetreiber verantwortlich.', 'gdpr-content-blocker' ); ?>
</p>
<p style="margin-top:20px;">
<?php
printf(
/* translators: %s: author link */
esc_html__( 'Entwickelt von %s', 'gdpr-content-blocker' ),
'<a href="https://lucas-orth.de" target="_blank" rel="noopener noreferrer">Lucas Orth</a>'
);
?>
</p>
</div>
<?php
}
/* ───────────────────────── Scan (AJAX) ───────────────────────── */
/**
* Build the list of URLs to scan: the home page plus a few published
* pages/posts, so the scan covers more than just the front page.
*/
private static function scan_urls(): array {
$urls = [ home_url( '/' ) ];
$posts = get_posts( [
'post_type' => [ 'page', 'post' ],
'post_status' => 'publish',
'numberposts' => 4,
'orderby' => 'comment_count', // roughly "most visited"
'order' => 'DESC',
'fields' => 'ids',
'no_found_rows' => true,
] );
foreach ( $posts as $pid ) {
$link = get_permalink( $pid );
if ( $link ) {
$urls[] = $link;
}
}
return array_values( array_unique( $urls ) );
}
public static function ajax_scan(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Keine Berechtigung.', 'gdpr-content-blocker' ) ], 403 );
}
check_ajax_referer( 'cb_scan', 'nonce' );
$lic = CB_License::get_license();
if ( ( $lic['status'] ?? '' ) !== 'active' ) {
wp_send_json_error( [ 'message' => __( 'Bitte zuerst eine Lizenz aktivieren (Tab „Lizenz").', 'gdpr-content-blocker' ) ] );
}
$response = wp_remote_post( CB_License::api_url() . '/api/v1/scan', [
'timeout' => 45,
'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json' ],
'body' => wp_json_encode( [
'key' => $lic['key'],
'product' => CB_PRODUCT_SLUG,
'domain' => CB_License::domain(),
'urls' => self::scan_urls(),
] ),
] );
if ( is_wp_error( $response ) ) {
wp_send_json_error( [ 'message' => $response->get_error_message() ] );
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) || empty( $data['ok'] ) ) {
$msg = is_array( $data ) && ! empty( $data['error'] )
? $data['error']
: __( 'Unerwartete Antwort vom Lizenzserver.', 'gdpr-content-blocker' );
wp_send_json_error( [ 'message' => $msg ] );
}
// Annotate each finding: is it already covered by a configured service?
$services = self::get_services();
$findings = is_array( $data['findings'] ?? null ) ? $data['findings'] : [];
foreach ( $findings as &$f ) {
$f['covered'] = self::is_covered( $f, $services );
}
unset( $f );
wp_send_json_success( [
'findings' => $findings,
'scanned' => $data['scanned'] ?? [],
] );
}
/** True if any configured service's match_pattern matches this finding. */
private static function is_covered( array $finding, array $services ): bool {
$haystacks = array_merge(
[ (string) ( $finding['host'] ?? '' ) ],
array_map( 'strval', (array) ( $finding['sample_urls'] ?? [] ) )
);
foreach ( $services as $svc ) {
$pattern = $svc['match_pattern'] ?? '';
if ( $pattern === '' ) {
continue;
}
foreach ( $haystacks as $h ) {
if ( $h !== '' && str_contains( $h, $pattern ) ) {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,139 @@
<?php
defined( 'ABSPATH' ) || exit;
/**
* Self-hosted update delivery. Asks the license backend whether a newer version
* exists and, if so, injects it into WordPress' update flow. The package is only
* served to licensed, activated sites (the backend gates the download).
*
* Without an active license nothing is offered (CB_License::gate_updates() is the
* additional safety net that strips any stray update entry).
*/
class CB_Updater {
const TRANSIENT = 'cb_update_info';
const CACHE_TTL = 6 * HOUR_IN_SECONDS;
public static function init(): void {
add_filter( 'pre_set_site_transient_update_plugins', [ __CLASS__, 'inject' ] );
add_filter( 'plugins_api', [ __CLASS__, 'plugin_info' ], 20, 3 );
add_action( 'upgrader_process_complete', [ __CLASS__, 'flush_cache' ], 10, 0 );
}
private static function basename(): string {
return plugin_basename( CB_FILE );
}
private static function slug(): string {
return dirname( self::basename() ); // e.g. "gdpr-content-blocker"
}
public static function flush_cache(): void {
delete_transient( self::TRANSIENT );
}
/**
* Fetch update info from the backend (cached). Returns the decoded array
* or null when unlicensed / on error.
*/
private static function fetch_info(): ?array {
if ( ! CB_License::is_active() ) {
return null;
}
$cached = get_transient( self::TRANSIENT );
if ( is_array( $cached ) ) {
return $cached;
}
$lic = CB_License::get_license();
$response = wp_remote_post( CB_License::api_url() . '/api/v1/update', [
'timeout' => 15,
'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json' ],
'body' => wp_json_encode( [
'key' => $lic['key'],
'product' => CB_PRODUCT_SLUG,
'domain' => CB_License::domain(),
'version' => CB_VERSION,
] ),
] );
if ( is_wp_error( $response ) ) {
return null;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) || empty( $data['ok'] ) ) {
return null;
}
set_transient( self::TRANSIENT, $data, self::CACHE_TTL );
return $data;
}
/** Inject the update into the plugins update transient. */
public static function inject( mixed $transient ): mixed {
if ( ! is_object( $transient ) ) {
return $transient;
}
$info = self::fetch_info();
if ( ! $info || empty( $info['update_available'] ) || empty( $info['package'] ) ) {
return $transient;
}
// Only offer if the backend's version is actually newer than ours.
if ( version_compare( $info['version'] ?? '0', CB_VERSION, '<=' ) ) {
return $transient;
}
$item = [
'slug' => self::slug(),
'plugin' => self::basename(),
'new_version' => (string) $info['version'],
'package' => esc_url_raw( $info['package'] ),
'url' => 'https://lucas-orth.de',
'tested' => (string) ( $info['tested'] ?? '' ),
'requires' => (string) ( $info['requires'] ?? '' ),
'requires_php'=> (string) ( $info['requires_php'] ?? '' ),
'icons' => [],
'banners' => [],
];
$transient->response[ self::basename() ] = (object) $item;
return $transient;
}
/**
* Provide the "View version details" popup content.
*/
public static function plugin_info( mixed $result, string $action, object $args ): mixed {
if ( $action !== 'plugin_information' ) {
return $result;
}
if ( empty( $args->slug ) || $args->slug !== self::slug() ) {
return $result;
}
$info = self::fetch_info();
if ( ! $info ) {
return $result;
}
return (object) [
'name' => 'GDPR Content Blocker',
'slug' => self::slug(),
'version' => (string) ( $info['version'] ?? CB_VERSION ),
'author' => '<a href="https://lucas-orth.de">Lucas Orth</a>',
'homepage' => 'https://lucas-orth.de',
'requires' => (string) ( $info['requires'] ?? '' ),
'tested' => (string) ( $info['tested'] ?? '' ),
'requires_php' => (string) ( $info['requires_php'] ?? '' ),
'download_link' => esc_url_raw( $info['package'] ?? '' ),
'sections' => [
'changelog' => wp_kses_post( $info['changelog'] ?? '' ),
],
];
}
}

View File

@@ -0,0 +1,125 @@
# Content Blocker German (de_DE)
# Copyright (C) 2024 Lucas Orth
# This file is distributed under the same license as the Content Blocker plugin.
msgid ""
msgstr ""
"Project-Id-Version: Content Blocker 1.0.0\n"
"Report-Msgid-Bugs-To: privat@lucas-orth.de\n"
"POT-Creation-Date: 2024-01-01 00:00+0000\n"
"PO-Revision-Date: 2024-01-01 00:00+0000\n"
"Last-Translator: Lucas Orth <privat@lucas-orth.de>\n"
"Language-Team: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Manual\n"
msgid "Content Blocker"
msgstr "Content Blocker"
msgid "DSGVO-konformer Consent-Blocker für externe iframes. Lädt Drittinhalte erst nach aktiver Einwilligung."
msgstr "DSGVO-konformer Consent-Blocker für externe iframes. Lädt Drittinhalte erst nach aktiver Einwilligung."
msgid "Keine Berechtigung."
msgstr "Keine Berechtigung."
msgid "Einstellungen gespeichert."
msgstr "Einstellungen gespeichert."
msgid "Dienste"
msgstr "Dienste"
msgid "Darstellung"
msgstr "Darstellung"
msgid "+ Dienst hinzufügen"
msgstr "+ Dienst hinzufügen"
msgid "Dienste speichern"
msgstr "Dienste speichern"
msgid "Darstellung speichern"
msgstr "Darstellung speichern"
msgid "Neuer Dienst"
msgstr "Neuer Dienst"
msgid "ID (Slug, intern)"
msgstr "ID (Slug, intern)"
msgid "Anbietername"
msgstr "Anbietername"
msgid "Erkennungsmuster (Domain/Pfad)"
msgstr "Erkennungsmuster (Domain/Pfad)"
msgid "Empfänger (inkl. Land)"
msgstr "Empfänger (inkl. Land)"
msgid "Datenschutz-URL des Anbieters"
msgstr "Datenschutz-URL des Anbieters"
msgid "Verarbeitungszweck"
msgstr "Verarbeitungszweck"
msgid "Individueller Platzhaltertext (leer = Standard)"
msgstr "Individueller Platzhaltertext (leer = Standard)"
msgid "Datenübermittlung in Drittland (außerhalb EU/EWR)"
msgstr "Datenübermittlung in Drittland (außerhalb EU/EWR)"
msgid "Setzt Cookies"
msgstr "Setzt Cookies"
msgid "Lädt externe Skripte"
msgstr "Lädt externe Skripte"
msgid "Entfernen"
msgstr "Entfernen"
msgid "Textfarbe Platzhalter"
msgstr "Textfarbe Platzhalter"
msgid "Hintergrundfarbe Platzhalter"
msgstr "Hintergrundfarbe Platzhalter"
msgid "Button: Hintergrundfarbe"
msgstr "Button: Hintergrundfarbe"
msgid "Button: Textfarbe"
msgstr "Button: Textfarbe"
msgid "Button Hover: Hintergrundfarbe"
msgstr "Button Hover: Hintergrundfarbe"
msgid "Button Hover: Textfarbe"
msgstr "Button Hover: Textfarbe"
msgid "Custom CSS"
msgstr "Custom CSS"
msgid "Wird nach den CSS-Variablen eingebunden und kann diese überschreiben."
msgstr "Wird nach den CSS-Variablen eingebunden und kann diese überschreiben."
msgid "Um diesen Inhalt von %s zu laden, ist Ihre Einwilligung erforderlich. Dabei werden personenbezogene Daten (z. B. Ihre IP-Adresse) an den Anbieter übertragen."
msgstr "Um diesen Inhalt von %s zu laden, ist Ihre Einwilligung erforderlich. Dabei werden personenbezogene Daten (z. B. Ihre IP-Adresse) an den Anbieter übertragen."
msgid "⚠ Datenübermittlung in ein Drittland außerhalb der EU/des EWR"
msgstr "⚠ Datenübermittlung in ein Drittland außerhalb der EU/des EWR"
msgid "Datenschutzerklärung des Anbieters"
msgstr "Datenschutzerklärung des Anbieters"
msgid "Empfänger:"
msgstr "Empfänger:"
msgid "Zweck:"
msgstr "Zweck:"
msgid "%s jetzt laden"
msgstr "%s jetzt laden"
msgid "Alle Einwilligungen widerrufen"
msgstr "Alle Einwilligungen widerrufen"

View File

@@ -0,0 +1,92 @@
=== GDPR Content Blocker ===
Contributors: lucasorth
Tags: dsgvo, gdpr, consent, iframe, datenschutz, gdpr-content-blocker
Requires at least: 6.0
Tested up to: 6.7
Requires PHP: 8.1
Stable tag: 1.0.0
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
DSGVO-konformer Consent-Blocker für externe iframes. Schlanke Alternative zu schweren Consent-Tools.
== Description ==
Content Blocker verhindert, dass externe Dienste (Google Maps, YouTube, OpenStreetMap, Bewerbungsportale etc.)
Daten übertragen, bevor der Nutzer aktiv eingewilligt hat. Kein iframe sendet vor dem Klick eine einzige
Anfrage an den Drittserver.
**Features:**
* Granulare Einwilligung pro Dienst (localStorage, je ein Key pro Service-ID)
* Art. 13 DSGVO-konformer Platzhalter: Anbieter, Empfänger, Zweck, Drittlandhinweis, Datenschutzlink
* Widerruf per Shortcode `[content_blocker_revoke]` — so einfach wie die Einwilligung (Art. 7 Abs. 3)
* Auto-Erkennung via DOMDocument (keine Regex-HTML-Parsing)
* Vorlagen für Google Maps, YouTube, OpenStreetMap, Vimeo (Ein-Klick, voll editierbar)
* Webseiten-Scan: listet alle eingebundenen Drittanbieter-Ressourcen auf (erfordert Lizenz)
* Platzhalter übernimmt automatisch die Höhe des eingebetteten Elements (kein Layout-Sprung)
* Manueller Shortcode `[content_blocker id="google-maps"]...[/content_blocker]`
* Stabile CSS-Klassen für vollständige Gestaltungsfreiheit per Custom-CSS
* Keine externen Abhängigkeiten, kein CDN, kein Composer
* PHP 8.1+, WordPress 6.0+
== Shortcodes ==
**Dienst blockieren:**
`[content_blocker id="google-maps"]<iframe src="https://maps.google.com/..." ...></iframe>[/content_blocker]`
**Widerruf für externe Inhalte (für Datenschutzerklärung):**
`[content_blocker_revoke]`
Rendert standardmäßig einen Text-Link. Betrifft nur die Freigabe externer
Einbettungen (nicht die Cookie-Einwilligung eines separaten Cookie-Plugins).
Optionen: `text="…"`, `style="link|button"`, `note="yes|no"`.
**Übersicht aller blockierten Dienste (für Datenschutzerklärung):**
`[content_blocker_services]`
== CSS-Klassen ==
| Klasse | Beschreibung |
|---|---|
| `.cb-blocker` | Wrapper / Platzhalter-Hintergrund |
| `.cb-blocker__inner` | Innerer Container (zentriert) |
| `.cb-blocker__text` | Hinweistext |
| `.cb-blocker__recipient` | Empfänger / Drittlandzeile |
| `.cb-blocker__purpose` | Zweck |
| `.cb-blocker__third-country` | Drittland-Warnhinweis |
| `.cb-blocker__privacy-link` | Link zur Anbieter-DSE |
| `.cb-blocker__button` | Lade-Button |
| `.cb-revoke-btn` | Widerrufs-Button |
CSS Custom Properties (via `:root`):
`--cb-text`, `--cb-bg`, `--cb-btn-bg`, `--cb-btn-text`, `--cb-btn-hover-bg`, `--cb-btn-hover-text`
Hinweis zur Spezifität: Die Plugin-Regeln sind unter `.cb-blocker` verschachtelt
(z. B. `.cb-blocker .cb-blocker__button`), damit Theme-Styles sie nicht überschreiben.
Ihr Custom-CSS wird DANACH geladen und überschreibt alles — verwenden Sie zur
Sicherheit denselben Präfix, z. B. `.cb-blocker .cb-blocker__button { ... }`.
== Updates ==
Updates werden über den eigenen Lizenzserver ausgeliefert und nur an Seiten mit
aktiver Lizenz angeboten. Der Update-Hinweis erscheint wie gewohnt unter
Plugins → Installierte Plugins. Ohne aktive Lizenz wird kein Update angeboten.
Veröffentlichung (für den Betreiber): Plugin-ZIP per Gitea-Actions (Tag `v*`) oder
manuell per curl an den Endpoint `POST /api/v1/releases` des Backends laden. Die
ZIP muss einen Ordner `gdpr-content-blocker/` auf oberster Ebene enthalten.
== Bekannte Grenzen ==
* Auto-Erkennung greift nur auf iframes im initialen Server-HTML. Durch JavaScript nachgeladene iframes
werden nicht automatisch erkannt. Für diese Fälle den manuellen Shortcode verwenden.
== Changelog ==
= 1.0.0 =
* Erstveröffentlichung
== Upgrade Notice ==
= 1.0.0 =
Erstveröffentlichung.

92
hilfsdaten/SPICKZETTEL.md Normal file
View File

@@ -0,0 +1,92 @@
# GDPR Content Blocker — Spickzettel (intern, NICHT ins Plugin)
Dieser Ordner liegt **außerhalb** des Plugin-Repos und wird nicht ausgeliefert.
Hier nur Hilfsdaten/Notizen für mich speichern.
## Wichtige URLs
- Gitea (Plugin): https://gitea.lucas-orth.de/lucas.orth/GDPR-Content-Blocker.git
- Lizenz-Backend: https://hub.lucas-orth.de
- Impressum: https://lucas-orth.de/impressum
- Datenschutz: https://lucas-orth.de/datenschutz
## Ordnerstruktur (lokal)
- `gdpr-content-blocker/` → das Plugin (Git-Repo, → Gitea). NUR Plugin-Dateien.
- `license-backend/` → Docker-Backend (separat).
- `hilfsdaten/` → dieser Ordner (Notizen, n8n, Skripte).
## Geheimnisse (NICHT hier im Klartext ablegen!)
- ADMIN_API_TOKEN → in license-backend/.env (openssl rand -hex 32)
- DOWNLOAD_SECRET → in license-backend/.env (openssl rand -hex 32)
- Gitea-Secrets: LICENSE_BACKEND_URL, LICENSE_ADMIN_TOKEN (Repo → Settings → Actions → Secrets)
---
## Backend starten / verwalten (auf dem Server)
```bash
cd /pfad/zu/license-backend
cp .env.example .env # einmalig, dann ausfüllen
docker compose up -d --build
docker compose logs -f
curl https://hub.lucas-orth.de/healthz
```
## Lizenzschlüssel erzeugen (manuell oder via n8n)
```bash
curl -X POST https://hub.lucas-orth.de/api/v1/licenses \
-H "X-Admin-Token: ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"product":"gdpr-content-blocker","max_activations":1,"email":"kunde@x.de"}'
```
max_activations: 1 = Einzelseite, 3 = drei Seiten, -1 = unbegrenzt.
## Lizenz verwalten
```bash
# ansehen (Status + Domains)
curl https://hub.lucas-orth.de/api/v1/licenses/KEY -H "X-Admin-Token: TOKEN"
# sperren / entsperren
curl -X POST https://hub.lucas-orth.de/api/v1/licenses/KEY/disable -H "X-Admin-Token: TOKEN"
curl -X POST https://hub.lucas-orth.de/api/v1/licenses/KEY/enable -H "X-Admin-Token: TOKEN"
```
---
## Neue Plugin-Version ausliefern (Update)
WICHTIG: interner Plugin-Slug = `gdpr-content-blocker` (NICHT umbenennen — Lizenz/Update
hängen daran). Repo-/Ordnername = gdpr-content-blocker, das ist nur kosmetisch.
### Automatisch (mit Gitea Act Runner)
```bash
cd .../gdpr-content-blocker
# Version in gdpr-content-blocker.php (Header + CB_VERSION) bumpen, committen
git add -A && git commit -m "release: 1.0.1"
git tag v1.0.1
git push origin main
git push origin v1.0.1 # löst den Release-Workflow aus
```
### Manuell (ohne Runner)
```bash
cd .../gdpr-content-blocker
git archive --format=zip --prefix=gdpr-content-blocker/ -o gdpr-content-blocker.zip HEAD
curl -fSs -X POST "https://hub.lucas-orth.de/api/v1/releases?product=gdpr-content-blocker&version=1.0.1" \
-H "X-Admin-Token: ADMIN_TOKEN" -H "Content-Type: application/zip" \
--data-binary @gdpr-content-blocker.zip
```
### Prüfen
```bash
curl https://hub.lucas-orth.de/api/v1/releases/gdpr-content-blocker -H "X-Admin-Token: TOKEN"
```
## Erstinstallation auf einer Kundenseite
1. ZIP einmalig manuell in WordPress hochladen (Plugins → Installieren → Hochladen).
2. Aktivieren → Einstellungen → GDPR Content Blocker → Tab „Lizenz" → Key eintragen.
3. Künftige Updates kommen automatisch (sobald höhere Version im Backend liegt).
## Tests lokal
```bash
# Backend
cd license-backend
npm run test:unit
npm run test:integration # braucht express in node_modules
```

View File

@@ -0,0 +1,41 @@
# Gitea Act Runner Einrichtung
Einmalig nötig, damit der Release-Workflow (Tag `v*`) automatisch baut + hochlädt.
## 1. Registrierungs-Token holen
Gitea → Repo **GDPR-Content-Blocker****Settings → Actions → Runner**
Button **„Neuen Runner erstellen"**. Es erscheint ein **Registration Token**
(eine lange Zeichenkette). Kopieren.
(Alternativ instanzweit: Admin-Bereich → Actions → Runners → Create new Runner.)
## 2. Token eintragen
In `docker-compose.yml` bei `GITEA_RUNNER_REGISTRATION_TOKEN` einsetzen.
## 3. Runner starten (auf dem Server, wo Docker läuft)
```bash
cd /pfad/zu/gitea-runner
docker compose up -d
docker compose logs -f # "runner registered successfully" abwarten
```
Danach in Gitea unter Settings → Actions → Runner taucht „hub-runner" mit
Status **idle/online** auf. Ab jetzt laufen Workflows automatisch.
## 4. Testen
Im Plugin-Repo:
```bash
git tag v1.0.1
git push origin v1.0.1
```
Gitea → Repo → Tab **Actions** zeigt den Lauf. Bei Erfolg ist die ZIP im Backend:
```bash
curl https://hub.lucas-orth.de/api/v1/releases/gdpr-content-blocker -H "X-Admin-Token: TOKEN"
```
## Hinweise
- Der Runner braucht Zugriff auf den Docker-Socket (startet die Job-Container).
- Erstes Image (catthehacker/ubuntu) wird beim ersten Lauf gezogen → dauert kurz.
- Das `data/`-Verzeichnis enthält nach der Registrierung die Runner-Identität
(`.runner`). Nicht löschen, sonst neu registrieren.
- Token wird nur einmal gebraucht; nach erfolgreicher Registrierung ignoriert.

View File

@@ -0,0 +1,18 @@
services:
gitea-runner:
image: gitea/act_runner:latest
container_name: gitea-runner
restart: always
environment:
GITEA_INSTANCE_URL: https://gitea.lucas-orth.de
# Registrierungs-Token aus: Repo → Settings → Actions → Runner →
# "Neuen Runner erstellen". Nur fuer die ERSTE Registrierung noetig.
GITEA_RUNNER_REGISTRATION_TOKEN: "HIER_TOKEN_EINFUEGEN"
GITEA_RUNNER_NAME: hub-runner
# Bildet das im Workflow genutzte "ubuntu-latest" auf ein Image ab,
# das zip, rsync, curl, git und node bereits enthaelt.
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
volumes:
- ./data:/data
# Docker-Socket: der Runner startet die Job-Container ueber den Host-Docker.
- /var/run/docker.sock:/var/run/docker.sock

View File

@@ -0,0 +1,7 @@
node_modules
npm-debug.log
.env
data
*.db
*.db-*
.git

View File

@@ -0,0 +1,27 @@
# Copy to .env and fill in. NEVER commit the real .env.
# Strong random token the n8n workflow (and you) use for admin endpoints.
# Generate e.g. with: openssl rand -hex 32
ADMIN_API_TOKEN=change-me-to-a-long-random-string
# Public port on the host (container always listens on 8080 internally).
PORT=8080
# Comma-separated product slugs to seed on boot ("slug:Display Name").
# Add a new entry here whenever you ship another plugin.
SEED_PRODUCTS=gdpr-content-blocker:GDPR Content Blocker
# Absolute, public base URL of this backend (behind your TLS reverse proxy).
# Used to build the package download links handed to WordPress.
PUBLIC_BASE_URL=https://hub.lucas-orth.de
# Secret for signing time-limited download tokens. Generate with:
# openssl rand -hex 32
# If left empty, ADMIN_API_TOKEN is used as a fallback.
DOWNLOAD_SECRET=
# Name of the existing Docker network that Nginx Proxy Manager runs on, so NPM
# can reach this container as "license-backend:8080". Find it with:
# docker network ls
# Typical values: npm_default, nginxproxymanager_default, proxy
NPM_NETWORK=npm_default

7
license-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.env
data/
*.db
*.db-shm
*.db-wal
npm-debug.log*

View File

@@ -0,0 +1,24 @@
# Node 20 on glibc so better-sqlite3 uses prebuilt binaries (no native build).
FROM node:20-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
# Install dependencies first for better layer caching.
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
# App source.
COPY src ./src
# Persistent data directory (mounted as a volume in compose).
RUN mkdir -p /data && chown -R node:node /data /app
VOLUME ["/data"]
USER node
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "src/server.js"]

170
license-backend/README.md Normal file
View File

@@ -0,0 +1,170 @@
# License Backend
Self-hosted, multi-product license server for WordPress plugins.
Stateless API + SQLite on a persistent volume. No prices anywhere — tiers are
expressed purely as **activation limits** (1 / 3 / -1 = unlimited). Pricing lives
on your website, not here.
## Architecture
```
PayPal (one-time payment)
│ webhook
n8n workflow
│ POST /api/v1/licenses (X-Admin-Token) ← generate key
License Backend (this container) ──► SQLite @ /data (Docker volume, survives redeploy)
│ returns { key }
n8n → e-mail key to customer + trigger invoice separately
```
The plugin later calls the **public** endpoints (`/activate`, `/validate`,
`/deactivate`) with the key, its product slug and the site domain.
## Run
```bash
cp .env.example .env
# edit .env: set a strong ADMIN_API_TOKEN (openssl rand -hex 32)
docker compose up -d --build
```
Put a TLS-terminating reverse proxy (Caddy / nginx / Traefik) in front and point
`hub.lucas-orth.de` at it. The container speaks plain HTTP on its port.
### Data persistence
The SQLite DB lives in the named volume `license-data` mounted at `/data`.
`docker compose down && up` or a fresh image build keeps all keys and
activations. Only `docker compose down -v` would wipe it.
Back up with:
```bash
docker run --rm -v license-data:/data -v "$PWD":/backup alpine \
sh -c "cp /data/license.db /backup/license-backup-$(date +%F).db"
```
## Adding a new plugin (extensibility)
Each plugin = one **product slug**. Two ways to add one:
1. Edit `SEED_PRODUCTS` in `.env` (`gdpr-content-blocker:Content Blocker,my-next-plugin:My Next Plugin`) and restart — idempotent, existing data untouched.
2. At runtime: `POST /api/v1/products` (admin) with `{ "slug": "...", "name": "..." }`.
Keys are always issued **per product**, so one backend serves all your plugins.
## API
### Admin (header `X-Admin-Token: <token>`)
**Generate a key — this is what n8n calls:**
```bash
curl -X POST https://hub.lucas-orth.de/api/v1/licenses \
-H "X-Admin-Token: $ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "product": "gdpr-content-blocker", "max_activations": 1, "email": "kunde@example.com" }'
# → 201 { "ok": true, "key": "ABCD-EFGH-JKMN-PQRS", ... }
```
`max_activations`: `1` (single site), `3` (three sites), `-1` (unlimited).
Optional: `email`, `note`, `expires_at` (ISO date; omit for lifetime).
| Method | Path | Purpose |
|---|---|---|
| POST | `/api/v1/licenses` | generate a key |
| GET | `/api/v1/licenses/:key` | inspect (status + bound domains) |
| POST | `/api/v1/licenses/:key/disable` | revoke |
| POST | `/api/v1/licenses/:key/enable` | un-revoke |
| GET | `/api/v1/products` | list products |
| POST | `/api/v1/products` | add a product |
| POST | `/api/v1/releases?product=&version=` | upload a plugin ZIP (raw body) |
| GET | `/api/v1/releases/:product` | list releases |
**Upload a new plugin version** (raw `.zip` as the body — no multipart):
```bash
curl -X POST "https://hub.lucas-orth.de/api/v1/releases?product=gdpr-content-blocker&version=1.1.0" \
-H "X-Admin-Token: $ADMIN_API_TOKEN" \
-H "Content-Type: application/zip" \
-H "X-Changelog: Fixes XYZ" \
-H "X-Tested: 6.7" -H "X-Requires-PHP: 8.1" \
--data-binary @gdpr-content-blocker.zip
```
The ZIP **must** contain a top-level `gdpr-content-blocker/` folder (so WordPress
extracts it into the right plugin directory). Stored under
`/data/releases/<product>/<version>.zip` (persists across redeploys).
### Public (called by the plugin)
| Method | Path | Body | Purpose |
|---|---|---|---|
| POST | `/api/v1/activate` | `{ key, product, domain }` | bind a domain, enforce limit |
| POST | `/api/v1/validate` | `{ key, product, domain }` | daily re-check |
| POST | `/api/v1/deactivate` | `{ key, product, domain }` | free a slot |
| POST | `/api/v1/scan` | `{ key, product, domain, urls? }` | visit the site, list embedded third-party resources |
**Scan** is SSRF-guarded on three levels: (1) the requesting domain must be an
**activated** slot of the license; (2) every target URL's host must equal that
domain; (3) the host is DNS-resolved and refused if it points at a private,
loopback or link-local IP (e.g. `169.254.169.254` cloud metadata). Redirects are
**not** followed (`redirect: manual`) so a 30x can't bounce the fetch into an
internal host. `urls` is optional — defaults to `https://<domain>/`; max 10 URLs,
10 s timeout, 2 MB per page. Residual risk: DNS-rebinding (TOCTOU between resolve
and fetch) is not fully closed; run the backend in a network segment without
access to sensitive internal services.
Response:
```json
{
"ok": true,
"scanned": [{ "url": "https://kunde.de/", "error": null }],
"findings": [
{ "host": "google.com", "third_party": true, "types": ["iframe"],
"count": 1, "sample_urls": ["https://www.google.com/maps/embed?..."],
"suggested_pattern": "google.com" }
]
}
```
Success: `{ "ok": true, "status": "valid", "activations_used": 1, "max_activations": 1 }`
Failure: `{ "ok": false, "error": "Maximale Anzahl an Domains für diese Lizenz erreicht" }`
**Update flow** (called by the plugin's updater):
| Method | Path | Body / Query | Purpose |
|---|---|---|---|
| POST | `/api/v1/update` | `{ key, product, domain, version }` | is there a newer version? returns a signed package URL |
| GET | `/api/v1/download` | `?token=…` | stream the ZIP; token is HMAC-signed, 7-day TTL, license re-checked live |
`/update` returns either `{ ok:true, update_available:false }` or
`{ ok:true, update_available:true, version, package, changelog, requires, tested, requires_php }`.
The `package` URL embeds a signed token (key+product+version+expiry); the download
endpoint verifies the signature **and** re-checks that the license is still active
before streaming — so a leaked URL stops working once the license is revoked.
`GET /healthz``{ ok: true }` (used by the Docker healthcheck).
## Shipping a release from Gitea
Tag a release in your plugin repo and let Gitea Actions build the ZIP and push it
here. Example `.gitea/workflows/release.yml` lives in the **plugin** repo; it runs
`on: push: tags: ['v*']`, zips the `gdpr-content-blocker/` folder, and `curl`s it to
`POST /api/v1/releases`. Store `ADMIN_API_TOKEN` and the backend URL as Gitea
**secrets**. You can also just run that `curl` by hand for a one-off release.
## n8n workflow (outline)
1. **Webhook / PayPal trigger** — receives the IPN/webhook of a completed payment.
2. **Map item → tier**: set `max_activations` from the purchased item
(your website knows which PayPal button = which tier; the backend never sees a price).
3. **HTTP Request**`POST {backend}/api/v1/licenses` with `X-Admin-Token`, body
`{ product, max_activations, email }`.
4. **Send e-mail** with `{{$json.key}}` to the customer.
5. **Trigger invoice** (separate node / sub-workflow).
## Security notes
- `ADMIN_API_TOKEN` is required; the server refuses to boot without it.
- Admin auth uses a timing-safe comparison.
- Run only behind HTTPS. Restrict the admin endpoints at the proxy to n8n's IP if possible.
- The container runs as the non-root `node` user.
```

View File

@@ -0,0 +1,33 @@
services:
license-backend:
build: .
image: license-backend:latest
container_name: license-backend
restart: unless-stopped
env_file: .env
environment:
- PORT=8080
- DATA_DIR=/data
# No public port mapping: Nginx Proxy Manager reaches the container over the
# shared Docker network as "license-backend:8080". (For a quick local test
# without NPM, uncomment the ports block below.)
# ports:
# - "127.0.0.1:8080:8080"
expose:
- "8080"
volumes:
# Named volume → data (SQLite DB) survives container recreation/redeploy.
- license-data:/data
networks:
- npm
networks:
# The network Nginx Proxy Manager already runs on. Find it with:
# docker network ls
# then set NPM_NETWORK in .env (e.g. NPM_NETWORK=npm_default).
npm:
external: true
name: ${NPM_NETWORK:-npm_default}
volumes:
license-data:

View File

@@ -0,0 +1,22 @@
{
"name": "license-backend",
"version": "1.0.0",
"description": "Self-hosted license server for WordPress plugins (extensible, multi-product).",
"author": "Lucas Orth",
"license": "UNLICENSED",
"private": true,
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test:unit": "node test/util.test.mjs && node test/scan.test.mjs",
"test:integration": "node --import ./test/register.mjs ./test/integration.mjs"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.21.0"
}
}

79
license-backend/src/db.js Normal file
View File

@@ -0,0 +1,79 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const DATA_DIR = process.env.DATA_DIR || '/data';
const DB_PATH = `${DATA_DIR}/license.db`;
mkdirSync(dirname(DB_PATH), { recursive: true });
export const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
max_activations INTEGER NOT NULL, -- -1 = unlimited
status TEXT NOT NULL DEFAULT 'active', -- active | disabled
email TEXT,
note TEXT,
created_at TEXT NOT NULL,
expires_at TEXT -- NULL = lifetime
);
CREATE TABLE IF NOT EXISTS activations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id INTEGER NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
domain TEXT NOT NULL,
created_at TEXT NOT NULL,
last_check TEXT NOT NULL,
UNIQUE(license_id, domain)
);
CREATE TABLE IF NOT EXISTS releases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
version TEXT NOT NULL,
zip_path TEXT NOT NULL,
changelog TEXT,
requires TEXT, -- min WordPress version
tested TEXT, -- tested-up-to WordPress version
requires_php TEXT,
created_at TEXT NOT NULL,
UNIQUE(product_id, version)
);
CREATE INDEX IF NOT EXISTS idx_licenses_key ON licenses(key);
CREATE INDEX IF NOT EXISTS idx_activations_license ON activations(license_id);
CREATE INDEX IF NOT EXISTS idx_releases_product ON releases(product_id);
`);
/**
* Seed product slugs from the SEED_PRODUCTS env var (comma separated
* "slug:Name" pairs). Safe to run on every boot — only inserts missing ones.
* This is how the backend stays extensible: add a slug here for each new plugin.
*/
export function seedProducts() {
const raw = process.env.SEED_PRODUCTS || 'gdpr-content-blocker:GDPR Content Blocker';
const now = new Date().toISOString();
const insert = db.prepare(
`INSERT OR IGNORE INTO products (slug, name, created_at) VALUES (?, ?, ?)`
);
for (const pair of raw.split(',')) {
const [slug, ...nameParts] = pair.split(':');
const s = (slug || '').trim();
if (!s) continue;
const name = (nameParts.join(':').trim()) || s;
insert.run(s, name, now);
}
}

132
license-backend/src/scan.js Normal file
View File

@@ -0,0 +1,132 @@
// Website scanner: given fetched HTML, list third-party resources (iframes,
// scripts, images, etc.) so the site owner sees which external providers are
// embedded. Pure functions — no network here, so they are unit-testable.
const TAG_RE = /<(iframe|script|img|link|source|video|audio|embed|object)\b([^>]*)>/gi;
function attr(tagBody, name) {
const m = tagBody.match(new RegExp(name + '\\s*=\\s*["\\\']([^"\\\']+)["\\\']', 'i'));
return m ? m[1] : '';
}
function normHost(h) {
return String(h || '').toLowerCase().replace(/:.*$/, '').replace(/^www\./, '');
}
/** Extract absolute resource URLs (with a type tag) from one HTML document. */
export function extractResources(html, baseUrl) {
const out = [];
let m;
TAG_RE.lastIndex = 0;
while ((m = TAG_RE.exec(html)) !== null) {
const tag = m[1].toLowerCase();
const body = m[2];
let raw = '';
if (tag === 'object') raw = attr(body, 'data');
else if (tag === 'link') raw = attr(body, 'href');
else raw = attr(body, 'src');
if (!raw) continue;
if (/^(data:|blob:|javascript:|mailto:|tel:|#|about:)/i.test(raw)) continue;
let abs;
try {
abs = new URL(raw, baseUrl).href;
} catch {
continue;
}
if (!/^https?:/i.test(abs)) continue;
out.push({ url: abs, type: tag });
}
return out;
}
/**
* Aggregate resources from one or more scanned pages into per-host findings.
* `pages` = [{ url, resources: [{url,type}] }]. `siteHost` is the first-party host.
*/
export function analyze(pages, siteHost) {
const site = normHost(siteHost);
const byHost = new Map();
for (const page of pages) {
for (const r of page.resources || []) {
let host;
try {
host = normHost(new URL(r.url).host);
} catch {
continue;
}
const thirdParty = !(host === site || host.endsWith('.' + site));
if (!byHost.has(host)) {
byHost.set(host, { host, third_party: thirdParty, types: new Set(), sample_urls: [], pages: new Set(), count: 0 });
}
const f = byHost.get(host);
f.count++;
f.types.add(r.type);
if (page.url) f.pages.add(page.url);
if (f.sample_urls.length < 3 && !f.sample_urls.includes(r.url)) {
f.sample_urls.push(r.url);
}
}
}
return [...byHost.values()]
.map((f) => ({
host: f.host,
third_party: f.third_party,
types: [...f.types].sort(),
sample_urls: f.sample_urls,
pages: [...f.pages], // which scanned pages embed this host
count: f.count,
// A sensible default match_pattern suggestion for the plugin.
suggested_pattern: f.host,
}))
.sort((a, b) => (a.third_party === b.third_party ? b.count - a.count : a.third_party ? -1 : 1));
}
/** Reject loopback / private-range literals to limit SSRF blast radius. */
export function isPublicHost(host) {
const h = normHost(host);
if (h === 'localhost' || h === '' || h.endsWith('.localhost')) return false;
if (/^127\./.test(h) || h === '::1' || h === '0.0.0.0') return false;
if (/^10\./.test(h)) return false;
if (/^192\.168\./.test(h)) return false;
if (/^169\.254\./.test(h)) return false;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return false;
return true;
}
/**
* True if a resolved IP address is private/loopback/link-local and must not be
* fetched. Used after DNS resolution to block SSRF where a public hostname
* resolves to an internal address (e.g. cloud metadata 169.254.169.254).
* Unknown/unparseable → treated as unsafe.
*/
export function isPrivateIp(ip) {
if (!ip) return true;
const s = String(ip).toLowerCase();
if (s === '::1') return true; // IPv6 loopback
if (s.startsWith('fe80')) return true; // IPv6 link-local
if (s.startsWith('fc') || s.startsWith('fd')) return true; // IPv6 unique-local
// IPv4 (incl. IPv4-mapped IPv6 ::ffff:a.b.c.d)
const mapped = s.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
const ip4 = mapped ? mapped[1] : s;
if (/^\d+\.\d+\.\d+\.\d+$/.test(ip4)) {
const p = ip4.split('.').map(Number);
if (p.some((n) => n > 255)) return true; // malformed → unsafe
if (p[0] === 0 || p[0] === 10 || p[0] === 127) return true;
if (p[0] === 169 && p[1] === 254) return true; // link-local + metadata
if (p[0] === 192 && p[1] === 168) return true;
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true; // CGNAT
return false;
}
return false; // global IPv6 / other → allow
}

View File

@@ -0,0 +1,574 @@
import express from 'express';
import { lookup } from 'node:dns/promises';
import { mkdirSync, writeFileSync, existsSync, statSync, createReadStream } from 'node:fs';
import { join } from 'node:path';
import { db, seedProducts } from './db.js';
import {
generateKey,
normalizeDomain,
safeEqual,
nowIso,
isExpired,
compareVersions,
signToken,
verifyToken,
} from './util.js';
import { extractResources, analyze, isPublicHost, isPrivateIp } from './scan.js';
const MAX_SCAN_URLS = 10;
const MAX_SCAN_BYTES = 2_000_000;
const SCAN_TIMEOUT_MS = 10_000;
const DATA_DIR = process.env.DATA_DIR || '/data';
const RELEASES_DIR = join(DATA_DIR, 'releases');
const MAX_ZIP_BYTES = 50 * 1024 * 1024;
const DOWNLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// Secret for signing download tokens. Falls back to the admin token if unset.
const DOWNLOAD_SECRET = process.env.DOWNLOAD_SECRET || process.env.ADMIN_API_TOKEN || '';
// Absolute base URL used to build package download links (behind your proxy).
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
mkdirSync(RELEASES_DIR, { recursive: true });
const PORT = Number(process.env.PORT || 8080);
const ADMIN_TOKEN = process.env.ADMIN_API_TOKEN || '';
if (!ADMIN_TOKEN) {
console.error('FATAL: ADMIN_API_TOKEN is not set. Refusing to start.');
process.exit(1);
}
seedProducts();
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', true); // honor X-Forwarded-* from the reverse proxy
// JSON parser for all endpoints EXCEPT the raw ZIP upload (mounted per-route).
app.use((req, res, next) => {
if (req.path === '/api/v1/releases' && req.method === 'POST') return next();
return express.json({ limit: '32kb' })(req, res, next);
});
// Minimal security headers (TLS termination is expected at the reverse proxy).
app.use((req, res, next) => {
res.set('X-Content-Type-Options', 'nosniff');
res.set('Referrer-Policy', 'no-referrer');
next();
});
/* ───────────────────────── prepared statements ───────────────────────── */
const Q = {
productBySlug: db.prepare('SELECT * FROM products WHERE slug = ?'),
allProducts: db.prepare('SELECT slug, name, created_at FROM products ORDER BY slug'),
insertProduct: db.prepare(
'INSERT INTO products (slug, name, created_at) VALUES (?, ?, ?)'
),
licenseByKey: db.prepare('SELECT * FROM licenses WHERE key = ?'),
insertLicense: db.prepare(`
INSERT INTO licenses (key, product_id, max_activations, status, email, note, created_at, expires_at)
VALUES (@key, @product_id, @max_activations, 'active', @email, @note, @created_at, @expires_at)
`),
setLicenseStatus: db.prepare('UPDATE licenses SET status = ? WHERE id = ?'),
activationsForLicense: db.prepare(
'SELECT * FROM activations WHERE license_id = ? ORDER BY created_at'
),
activationByDomain: db.prepare(
'SELECT * FROM activations WHERE license_id = ? AND domain = ?'
),
countActivations: db.prepare(
'SELECT COUNT(*) AS n FROM activations WHERE license_id = ?'
),
insertActivation: db.prepare(
'INSERT INTO activations (license_id, domain, created_at, last_check) VALUES (?, ?, ?, ?)'
),
touchActivation: db.prepare('UPDATE activations SET last_check = ? WHERE id = ?'),
deleteActivation: db.prepare(
'DELETE FROM activations WHERE license_id = ? AND domain = ?'
),
upsertRelease: db.prepare(`
INSERT INTO releases (product_id, version, zip_path, changelog, requires, tested, requires_php, created_at)
VALUES (@product_id, @version, @zip_path, @changelog, @requires, @tested, @requires_php, @created_at)
ON CONFLICT(product_id, version) DO UPDATE SET
zip_path = excluded.zip_path,
changelog = excluded.changelog,
requires = excluded.requires,
tested = excluded.tested,
requires_php = excluded.requires_php,
created_at = excluded.created_at
`),
releasesForProduct: db.prepare('SELECT * FROM releases WHERE product_id = ?'),
releaseByVersion: db.prepare('SELECT * FROM releases WHERE product_id = ? AND version = ?'),
};
/** Return the highest-version release row for a product, or null. */
function latestRelease(productId) {
const rows = Q.releasesForProduct.all(productId);
if (!rows.length) return null;
return rows.reduce((best, r) => (compareVersions(r.version, best.version) > 0 ? r : best));
}
/* ───────────────────────── helpers ───────────────────────── */
function adminOnly(req, res, next) {
const token = req.get('X-Admin-Token') || '';
if (!safeEqual(token, ADMIN_TOKEN)) {
return res.status(401).json({ ok: false, error: 'unauthorized' });
}
next();
}
function fail(res, code, error) {
return res.status(code).json({ ok: false, error });
}
/**
* Resolve a license for a public request and run the common validity gates.
* Returns { license, product } or sends an error response and returns null.
*/
function resolveLicense(res, key, productSlug) {
if (!key || !productSlug) {
fail(res, 400, 'key and product are required');
return null;
}
const product = Q.productBySlug.get(productSlug);
if (!product) {
fail(res, 404, 'unknown product');
return null;
}
const license = Q.licenseByKey.get(key);
if (!license || license.product_id !== product.id) {
fail(res, 404, 'Lizenz nicht gefunden');
return null;
}
if (license.status !== 'active') {
fail(res, 403, 'Lizenz deaktiviert');
return null;
}
if (isExpired(license.expires_at)) {
fail(res, 403, 'Lizenz abgelaufen');
return null;
}
return { license, product };
}
/* ───────────────────────── public endpoints (plugin) ───────────────────────── */
// Activate a license for a domain (binds the slot, enforces the limit).
app.post('/api/v1/activate', (req, res) => {
const key = String(req.body?.key || '').trim();
const productSlug = String(req.body?.product || '').trim();
const domain = normalizeDomain(req.body?.domain);
if (!domain) return fail(res, 400, 'domain is required');
const ctx = resolveLicense(res, key, productSlug);
if (!ctx) return;
const { license } = ctx;
const existing = Q.activationByDomain.get(license.id, domain);
if (existing) {
Q.touchActivation.run(nowIso(), existing.id);
return res.json({
ok: true,
status: 'valid',
domain,
activations_used: Q.countActivations.get(license.id).n,
max_activations: license.max_activations,
});
}
const used = Q.countActivations.get(license.id).n;
if (license.max_activations !== -1 && used >= license.max_activations) {
// Limit reached: tell the client which domains occupy the slots so the
// user can free one and retry.
const domains = Q.activationsForLicense.all(license.id).map((a) => a.domain);
return res.status(409).json({
ok: false,
code: 'limit_reached',
error: 'Maximale Anzahl an Domains für diese Lizenz erreicht',
domains,
max_activations: license.max_activations,
});
}
const now = nowIso();
Q.insertActivation.run(license.id, domain, now, now);
return res.json({
ok: true,
status: 'valid',
domain,
activations_used: used + 1,
max_activations: license.max_activations,
});
});
// Validate an already-activated domain (used by the daily re-check).
app.post('/api/v1/validate', (req, res) => {
const key = String(req.body?.key || '').trim();
const productSlug = String(req.body?.product || '').trim();
const domain = normalizeDomain(req.body?.domain);
if (!domain) return fail(res, 400, 'domain is required');
const ctx = resolveLicense(res, key, productSlug);
if (!ctx) return;
const { license } = ctx;
const existing = Q.activationByDomain.get(license.id, domain);
if (!existing) {
return fail(res, 403, 'Domain ist für diese Lizenz nicht aktiviert');
}
Q.touchActivation.run(nowIso(), existing.id);
return res.json({
ok: true,
status: 'valid',
domain,
activations_used: Q.countActivations.get(license.id).n,
max_activations: license.max_activations,
});
});
// Release a domain's activation slot.
app.post('/api/v1/deactivate', (req, res) => {
const key = String(req.body?.key || '').trim();
const productSlug = String(req.body?.product || '').trim();
const domain = normalizeDomain(req.body?.domain);
if (!key || !productSlug) return fail(res, 400, 'key and product are required');
if (!domain) return fail(res, 400, 'domain is required');
const product = Q.productBySlug.get(productSlug);
const license = product ? Q.licenseByKey.get(key) : null;
if (license && license.product_id === product.id) {
Q.deleteActivation.run(license.id, domain);
}
// Idempotent: always report success so the plugin can clean up locally.
return res.json({ ok: true, status: 'deactivated', domain });
});
// Scan the licensed site for embedded third-party resources.
// SSRF-guarded: only URLs on the license's own activated domain are fetched.
app.post('/api/v1/scan', async (req, res) => {
const key = String(req.body?.key || '').trim();
const productSlug = String(req.body?.product || '').trim();
const domain = normalizeDomain(req.body?.domain);
if (!domain) return fail(res, 400, 'domain is required');
if (!isPublicHost(domain)) return fail(res, 400, 'domain must be a public host');
const ctx = resolveLicense(res, key, productSlug);
if (!ctx) return;
const { license } = ctx;
// The requesting domain must be an activated slot of this license.
if (!Q.activationByDomain.get(license.id, domain)) {
return fail(res, 403, 'Domain ist für diese Lizenz nicht aktiviert');
}
// Build the target list; default to the home page.
let urls = Array.isArray(req.body?.urls) && req.body.urls.length
? req.body.urls
: [`https://${domain}/`];
// Anti-SSRF: keep only http(s) URLs whose host is exactly the licensed domain.
const targets = [];
for (const u of urls.slice(0, MAX_SCAN_URLS)) {
try {
const url = new URL(String(u));
if (!/^https?:$/.test(url.protocol)) continue;
if (normalizeDomain(url.host) !== domain) continue;
if (!isPublicHost(url.host)) continue;
targets.push(url.href);
} catch {
/* skip invalid */
}
}
if (!targets.length) return fail(res, 400, 'no valid target URLs for this domain');
const pages = [];
for (const t of targets) {
try {
// SSRF hardening: resolve the host and refuse private/link-local IPs
// (e.g. a public hostname pointed at 169.254.169.254 cloud metadata).
const host = new URL(t).hostname;
let address;
try {
({ address } = await lookup(host));
} catch {
pages.push({ url: t, error: 'dns lookup failed', resources: [] });
continue;
}
if (isPrivateIp(address)) {
pages.push({ url: t, error: 'blocked: resolves to a private address', resources: [] });
continue;
}
const r = await fetch(t, {
headers: { 'User-Agent': 'ContentBlockerScanner/1.0', Accept: 'text/html' },
redirect: 'manual', // do not auto-follow into unvalidated hosts
signal: AbortSignal.timeout(SCAN_TIMEOUT_MS),
});
if (r.status >= 300 && r.status < 400) {
pages.push({ url: t, error: `redirect (${r.status}) not followed`, resources: [] });
continue;
}
const buf = await r.text();
pages.push({ url: t, resources: extractResources(buf.slice(0, MAX_SCAN_BYTES), t) });
} catch (e) {
pages.push({ url: t, error: String(e?.message || e), resources: [] });
}
}
const findings = analyze(pages, domain);
return res.json({
ok: true,
scanned: pages.map((p) => ({ url: p.url, error: p.error || null })),
findings,
});
});
// Update check: tell a licensed, activated site whether a newer version exists
// and hand back a signed, time-limited package download URL.
app.post('/api/v1/update', (req, res) => {
const key = String(req.body?.key || '').trim();
const productSlug = String(req.body?.product || '').trim();
const domain = normalizeDomain(req.body?.domain);
const current = String(req.body?.version || '0').trim();
const ctx = resolveLicense(res, key, productSlug);
if (!ctx) return;
const { license, product } = ctx;
if (domain && !Q.activationByDomain.get(license.id, domain)) {
return fail(res, 403, 'Domain ist für diese Lizenz nicht aktiviert');
}
const rel = latestRelease(product.id);
if (!rel || compareVersions(rel.version, current) <= 0) {
return res.json({ ok: true, update_available: false, version: rel ? rel.version : current });
}
const token = signToken(
{ k: key, p: product.slug, v: rel.version, exp: Date.now() + DOWNLOAD_TTL_MS },
DOWNLOAD_SECRET
);
const base = PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`;
const pkg = `${base}/api/v1/download?token=${encodeURIComponent(token)}`;
return res.json({
ok: true,
update_available: true,
version: rel.version,
package: pkg,
slug: product.slug,
changelog: rel.changelog || '',
requires: rel.requires || '',
tested: rel.tested || '',
requires_php: rel.requires_php || '',
});
});
// Download a release ZIP. The token (issued by /update) carries the license key,
// product and version; we re-check the license live before streaming.
app.get('/api/v1/download', (req, res) => {
const payload = verifyToken(String(req.query.token || ''), DOWNLOAD_SECRET);
if (!payload) return fail(res, 403, 'invalid or expired token');
const product = Q.productBySlug.get(String(payload.p || ''));
const license = product ? Q.licenseByKey.get(String(payload.k || '')) : null;
if (!product || !license || license.product_id !== product.id) {
return fail(res, 403, 'invalid license');
}
if (license.status !== 'active' || isExpired(license.expires_at)) {
return fail(res, 403, 'license not active');
}
const rel = Q.releaseByVersion.get(product.id, String(payload.v || ''));
if (!rel || !existsSync(rel.zip_path)) {
return fail(res, 404, 'release not found');
}
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Length', statSync(rel.zip_path).size);
res.setHeader(
'Content-Disposition',
`attachment; filename="${product.slug}-${rel.version}.zip"`
);
createReadStream(rel.zip_path).pipe(res);
});
/* ───────────────────────── admin endpoints (n8n / you) ───────────────────────── */
// Upload / register a plugin release. Body is the raw .zip; metadata via query
// (?product=&version=) and optional X-Changelog / X-Requires / X-Tested /
// X-Requires-PHP headers. This is what your Gitea CI (or a curl) calls.
app.post(
'/api/v1/releases',
adminOnly,
express.raw({ type: ['application/zip', 'application/octet-stream'], limit: MAX_ZIP_BYTES }),
(req, res) => {
const productSlug = String(req.query.product || '').trim();
const version = String(req.query.version || '').trim();
const product = Q.productBySlug.get(productSlug);
if (!product) return fail(res, 404, 'unknown product');
if (!/^\d+(\.\d+){0,3}$/.test(version)) {
return fail(res, 400, 'version must look like 1.2.3');
}
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
return fail(res, 400, 'empty body — send the .zip as application/zip');
}
// Basic ZIP signature check ("PK\x03\x04").
if (!(req.body[0] === 0x50 && req.body[1] === 0x4b)) {
return fail(res, 400, 'body is not a ZIP file');
}
const dir = join(RELEASES_DIR, productSlug);
mkdirSync(dir, { recursive: true });
const zipPath = join(dir, `${version}.zip`);
writeFileSync(zipPath, req.body);
Q.upsertRelease.run({
product_id: product.id,
version,
zip_path: zipPath,
changelog: req.get('X-Changelog') || null,
requires: req.get('X-Requires') || null,
tested: req.get('X-Tested') || null,
requires_php: req.get('X-Requires-PHP') || null,
created_at: nowIso(),
});
return res.status(201).json({ ok: true, product: productSlug, version, bytes: req.body.length });
}
);
// List releases for a product.
app.get('/api/v1/releases/:product', adminOnly, (req, res) => {
const product = Q.productBySlug.get(req.params.product);
if (!product) return fail(res, 404, 'unknown product');
const rows = Q.releasesForProduct.all(product.id)
.map((r) => ({ version: r.version, created_at: r.created_at, changelog: r.changelog }))
.sort((a, b) => compareVersions(b.version, a.version));
return res.json({ ok: true, product: product.slug, releases: rows });
});
// Generate a license key. This is the endpoint the n8n workflow calls.
app.post('/api/v1/licenses', adminOnly, (req, res) => {
const productSlug = String(req.body?.product || '').trim();
const maxRaw = req.body?.max_activations;
const email = req.body?.email ? String(req.body.email).trim() : null;
const note = req.body?.note ? String(req.body.note).trim() : null;
const expiresAt = req.body?.expires_at ? String(req.body.expires_at).trim() : null;
const product = Q.productBySlug.get(productSlug);
if (!product) return fail(res, 404, 'unknown product');
const max = Number(maxRaw);
if (!Number.isInteger(max) || (max < 1 && max !== -1)) {
return fail(res, 400, 'max_activations must be a positive integer or -1 (unlimited)');
}
// Generate a unique key (retry on the astronomically rare collision).
let key;
for (let attempt = 0; attempt < 5; attempt++) {
key = generateKey();
if (!Q.licenseByKey.get(key)) break;
key = null;
}
if (!key) return fail(res, 500, 'could not generate unique key');
Q.insertLicense.run({
key,
product_id: product.id,
max_activations: max,
email,
note,
created_at: nowIso(),
expires_at: expiresAt || null,
});
return res.status(201).json({
ok: true,
key,
product: product.slug,
max_activations: max,
email,
expires_at: expiresAt || null,
});
});
// Inspect a license (status + bound domains).
app.get('/api/v1/licenses/:key', adminOnly, (req, res) => {
const license = Q.licenseByKey.get(req.params.key);
if (!license) return fail(res, 404, 'not found');
const product = db.prepare('SELECT slug FROM products WHERE id = ?').get(license.product_id);
const activations = Q.activationsForLicense.all(license.id);
return res.json({
ok: true,
key: license.key,
product: product?.slug,
status: license.status,
max_activations: license.max_activations,
email: license.email,
note: license.note,
created_at: license.created_at,
expires_at: license.expires_at,
activations_used: activations.length,
domains: activations.map((a) => ({ domain: a.domain, since: a.created_at, last_check: a.last_check })),
});
});
// Disable a license (revokes it everywhere on next check).
app.post('/api/v1/licenses/:key/disable', adminOnly, (req, res) => {
const license = Q.licenseByKey.get(req.params.key);
if (!license) return fail(res, 404, 'not found');
Q.setLicenseStatus.run('disabled', license.id);
return res.json({ ok: true, key: license.key, status: 'disabled' });
});
// Re-enable a disabled license.
app.post('/api/v1/licenses/:key/enable', adminOnly, (req, res) => {
const license = Q.licenseByKey.get(req.params.key);
if (!license) return fail(res, 404, 'not found');
Q.setLicenseStatus.run('active', license.id);
return res.json({ ok: true, key: license.key, status: 'active' });
});
// List / add products (extensibility for future plugins).
app.get('/api/v1/products', adminOnly, (req, res) => {
return res.json({ ok: true, products: Q.allProducts.all() });
});
app.post('/api/v1/products', adminOnly, (req, res) => {
const slug = String(req.body?.slug || '').trim().toLowerCase();
const name = String(req.body?.name || '').trim() || slug;
if (!/^[a-z0-9-]+$/.test(slug)) {
return fail(res, 400, 'slug must match [a-z0-9-]+');
}
if (Q.productBySlug.get(slug)) return fail(res, 409, 'product already exists');
Q.insertProduct.run(slug, name, nowIso());
return res.status(201).json({ ok: true, slug, name });
});
/* ───────────────────────── health ───────────────────────── */
app.get('/healthz', (req, res) => res.json({ ok: true, time: nowIso() }));
app.use((req, res) => fail(res, 404, 'not found'));
// JSON body parse errors etc.
app.use((err, req, res, _next) => {
if (err?.type === 'entity.parse.failed') return fail(res, 400, 'invalid JSON');
console.error(err);
return fail(res, 500, 'internal error');
});
app.listen(PORT, () => {
console.log(`License backend listening on :${PORT}`);
});

View File

@@ -0,0 +1,92 @@
import { randomInt, timingSafeEqual, createHmac } from 'node:crypto';
// Unambiguous alphabet (no 0/O/1/I/L) for human-readable, dictation-safe keys.
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
/** Generate a license key like ABCD-EFGH-JKMN-PQRS. */
export function generateKey(groups = 4, groupLen = 4) {
const parts = [];
for (let g = 0; g < groups; g++) {
let s = '';
for (let i = 0; i < groupLen; i++) {
s += ALPHABET[randomInt(0, ALPHABET.length)];
}
parts.push(s);
}
return parts.join('-');
}
/**
* Normalize a domain for stable comparison:
* strip scheme, path, port, leading www, lowercase.
*/
export function normalizeDomain(input) {
if (!input || typeof input !== 'string') return '';
let d = input.trim().toLowerCase();
d = d.replace(/^[a-z]+:\/\//, ''); // scheme
d = d.replace(/\/.*$/, ''); // path
d = d.replace(/:.*$/, ''); // port
d = d.replace(/^www\./, ''); // www
return d;
}
/** Timing-safe string compare for tokens. */
export function safeEqual(a, b) {
const ba = Buffer.from(String(a || ''));
const bb = Buffer.from(String(b || ''));
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
export function nowIso() {
return new Date().toISOString();
}
/** True if an ISO date string is in the past. */
export function isExpired(expiresAt) {
if (!expiresAt) return false;
const t = Date.parse(expiresAt);
if (Number.isNaN(t)) return false;
return t < Date.now();
}
/** Compare dotted version strings. Returns 1 if a>b, -1 if a<b, 0 if equal. */
export function compareVersions(a, b) {
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const x = pa[i] || 0;
const y = pb[i] || 0;
if (x > y) return 1;
if (x < y) return -1;
}
return 0;
}
/** Sign a small JSON payload (download tokens). Returns "body.sig" (base64url). */
export function signToken(payload, secret) {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = createHmac('sha256', secret).update(body).digest('base64url');
return body + '.' + sig;
}
/** Verify a token from signToken. Returns the payload, or null if bad/expired. */
export function verifyToken(token, secret) {
if (typeof token !== 'string' || !token.includes('.')) return null;
const [body, sig] = token.split('.');
if (!body || !sig) return null;
const expected = createHmac('sha256', secret).update(body).digest('base64url');
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
if (payload.exp && Date.now() > payload.exp) return null;
return payload;
} catch {
return null;
}
}

View File

@@ -0,0 +1,11 @@
import { pathToFileURL } from 'node:url';
import { resolve as pathResolve } from 'node:path';
const shim = pathToFileURL( pathResolve( 'test/shim-better-sqlite3.mjs' ) ).href;
export async function resolve( specifier, context, next ) {
if ( specifier === 'better-sqlite3' ) {
return { url: shim, shortCircuit: true };
}
return next( specifier, context );
}

View File

@@ -0,0 +1,212 @@
// End-to-end test of the REAL server.js (better-sqlite3 shimmed to node:sqlite).
import { rmSync } from 'node:fs';
const PORT = 8799;
const TOKEN = 'test-admin-token';
const BASE = `http://127.0.0.1:${PORT}`;
process.env.ADMIN_API_TOKEN = TOKEN;
process.env.PORT = String(PORT);
process.env.DATA_DIR = './.testdata';
process.env.SEED_PRODUCTS = 'gdpr-content-blocker:Content Blocker';
process.env.PUBLIC_BASE_URL = BASE;
rmSync('./.testdata', { recursive: true, force: true });
let fail = 0;
const ok = (name, cond, extra = '') => {
console.log((cond ? '[PASS] ' : '[FAIL] ') + name + (cond ? '' : ' ' + extra));
if (!cond) fail++;
};
async function admin(method, path, body) {
const r = await fetch(BASE + path, {
method,
headers: { 'Content-Type': 'application/json', 'X-Admin-Token': TOKEN },
body: body ? JSON.stringify(body) : undefined,
});
return { status: r.status, json: await r.json() };
}
async function pub(path, body) {
const r = await fetch(BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: r.status, json: await r.json() };
}
async function adminRaw(path, buffer, headers = {}) {
const r = await fetch(BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/zip', 'X-Admin-Token': TOKEN, ...headers },
body: buffer,
});
return { status: r.status, json: await r.json() };
}
// Boot the real server.
await import('../src/server.js');
await new Promise((r) => setTimeout(r, 400));
try {
const P = 'gdpr-content-blocker';
// Health
ok('healthz', (await (await fetch(BASE + '/healthz')).json()).ok === true);
// Admin auth required
const noAuth = await fetch(BASE + '/api/v1/licenses', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: P, max_activations: 1 }),
});
ok('generate without token → 401', noAuth.status === 401);
// Generate single-site key (max=1)
const g1 = await admin('POST', '/api/v1/licenses', { product: P, max_activations: 1, email: 'a@b.de' });
ok('generate key 201', g1.status === 201 && g1.json.ok === true, JSON.stringify(g1.json));
const key1 = g1.json.key;
ok('key format', /^[A-Z2-9]{4}(-[A-Z2-9]{4}){3}$/.test(key1 || ''), key1);
// Activate domain A
const a1 = await pub('/api/v1/activate', { key: key1, product: P, domain: 'https://www.Kunde-A.de/' });
ok('activate A valid', a1.json.status === 'valid' && a1.json.activations_used === 1, JSON.stringify(a1.json));
// Re-activate same domain (idempotent, count stays 1)
const a1b = await pub('/api/v1/activate', { key: key1, product: P, domain: 'kunde-a.de' });
ok('re-activate A idempotent', a1b.json.ok && a1b.json.activations_used === 1, JSON.stringify(a1b.json));
// Activate second domain → limit reached (max=1), response lists bound domains
const a2 = await pub('/api/v1/activate', { key: key1, product: P, domain: 'kunde-b.de' });
ok('second domain → 409 limit', a2.status === 409 && a2.json.ok === false, JSON.stringify(a2.json));
ok('409 reports code limit_reached', a2.json.code === 'limit_reached', JSON.stringify(a2.json));
ok('409 lists occupied domain', Array.isArray(a2.json.domains) && a2.json.domains.includes('kunde-a.de'), JSON.stringify(a2.json));
// Validate activated domain
const v1 = await pub('/api/v1/validate', { key: key1, product: P, domain: 'kunde-a.de' });
ok('validate A valid', v1.json.status === 'valid', JSON.stringify(v1.json));
// Validate non-activated domain
const v2 = await pub('/api/v1/validate', { key: key1, product: P, domain: 'kunde-x.de' });
ok('validate unbound → 403', v2.status === 403 && v2.json.ok === false, JSON.stringify(v2.json));
// Deactivate A, then B should fit
const d1 = await pub('/api/v1/deactivate', { key: key1, product: P, domain: 'kunde-a.de' });
ok('deactivate A', d1.json.ok === true);
const a3 = await pub('/api/v1/activate', { key: key1, product: P, domain: 'kunde-b.de' });
ok('after deactivate, B fits', a3.json.status === 'valid', JSON.stringify(a3.json));
// 3-site key
const g3 = await admin('POST', '/api/v1/licenses', { product: P, max_activations: 3 });
const key3 = g3.json.key;
for (const d of ['s1.de', 's2.de', 's3.de']) {
const r = await pub('/api/v1/activate', { key: key3, product: P, domain: d });
ok('3-site activate ' + d, r.json.status === 'valid', JSON.stringify(r.json));
}
const g3over = await pub('/api/v1/activate', { key: key3, product: P, domain: 's4.de' });
ok('3-site 4th → 409', g3over.status === 409, JSON.stringify(g3over.json));
// Unlimited key
const gU = await admin('POST', '/api/v1/licenses', { product: P, max_activations: -1 });
const keyU = gU.json.key;
let allOk = true;
for (let i = 0; i < 10; i++) {
const r = await pub('/api/v1/activate', { key: keyU, product: P, domain: `u${i}.de` });
if (r.json.status !== 'valid') allOk = false;
}
ok('unlimited activates 10 domains', allOk);
// Disable a license → activation blocked
await admin('POST', `/api/v1/licenses/${key3}/disable`);
const disabled = await pub('/api/v1/activate', { key: key3, product: P, domain: 'new.de' });
ok('disabled license → 403', disabled.status === 403, JSON.stringify(disabled.json));
// Wrong product
const wrong = await pub('/api/v1/activate', { key: key1, product: 'nope', domain: 'kunde-b.de' });
ok('unknown product → 404', wrong.status === 404, JSON.stringify(wrong.json));
// Unknown key
const unk = await pub('/api/v1/activate', { key: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', product: P, domain: 'x.de' });
ok('unknown key → 404', unk.status === 404, JSON.stringify(unk.json));
// Invalid max_activations
const badMax = await admin('POST', '/api/v1/licenses', { product: P, max_activations: 0 });
ok('max_activations 0 → 400', badMax.status === 400, JSON.stringify(badMax.json));
// Add a new product (extensibility) and issue a key for it
const np = await admin('POST', '/api/v1/products', { slug: 'next-plugin', name: 'Next Plugin' });
ok('add product 201', np.status === 201);
const npKey = await admin('POST', '/api/v1/licenses', { product: 'next-plugin', max_activations: 1 });
ok('key for new product', npKey.status === 201 && npKey.json.ok, JSON.stringify(npKey.json));
// Inspect a license
const info = await admin('GET', `/api/v1/licenses/${key1}`);
ok('inspect shows 1 domain (kunde-b)', info.json.activations_used === 1 && info.json.domains[0].domain === 'kunde-b.de', JSON.stringify(info.json));
// ── Scan endpoint guards ──
// Unactivated domain → 403
const scanUnbound = await pub('/api/v1/scan', { key: key1, product: P, domain: 'kunde-a.de' });
ok('scan unbound domain → 403', scanUnbound.status === 403, JSON.stringify(scanUnbound.json));
// SSRF guard: target url on a different host than the licensed domain → 400
const scanWrongHost = await pub('/api/v1/scan', { key: key1, product: P, domain: 'kunde-b.de', urls: ['https://evil.example.org/'] });
ok('scan foreign host filtered → 400', scanWrongHost.status === 400, JSON.stringify(scanWrongHost.json));
// Private/loopback licensed domain rejected
const scanLocal = await pub('/api/v1/scan', { key: key1, product: P, domain: '127.0.0.1' });
ok('scan private domain → 400', scanLocal.status === 400, JSON.stringify(scanLocal.json));
// Valid bound domain: host is unreachable in test, so endpoint returns ok:true
// with a per-page fetch error and empty findings (pipeline wiring works).
const scanOk = await pub('/api/v1/scan', { key: key1, product: P, domain: 'kunde-b.de' });
ok('scan bound domain → 200 ok', scanOk.status === 200 && scanOk.json.ok === true, JSON.stringify(scanOk.json));
ok('scan reports the attempted page', Array.isArray(scanOk.json.scanned) && scanOk.json.scanned.length === 1, JSON.stringify(scanOk.json));
// ── Update delivery ──
// A minimal but valid ZIP (PK signature). key1 is active on kunde-b.de.
const zip = Buffer.from([0x50, 0x4b, 0x03, 0x04, 1, 2, 3, 4, 5, 6, 7, 8]);
const upBad = await adminRaw('/api/v1/releases?product=gdpr-content-blocker&version=bad', zip);
ok('upload bad version → 400', upBad.status === 400, JSON.stringify(upBad.json));
const upEmpty = await adminRaw('/api/v1/releases?product=gdpr-content-blocker&version=1.1.0', Buffer.alloc(0));
ok('upload empty body → 400', upEmpty.status === 400, JSON.stringify(upEmpty.json));
const up = await adminRaw('/api/v1/releases?product=gdpr-content-blocker&version=1.1.0', zip, { 'X-Changelog': 'Neues' });
ok('upload release 201', up.status === 201 && up.json.bytes === zip.length, JSON.stringify(up.json));
// No update when current >= latest
const uNo = await pub('/api/v1/update', { key: key1, product: P, domain: 'kunde-b.de', version: '1.1.0' });
ok('update: current==latest → none', uNo.json.ok && uNo.json.update_available === false, JSON.stringify(uNo.json));
// Update available when current < latest
const uYes = await pub('/api/v1/update', { key: key1, product: P, domain: 'kunde-b.de', version: '1.0.0' });
ok('update available', uYes.json.update_available === true && uYes.json.version === '1.1.0', JSON.stringify(uYes.json));
ok('update has package url', typeof uYes.json.package === 'string' && uYes.json.package.includes('/api/v1/download?token='), JSON.stringify(uYes.json));
// Unactivated domain cannot check updates
const uUnbound = await pub('/api/v1/update', { key: key1, product: P, domain: 'fremd.de', version: '1.0.0' });
ok('update unbound domain → 403', uUnbound.status === 403, JSON.stringify(uUnbound.json));
// Download via the signed package URL → real ZIP bytes
const dl = await fetch(uYes.json.package);
const dlBuf = Buffer.from(await dl.arrayBuffer());
ok('download 200 zip', dl.status === 200 && dl.headers.get('content-type') === 'application/zip', String(dl.status));
ok('download bytes match upload', dlBuf.length === zip.length && dlBuf[0] === 0x50 && dlBuf[1] === 0x4b);
// Tampered token rejected
const badToken = uYes.json.package.replace('token=', 'token=x');
const dlBad = await fetch(badToken);
ok('download tampered token → 403', dlBad.status === 403, String(dlBad.status));
// Admin can list releases
const relList = await admin('GET', '/api/v1/releases/gdpr-content-blocker');
ok('release list shows 1.1.0', relList.json.ok && relList.json.releases.some((r) => r.version === '1.1.0'), JSON.stringify(relList.json));
} catch (e) {
console.error(e);
fail++;
}
console.log('\n' + (fail === 0 ? 'ALL PASSED' : fail + ' FAILED'));
try { rmSync('./.testdata', { recursive: true, force: true }); } catch { /* WAL handle still open; harmless */ }
process.exit(fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,4 @@
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
register( './test/hooks.mjs', pathToFileURL( './' ) );

View File

@@ -0,0 +1,87 @@
import { extractResources, analyze, isPublicHost, isPrivateIp } from '../src/scan.js';
let fail = 0;
const ok = (name, cond, extra = '') => {
console.log((cond ? '[PASS] ' : '[FAIL] ') + name + (cond ? '' : ' ' + extra));
if (!cond) fail++;
};
const html = `
<!DOCTYPE html><html><head>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto">
<script src="/wp-includes/js/jquery.js"></script>
<script src="https://www.googletagmanager.com/gtag/js?id=G-XYZ"></script>
</head><body>
<img src="https://cdn.example.com/logo.png">
<img src="/local/pic.png">
<iframe src="https://www.google.com/maps/embed?pb=1" width="600" height="450"></iframe>
<iframe src="https://www.youtube-nocookie.com/embed/abc"></iframe>
<iframe src="data:text/html,nope"></iframe>
<object data="https://player.vimeo.com/video/123"></object>
<a href="https://twitter.com/foo">x</a>
</body></html>`;
const base = 'https://example.com/page/';
const resources = extractResources(html, base);
ok('finds google maps iframe', resources.some((r) => r.type === 'iframe' && r.url.includes('google.com/maps')));
ok('finds youtube-nocookie iframe', resources.some((r) => r.url.includes('youtube-nocookie.com/embed')));
ok('skips data: iframe', !resources.some((r) => r.url.startsWith('data:')));
ok('finds external script (gtm)', resources.some((r) => r.type === 'script' && r.url.includes('googletagmanager.com')));
ok('resolves relative script to absolute', resources.some((r) => r.url === 'https://example.com/wp-includes/js/jquery.js'));
ok('finds external img', resources.some((r) => r.type === 'img' && r.url.includes('cdn.example.com')));
ok('finds link stylesheet', resources.some((r) => r.type === 'link' && r.url.includes('fonts.googleapis.com')));
ok('finds object data (vimeo)', resources.some((r) => r.type === 'object' && r.url.includes('player.vimeo.com')));
ok('ignores anchor href', !resources.some((r) => r.url.includes('twitter.com')));
const findings = analyze([{ url: base, resources }], 'example.com');
const byHost = Object.fromEntries(findings.map((f) => [f.host, f]));
ok('example.com is first-party', byHost['example.com'] && byHost['example.com'].third_party === false);
ok('cdn.example.com is first-party (subdomain)', byHost['cdn.example.com'].third_party === false);
ok('google.com is third-party', byHost['google.com'].third_party === true);
ok('googletagmanager third-party', byHost['googletagmanager.com'].third_party === true);
ok('third-party sorted first', findings[0].third_party === true);
ok('suggested_pattern = host', byHost['player.vimeo.com'].suggested_pattern === 'player.vimeo.com');
ok('types aggregated', Array.isArray(byHost['google.com'].types) && byHost['google.com'].types.includes('iframe'));
ok('sample_urls capped at 3', findings.every((f) => f.sample_urls.length <= 3));
ok('pages list source page', Array.isArray(byHost['google.com'].pages) && byHost['google.com'].pages.includes(base));
// pages aggregate across multiple scanned pages
const multi = analyze([
{ url: 'https://example.com/a', resources: [{ url: 'https://google.com/maps', type: 'iframe' }] },
{ url: 'https://example.com/b', resources: [{ url: 'https://google.com/maps', type: 'iframe' }] },
], 'example.com');
ok('host found on both pages', multi[0].pages.length === 2 && multi[0].pages.includes('https://example.com/a') && multi[0].pages.includes('https://example.com/b'));
// www normalization: www.google.com counts as google.com
const f2 = analyze([{ url: base, resources: [{ url: 'https://www.google.com/x', type: 'iframe' }] }], 'example.com');
ok('www stripped in host grouping', f2[0].host === 'google.com');
// isPublicHost
ok('localhost not public', isPublicHost('localhost') === false);
ok('127.0.0.1 not public', isPublicHost('127.0.0.1') === false);
ok('10.x not public', isPublicHost('10.1.2.3') === false);
ok('192.168 not public', isPublicHost('192.168.0.1') === false);
ok('172.16 not public', isPublicHost('172.16.0.1') === false);
ok('172.32 IS public', isPublicHost('172.32.0.1') === true);
ok('public domain ok', isPublicHost('kunde-a.de') === true);
// isPrivateIp (post-DNS-resolution SSRF guard)
ok('metadata 169.254.169.254 private', isPrivateIp('169.254.169.254') === true);
ok('127.0.0.1 private', isPrivateIp('127.0.0.1') === true);
ok('10.x private', isPrivateIp('10.0.0.5') === true);
ok('192.168 private', isPrivateIp('192.168.1.1') === true);
ok('172.16 private', isPrivateIp('172.16.5.5') === true);
ok('172.32 public ip', isPrivateIp('172.32.0.1') === false);
ok('CGNAT 100.64 private', isPrivateIp('100.64.0.1') === true);
ok('public ip allowed', isPrivateIp('93.184.216.34') === false);
ok('::1 private', isPrivateIp('::1') === true);
ok('fe80 link-local private', isPrivateIp('fe80::1') === true);
ok('fd00 ula private', isPrivateIp('fd12::1') === true);
ok('ipv4-mapped metadata private', isPrivateIp('::ffff:169.254.169.254') === true);
ok('global ipv6 allowed', isPrivateIp('2606:2800:220:1:248:1893:25c8:1946') === false);
ok('empty ip unsafe', isPrivateIp('') === true);
console.log('\n' + (fail === 0 ? 'ALL PASSED' : fail + ' FAILED'));
process.exit(fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,24 @@
// Test-only shim: maps the better-sqlite3 API onto Node's built-in node:sqlite.
// Used ONLY for local integration testing (production uses real better-sqlite3
// inside the Docker image). The SQL executed is identical.
import { DatabaseSync } from 'node:sqlite';
class Statement {
constructor(stmt) {
this.stmt = stmt;
if (typeof stmt.setAllowBareNamedParameters === 'function') {
stmt.setAllowBareNamedParameters(true);
}
}
run(...args) { return this.stmt.run(...args); }
get(...args) { return this.stmt.get(...args); }
all(...args) { return this.stmt.all(...args); }
}
export default class Database {
constructor(path) { this.db = new DatabaseSync(path); }
pragma(s) { return this.db.exec('PRAGMA ' + s + ';'); }
exec(sql) { return this.db.exec(sql); }
prepare(sql) { return new Statement(this.db.prepare(sql)); }
close() { this.db.close(); }
}

View File

@@ -0,0 +1,50 @@
import { generateKey, normalizeDomain, safeEqual, isExpired, compareVersions, signToken, verifyToken } from '../src/util.js';
let fail = 0;
const ok = (name, cond) => { console.log((cond ? '[PASS] ' : '[FAIL] ') + name); if (!cond) fail++; };
// Key format
const k = generateKey();
ok('key format XXXX-XXXX-XXXX-XXXX', /^[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(k));
ok('no ambiguous chars (0OI1L)', !/[0OI1L]/.test(k));
const keys = new Set(Array.from({ length: 1000 }, () => generateKey()));
ok('1000 keys unique', keys.size === 1000);
// Domain normalization
ok('strip https + www', normalizeDomain('https://www.Example.com/') === 'example.com');
ok('strip path', normalizeDomain('http://example.com/foo/bar') === 'example.com');
ok('strip port', normalizeDomain('example.com:8080') === 'example.com');
ok('subdomain kept', normalizeDomain('https://shop.example.com/x') === 'shop.example.com');
ok('empty stays empty', normalizeDomain('') === '');
ok('null safe', normalizeDomain(null) === '');
// safeEqual
ok('safeEqual match', safeEqual('abc123', 'abc123') === true);
ok('safeEqual mismatch', safeEqual('abc', 'abd') === false);
ok('safeEqual length diff', safeEqual('abc', 'abcd') === false);
// isExpired
ok('no expiry = not expired', isExpired(null) === false);
ok('past = expired', isExpired('2000-01-01T00:00:00Z') === true);
ok('future = not expired', isExpired('2999-01-01T00:00:00Z') === false);
ok('garbage = not expired', isExpired('not-a-date') === false);
// compareVersions
ok('1.1.0 > 1.0.0', compareVersions('1.1.0', '1.0.0') === 1);
ok('1.0.0 < 1.0.1', compareVersions('1.0.0', '1.0.1') === -1);
ok('1.0 == 1.0.0', compareVersions('1.0', '1.0.0') === 0);
ok('2.0.0 > 1.9.9', compareVersions('2.0.0', '1.9.9') === 1);
ok('10.0 > 9.0 (numeric not lexical)', compareVersions('10.0', '9.0') === 1);
// signToken / verifyToken
const secret = 'top-secret';
const tok = signToken({ k: 'KEY', p: 'gdpr-content-blocker', v: '1.1.0', exp: Date.now() + 10000 }, secret);
const payload = verifyToken(tok, secret);
ok('token round-trips', payload && payload.k === 'KEY' && payload.v === '1.1.0');
ok('wrong secret → null', verifyToken(tok, 'other') === null);
ok('tampered body → null', verifyToken('x' + tok, secret) === null);
ok('expired token → null', verifyToken(signToken({ exp: Date.now() - 1 }, secret), secret) === null);
ok('garbage token → null', verifyToken('not-a-token', secret) === null);
console.log('\n' + (fail === 0 ? 'ALL PASSED' : fail + ' FAILED'));
process.exit(fail === 0 ? 0 : 1);

32
release.ps1 Normal file
View File

@@ -0,0 +1,32 @@
# Manueller Release: baut die Plugin-ZIP (sauber, ohne .git) aus dem COMMITTETEN
# Stand und lädt sie ins Lizenz-Backend, das sie dann als Update verteilt.
#
# WICHTIG: vorher die Version in gdpr-content-blocker/gdpr-content-blocker.php
# (Header "Version:" UND define CB_VERSION) auf $Version setzen und committen,
# damit das installierte Plugin dieselbe Version meldet.
#
# Aufruf (im Repo-Wurzelverzeichnis):
# .\release.ps1 -Version 1.0.1 -Token DEIN_ADMIN_API_TOKEN
#
param(
[Parameter(Mandatory = $true)][string]$Version,
[Parameter(Mandatory = $true)][string]$Token,
[string]$Backend = "https://hub.lucas-orth.de"
)
$ErrorActionPreference = "Stop"
$zip = "gdpr-content-blocker.zip"
if (Test-Path $zip) { Remove-Item $zip }
# Saubere ZIP mit Top-Level-Ordner gdpr-content-blocker/ aus dem committeten Stand.
git archive --format=zip --prefix=gdpr-content-blocker/ -o $zip "HEAD:gdpr-content-blocker"
Write-Host "ZIP gebaut: $zip"
# Hochladen ans Backend (Raw-ZIP-Body).
curl.exe -fSs -X POST "$Backend/api/v1/releases?product=gdpr-content-blocker&version=$Version" `
-H "X-Admin-Token: $Token" `
-H "Content-Type: application/zip" `
-H "X-Tested: 6.7" -H "X-Requires-PHP: 8.1" `
--data-binary "@$zip"
Write-Host "`n`nFertig: Version $Version hochgeladen. Lizenzierte Seiten sehen das Update."