From ecb5e1bd22fcb6ac380810c051c3dbd607ce7ae5 Mon Sep 17 00:00:00 2001 From: s4luorth Date: Sun, 7 Jun 2026 14:41:38 +0200 Subject: [PATCH] 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 --- .gitignore | 23 + README.md | 29 + .../.gitea/workflows/release.yml | 51 ++ gdpr-content-blocker/.gitignore | 7 + gdpr-content-blocker/assets/admin.js | 273 +++++++ gdpr-content-blocker/assets/frontend.css | 198 +++++ gdpr-content-blocker/assets/frontend.js | 115 +++ gdpr-content-blocker/gdpr-content-blocker.php | 55 ++ .../includes/class-autodetect.php | 109 +++ .../includes/class-license.php | 405 ++++++++++ .../includes/class-renderer.php | 285 +++++++ .../includes/class-settings.php | 746 ++++++++++++++++++ .../includes/class-updater.php | 139 ++++ .../languages/gdpr-content-blocker-de_DE.mo | Bin 0 -> 3230 bytes .../languages/gdpr-content-blocker-de_DE.po | 125 +++ gdpr-content-blocker/readme.txt | 92 +++ hilfsdaten/SPICKZETTEL.md | 92 +++ hilfsdaten/gitea-runner/README.md | 41 + hilfsdaten/gitea-runner/docker-compose.yml | 18 + license-backend/.dockerignore | 7 + license-backend/.env.example | 27 + license-backend/.gitignore | 7 + license-backend/Dockerfile | 24 + license-backend/README.md | 170 ++++ license-backend/docker-compose.yml | 33 + license-backend/package.json | 22 + license-backend/src/db.js | 79 ++ license-backend/src/scan.js | 132 ++++ license-backend/src/server.js | 574 ++++++++++++++ license-backend/src/util.js | 92 +++ license-backend/test/hooks.mjs | 11 + license-backend/test/integration.mjs | 212 +++++ license-backend/test/register.mjs | 4 + license-backend/test/scan.test.mjs | 87 ++ license-backend/test/shim-better-sqlite3.mjs | 24 + license-backend/test/util.test.mjs | 50 ++ release.ps1 | 32 + 37 files changed, 4390 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 gdpr-content-blocker/.gitea/workflows/release.yml create mode 100644 gdpr-content-blocker/.gitignore create mode 100644 gdpr-content-blocker/assets/admin.js create mode 100644 gdpr-content-blocker/assets/frontend.css create mode 100644 gdpr-content-blocker/assets/frontend.js create mode 100644 gdpr-content-blocker/gdpr-content-blocker.php create mode 100644 gdpr-content-blocker/includes/class-autodetect.php create mode 100644 gdpr-content-blocker/includes/class-license.php create mode 100644 gdpr-content-blocker/includes/class-renderer.php create mode 100644 gdpr-content-blocker/includes/class-settings.php create mode 100644 gdpr-content-blocker/includes/class-updater.php create mode 100644 gdpr-content-blocker/languages/gdpr-content-blocker-de_DE.mo create mode 100644 gdpr-content-blocker/languages/gdpr-content-blocker-de_DE.po create mode 100644 gdpr-content-blocker/readme.txt create mode 100644 hilfsdaten/SPICKZETTEL.md create mode 100644 hilfsdaten/gitea-runner/README.md create mode 100644 hilfsdaten/gitea-runner/docker-compose.yml create mode 100644 license-backend/.dockerignore create mode 100644 license-backend/.env.example create mode 100644 license-backend/.gitignore create mode 100644 license-backend/Dockerfile create mode 100644 license-backend/README.md create mode 100644 license-backend/docker-compose.yml create mode 100644 license-backend/package.json create mode 100644 license-backend/src/db.js create mode 100644 license-backend/src/scan.js create mode 100644 license-backend/src/server.js create mode 100644 license-backend/src/util.js create mode 100644 license-backend/test/hooks.mjs create mode 100644 license-backend/test/integration.mjs create mode 100644 license-backend/test/register.mjs create mode 100644 license-backend/test/scan.test.mjs create mode 100644 license-backend/test/shim-better-sqlite3.mjs create mode 100644 license-backend/test/util.test.mjs create mode 100644 release.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86e996c --- /dev/null +++ b/.gitignore @@ -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*/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..259ae1c --- /dev/null +++ b/README.md @@ -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. diff --git a/gdpr-content-blocker/.gitea/workflows/release.yml b/gdpr-content-blocker/.gitea/workflows/release.yml new file mode 100644 index 0000000..c1e5c21 --- /dev/null +++ b/gdpr-content-blocker/.gitea/workflows/release.yml @@ -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 diff --git a/gdpr-content-blocker/.gitignore b/gdpr-content-blocker/.gitignore new file mode 100644 index 0000000..07d558a --- /dev/null +++ b/gdpr-content-blocker/.gitignore @@ -0,0 +1,7 @@ +# CI build artifacts +build/ +*.zip + +# scratch +.commitmsg.tmp +node_modules/ diff --git a/gdpr-content-blocker/assets/admin.js b/gdpr-content-blocker/assets/admin.js new file mode 100644 index 0000000..eb5f6b7 --- /dev/null +++ b/gdpr-content-blocker/assets/admin.js @@ -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 = $( '

' ).text( ( i18n.scannedPages || 'Scanned:' ) + ' ' ); + data.scanned.forEach( function ( s, i ) { + if ( i > 0 ) $p.append( ', ' ); + $p.append( $( '' ).text( s.url + ( s.error ? ' (!)' : '' ) ) ); + } ); + $out.append( $p ); + } + + if ( ! findings.length ) { + $out.append( $( '

' ).text( i18n.noFindings || 'No external resources found.' ) ); + return; + } + + const $table = $( '
' ); + const $head = $( '' ); + [ 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( $( '' ).text( h ) ); } ); + $table.append( $( '' ).append( $head ) ); + + const $body = $( '' ); + findings.forEach( function ( f ) { + const $tr = $( '' ); + + // Host (+ party badge) + const $host = $( '' ); + $host.append( $( '' ).text( f.host ) ); + $host.append( document.createElement( 'br' ) ); + $host.append( $( '' ) + .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( $( '' ).text( ( f.types || [] ).join( ', ' ) ) ); + + // Count + $tr.append( $( '' ).text( f.count ) ); + + // Found on which pages + const $pages = $( '' ).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( $( '' ) + .attr( { href: pageUrl, target: '_blank', rel: 'noopener noreferrer', title: pageUrl } ) + .text( label ) ); + } ); + $tr.append( $pages ); + + // Example URL + const $ex = $( '' ); + const sample = ( f.sample_urls || [] )[ 0 ] || ''; + $ex.append( $( '' ).css( { fontSize: '11px', wordBreak: 'break-all' } ).text( sample ) ); + $tr.append( $ex ); + + // Status + const $st = $( '' ); + if ( f.covered ) { + $st.append( $( '' ).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 = $( '' ); + if ( f.third_party && ! f.covered ) { + $( '' ) + .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 ); diff --git a/gdpr-content-blocker/assets/frontend.css b/gdpr-content-blocker/assets/frontend.css new file mode 100644 index 0000000..e707e5f --- /dev/null +++ b/gdpr-content-blocker/assets/frontend.css @@ -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; +} diff --git a/gdpr-content-blocker/assets/frontend.js b/gdpr-content-blocker/assets/frontend.js new file mode 100644 index 0000000..8b4f23a --- /dev/null +++ b/gdpr-content-blocker/assets/frontend.js @@ -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(); + } +} )(); diff --git a/gdpr-content-blocker/gdpr-content-blocker.php b/gdpr-content-blocker/gdpr-content-blocker.php new file mode 100644 index 0000000..e211c51 --- /dev/null +++ b/gdpr-content-blocker/gdpr-content-blocker.php @@ -0,0 +1,55 @@ +… 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, ']*>.*?#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( + '
' . $iframe_html . '
', + 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( '&', '&', $src ); + + return $src; + } +} diff --git a/gdpr-content-blocker/includes/class-license.php b/gdpr-content-blocker/includes/class-license.php new file mode 100644 index 0000000..937ca04 --- /dev/null +++ b/gdpr-content-blocker/includes/class-license.php @@ -0,0 +1,405 @@ +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 '

'; + 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' ), + '' . esc_html__( 'Lizenzschlüssel', 'gdpr-content-blocker' ) . '' + ); + echo '

'; + } + + public static function render_tab(): void { + $lic = self::get_license(); + $status = $lic['status']; + + $badge = match ( $status ) { + 'active' => '● ' . esc_html__( 'Aktiv', 'gdpr-content-blocker' ) . '', + 'invalid' => '● ' . esc_html__( 'Ungültig', 'gdpr-content-blocker' ) . '', + 'limit' => '● ' . esc_html__( 'Alle Plätze belegt', 'gdpr-content-blocker' ) . '', + default => '● ' . esc_html__( 'Nicht aktiviert', 'gdpr-content-blocker' ) . '', + }; + ?> + + + + + + + + + + + + + +
+ + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + +
+ [/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 ''; + } + + // 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 = '' + . esc_html( $atts['text'] ) + . ''; + + if ( $atts['note'] === 'yes' ) { + $out .= '' + . esc_html__( 'Betrifft nur die Freigabe externer Einbettungen (z. B. Karten, Videos). Cookie-Einstellungen werden separat verwaltet.', 'gdpr-content-blocker' ) + . ''; + } + + 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 = '
    '; + 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 .= '
  • '; + $out .= '' . $name . ''; + $out .= '
    '; + + 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 .= '
    ' . esc_html__( 'Empfänger', 'gdpr-content-blocker' ) . '
    ' . $recipient . '
    '; + } + + if ( ! empty( $svc['purpose'] ) ) { + $out .= '
    ' . esc_html__( 'Zweck', 'gdpr-content-blocker' ) . '
    ' . esc_html( $svc['purpose'] ) . '
    '; + } + + $out .= '
    ' . esc_html__( 'Setzt Cookies', 'gdpr-content-blocker' ) . '
    ' + . ( ! empty( $svc['sets_cookie'] ) ? esc_html__( 'Ja', 'gdpr-content-blocker' ) : esc_html__( 'Nein', 'gdpr-content-blocker' ) ) + . '
    '; + + if ( ! empty( $svc['privacy_url'] ) ) { + $url = esc_url( $svc['privacy_url'] ); + $out .= '
    ' . esc_html__( 'Datenschutz', 'gdpr-content-blocker' ) . '
    ' + . '
    ' . $url . '
    '; + } + + $out .= '
  • '; + } + $out .= '
'; + + 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' ), + '' . $name . '' + ); + } + + $third_note = ''; + if ( $third ) { + $third_note = '' + . esc_html__( '⚠ Datenübermittlung in ein Drittland außerhalb der EU/des EWR', 'gdpr-content-blocker' ) + . ''; + } + + $privacy_link = ''; + if ( $privacy_url !== '' ) { + $privacy_link = '' + . esc_html__( 'Datenschutzerklärung des Anbieters', 'gdpr-content-blocker' ) + . ''; + } + + $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 '
' + . '
' + . '

' . $info_text . '

' + . '

' + . '' . esc_html__( 'Empfänger:', 'gdpr-content-blocker' ) . ' ' + . $recipient + . ( $third_note !== '' ? ' — ' . $third_note : '' ) + . '

' + . '

' + . '' . esc_html__( 'Zweck:', 'gdpr-content-blocker' ) . ' ' + . $purpose + . '

' + . ( $privacy_link !== '' ? '

' . $privacy_link . '

' : '' ) + . '' + . '
' + . '
'; + } + + /** 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 ]; + } +} diff --git a/gdpr-content-blocker/includes/class-settings.php b/gdpr-content-blocker/includes/class-settings.php new file mode 100644 index 0000000..31224fa --- /dev/null +++ b/gdpr-content-blocker/includes/class-settings.php @@ -0,0 +1,746 @@ + [ __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'; + ?> +
+

+ + +
+

+
+ + + + + +
+
+ + + +
+ $svc ) : ?> + + +
+ +
+ + + +
+ + +
+ + + +
+ + + + + + + + + + + + +
+ + + 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 : '' ) ); + ?> +
+
+ + + + + + +
+ +
+ __( '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 ''; + foreach ( $fields as $key => $label ) { + $val = esc_attr( $style[ $key ] ); + echo ''; + echo ''; + } + echo ''; + echo ''; + echo ''; + } + + /** + * 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 => '' . esc_html( $s ) . ''; + ?> +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +

+

+

+
+ , + , + , + , + , + , + +

+
+ +
+

GDPR Content Blocker

+

+ +

+ +

+ +

+ +

+
    +
  • +
  • +
  • +
  • +
  • +
+ +

+

+
+ +

+ +

+ +

+ +

+ Lucas Orth' + ); + ?> +

+
+ [ '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; + } +} diff --git a/gdpr-content-blocker/includes/class-updater.php b/gdpr-content-blocker/includes/class-updater.php new file mode 100644 index 0000000..bcbaaa3 --- /dev/null +++ b/gdpr-content-blocker/includes/class-updater.php @@ -0,0 +1,139 @@ + 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' => 'Lucas Orth', + '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'] ?? '' ), + ], + ]; + } +} diff --git a/gdpr-content-blocker/languages/gdpr-content-blocker-de_DE.mo b/gdpr-content-blocker/languages/gdpr-content-blocker-de_DE.mo new file mode 100644 index 0000000000000000000000000000000000000000..1787fd1929ad66cf56cf4ad0689212a5b6811f5d GIT binary patch literal 3230 zcmeH|O>7%Q6vw9!z6>p*w17~d51~r7!a7di5N!w|iJjCUAy#as2vtI3@7SJtJ!{R* zI@IFEg)34kAr1wVoVdUhsTD#N5?78O!~w27A#PmwKd+OxrEuYzmF?fo*PHkL@6Gtv z(=*ovo(uS%!?$>XTSrg$fDot9?15*%55O0}kHIIvuP5!>;InA|0zL=+2|fj$d{Brd z!Si4SoC7ag_&it#dA~l$c0K|h1FwPX z-wlxCxefBVpTRls4)`>9?xA$w0?2W!gD1i2q+fzJ(B1=2gU>vSIbaUNNA&Tt!sFm4 z(CizKb=r9})k%Wv#{gve*C*|pAp89t$U6N5vVVVotkVpFV4co@9q>Fjjt^v=u7Rx6 zmmur(1IRkv0a+))Vz!qBStlRl{pujw>4BIku7d2}hal^86XbQ@f~?bzAnSAji+JC& zAjdHavQCSW{&kRbx(u>Te}U`ZDNKeWq7Jf7mwEApn64-{bQH=!88J&mtxW8wqs=gB zjc&Ekxe|toO4{t{Fx2hDppW)+psY<=JZu_TIc1IPDY2S37a6)34V3liqBa<4+r$Jd zX&d(r*VOxNy8mAn_;fUNErLzsXf=$QU1h~u61%8JYt^bKRyTIHy>4V$k?koOfM zH^hB5?g~FIiV_o47($Eb zcZ&N;H#;zoUN{*yJBb^5^_@)$R7?j##nYq8cEiy=6l&jZ&gfP{*&aOOtw@{H%}|4s1Q_H$GH zz9<>jV%3Z&*{(7M-o!l!&ufZNPiiw)X-O=)H{(l$$80w>rB8b*d!w82)VH8zC*@uuN#2d}k$= z=EbhEe1f!N>4$r&*%gVN(1X0UuqVmX_aoU z<#*YbFMj*<^VGk8)EsP0)!kItA!;r?u5FZ`sMzR=YC5kRcZ_O&{V9sOxd|D_hyyg|= z@u5P&FBE1Acrel1Q3K7}{^z_+89T3LrHN6Q5iY4s-jBAiAH9A*7v9L=JhT&}!K*3R z^JxP|QkuhFA6a{^Sjudbw@OEWBhbtwc&~P~54X9b=lUVe$`Um@xIvU#uGiMR#iM!b z70#JgGR-JJ27Ov=XqTykiIt(Zj$1AE$@Is~czI!o(%0pzp_i6vKDU&4$J;=m*)*~( aX%ZP`jxYJ+OaAzh$N#!LzU2S+CI2^mW8fD6 literal 0 HcmV?d00001 diff --git a/gdpr-content-blocker/languages/gdpr-content-blocker-de_DE.po b/gdpr-content-blocker/languages/gdpr-content-blocker-de_DE.po new file mode 100644 index 0000000..b480957 --- /dev/null +++ b/gdpr-content-blocker/languages/gdpr-content-blocker-de_DE.po @@ -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 \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" diff --git a/gdpr-content-blocker/readme.txt b/gdpr-content-blocker/readme.txt new file mode 100644 index 0000000..bfa53a8 --- /dev/null +++ b/gdpr-content-blocker/readme.txt @@ -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"][/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. diff --git a/hilfsdaten/SPICKZETTEL.md b/hilfsdaten/SPICKZETTEL.md new file mode 100644 index 0000000..927e879 --- /dev/null +++ b/hilfsdaten/SPICKZETTEL.md @@ -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 +``` diff --git a/hilfsdaten/gitea-runner/README.md b/hilfsdaten/gitea-runner/README.md new file mode 100644 index 0000000..2e5c9f3 --- /dev/null +++ b/hilfsdaten/gitea-runner/README.md @@ -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. diff --git a/hilfsdaten/gitea-runner/docker-compose.yml b/hilfsdaten/gitea-runner/docker-compose.yml new file mode 100644 index 0000000..99cf01a --- /dev/null +++ b/hilfsdaten/gitea-runner/docker-compose.yml @@ -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 diff --git a/license-backend/.dockerignore b/license-backend/.dockerignore new file mode 100644 index 0000000..577011b --- /dev/null +++ b/license-backend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +npm-debug.log +.env +data +*.db +*.db-* +.git diff --git a/license-backend/.env.example b/license-backend/.env.example new file mode 100644 index 0000000..393200a --- /dev/null +++ b/license-backend/.env.example @@ -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 diff --git a/license-backend/.gitignore b/license-backend/.gitignore new file mode 100644 index 0000000..db5b5fd --- /dev/null +++ b/license-backend/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +data/ +*.db +*.db-shm +*.db-wal +npm-debug.log* diff --git a/license-backend/Dockerfile b/license-backend/Dockerfile new file mode 100644 index 0000000..cb86fda --- /dev/null +++ b/license-backend/Dockerfile @@ -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"] diff --git a/license-backend/README.md b/license-backend/README.md new file mode 100644 index 0000000..9774d34 --- /dev/null +++ b/license-backend/README.md @@ -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: `) + +**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//.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:///`; 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. +``` diff --git a/license-backend/docker-compose.yml b/license-backend/docker-compose.yml new file mode 100644 index 0000000..a5bfce1 --- /dev/null +++ b/license-backend/docker-compose.yml @@ -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: diff --git a/license-backend/package.json b/license-backend/package.json new file mode 100644 index 0000000..2c3849d --- /dev/null +++ b/license-backend/package.json @@ -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" + } +} diff --git a/license-backend/src/db.js b/license-backend/src/db.js new file mode 100644 index 0000000..1305c03 --- /dev/null +++ b/license-backend/src/db.js @@ -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); + } +} diff --git a/license-backend/src/scan.js b/license-backend/src/scan.js new file mode 100644 index 0000000..9f492fc --- /dev/null +++ b/license-backend/src/scan.js @@ -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 +} diff --git a/license-backend/src/server.js b/license-backend/src/server.js new file mode 100644 index 0000000..34f2f1a --- /dev/null +++ b/license-backend/src/server.js @@ -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}`); +}); diff --git a/license-backend/src/util.js b/license-backend/src/util.js new file mode 100644 index 0000000..44617db --- /dev/null +++ b/license-backend/src/util.js @@ -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 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; + } +} diff --git a/license-backend/test/hooks.mjs b/license-backend/test/hooks.mjs new file mode 100644 index 0000000..2651f59 --- /dev/null +++ b/license-backend/test/hooks.mjs @@ -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 ); +} diff --git a/license-backend/test/integration.mjs b/license-backend/test/integration.mjs new file mode 100644 index 0000000..d973637 --- /dev/null +++ b/license-backend/test/integration.mjs @@ -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); diff --git a/license-backend/test/register.mjs b/license-backend/test/register.mjs new file mode 100644 index 0000000..982f6d3 --- /dev/null +++ b/license-backend/test/register.mjs @@ -0,0 +1,4 @@ +import { register } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +register( './test/hooks.mjs', pathToFileURL( './' ) ); diff --git a/license-backend/test/scan.test.mjs b/license-backend/test/scan.test.mjs new file mode 100644 index 0000000..f939ab3 --- /dev/null +++ b/license-backend/test/scan.test.mjs @@ -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 = ` + + + + + + + + + + + + x +`; + +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); diff --git a/license-backend/test/shim-better-sqlite3.mjs b/license-backend/test/shim-better-sqlite3.mjs new file mode 100644 index 0000000..9670db9 --- /dev/null +++ b/license-backend/test/shim-better-sqlite3.mjs @@ -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(); } +} diff --git a/license-backend/test/util.test.mjs b/license-backend/test/util.test.mjs new file mode 100644 index 0000000..a84a5ed --- /dev/null +++ b/license-backend/test/util.test.mjs @@ -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); diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..a8d1064 --- /dev/null +++ b/release.ps1 @@ -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."