post_password === ''; } /** * Alle oeffentlichen Inhalte einsammeln. * * Titel und ein Textauszug muessen mit: Genau daran erkennt Same-QR dieselbe * Seite wieder, wenn sich die Adresse geaendert hat. Beim Wechsel des Systems * ist die Adresse das Einzige, was garantiert nicht mehr passt. */ function samegr_seiten_sammeln(): array { $seiten = []; $typen = get_post_types(['public' => true], 'names'); $abfrage = new WP_Query([ 'post_type' => array_values($typen), 'post_status' => 'publish', 'has_password' => false, 'posts_per_page' => 500, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- Laeuft nur im Abgleich, nie beim Seitenaufbau. 'no_found_rows' => true, ]); foreach ($abfrage->posts as $beitrag) { // Doppelt: Ein Filter eines anderen Plugins kann die Abfrage aendern. if (!samegr_oeffentlich($beitrag)) continue; $seiten[] = [ 'url' => get_permalink($beitrag), 'titel' => get_the_title($beitrag), // Ohne Auszeichnung und gekuerzt: Der Vergleich braucht Woerter, // keine Formatierung, und die Anfrage soll klein bleiben. 'text' => mb_substr(wp_strip_all_tags(strip_shortcodes($beitrag->post_content)), 0, 1500), ]; } wp_reset_postdata(); return $seiten; } /** * IDs oeffentlicher Beitraege, die einen bestimmten Meta-Eintrag haben. * Ueber WP_Query statt einer eigenen SQL-Abfrage, damit Caches und Filter * anderer Plugins greifen. */ function samegr_beitraege_mit_meta(string $schluessel): array { return get_posts([ 'post_type' => array_values(get_post_types(['public' => true], 'names')), 'post_status' => 'publish', 'has_password' => false, 'meta_key' => $schluessel, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Laeuft nur im Abgleich, nie beim Seitenaufbau. 'posts_per_page' => 500, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- Laeuft nur im Abgleich, nie beim Seitenaufbau. 'fields' => 'ids', 'no_found_rows' => true, ]); } /** * Zuordnung alt -> neu, soweit WordPress sie selbst kennt. * * Beim Aendern eines Permalinks legt WordPress die alte Adresse als * '_wp_old_slug' ab, damit eigene Weiterleitungen funktionieren. Genau das * ist die Antwort, die wir sonst erraten muessten. * * Zwei Haken (seit 0.1.1 beachtet): * - Die alte Adresse ist der heutige Permalink mit dem alten Slug, nicht * home_url('/slug/'). Bei /2024/05/beitrag/ oder /produkt/beitrag/ waere * das sonst eine Adresse, die es nie gab. * - WordPress raeumt _wp_old_slug nie auf. Traegt inzwischen ein anderer * Beitrag die alte Adresse, ist die Zuordnung veraltet und wird nicht * gemeldet. Sonst bog Same-QR den gedruckten Code von der lebenden Seite * weg. */ function samegr_bekannte_zuordnung(): array { $zuordnung = []; foreach (samegr_beitraege_mit_meta('_wp_old_slug') as $id) { $beitrag = get_post($id); if (!samegr_oeffentlich($beitrag)) continue; $neu = get_permalink($beitrag); if (!$neu || $beitrag->post_name === '') continue; foreach ((array) get_post_meta($id, '_wp_old_slug') as $wert) { $alter_slug = trim((string) $wert, '/'); if ($alter_slug === '') continue; // Den heutigen Slug im Permalink (das letzte Vorkommen) gegen den // alten tauschen. Steht er dort nicht (einfache Permalinks // ?p=123), gibt es keine verlaessliche alte Adresse. $teil = '/' . $beitrag->post_name . '/'; $stelle = strrpos($neu, $teil); if ($stelle === false) continue; $alt = substr_replace($neu, '/' . $alter_slug . '/', $stelle, strlen($teil)); // Gegencheck: Gehoert die alte Adresse heute einem anderen Beitrag? $heute = url_to_postid($alt); if ($heute && $heute !== (int) $beitrag->ID) continue; $zuordnung[] = ['alt' => $alt, 'neu' => $neu]; } } // Selbst gemerkte alte Adressen (samegr_merke_alte_adresse). Sie decken // ab, was _wp_old_slug nicht kennt: Seiten, geaenderte Elternseiten. $gesehen = array_column($zuordnung, 'alt'); foreach (samegr_beitraege_mit_meta('_samegr_alte_adresse') as $id) { $beitrag = get_post($id); if (!samegr_oeffentlich($beitrag)) continue; $neu = get_permalink($beitrag); foreach ((array) get_post_meta($id, '_samegr_alte_adresse') as $wert) { $alt = (string) $wert; if (!$neu || $alt === '' || $alt === $neu || in_array($alt, $gesehen, true)) continue; $heute = url_to_postid($alt); if ($heute && $heute !== (int) $beitrag->ID) continue; $zuordnung[] = ['alt' => $alt, 'neu' => $neu]; $gesehen[] = $alt; } } return $zuordnung; } /** * Alte Adresse selbst merken, sobald sich ein Permalink aendert. * * WordPress legt _wp_old_slug nur fuer nicht-hierarchische Inhalte an, * Seiten bekommen keinen. Beim lokalen Test (03.10.2026) kam deshalb fuer eine * umbenannte Seite "unklar" statt "sicher" zurueck. Gemerkt wird erst ab * Aktivierung des Plugins; je Beitrag hoechstens 20 alte Adressen. Das bleibt * lokal in der Datenbank; gesendet wird es erst mit einem Abgleich. */ function samegr_merke_alte_adresse($post_id, $nachher, $vorher) { if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) return; if (!samegr_oeffentlich($vorher)) return; $alt = get_permalink($vorher); $neu = get_permalink($nachher); if (!$alt || !$neu || $alt === $neu) return; $bisher = get_post_meta($post_id, '_samegr_alte_adresse'); if (in_array($alt, $bisher, true)) return; if (count($bisher) >= 20) delete_post_meta($post_id, '_samegr_alte_adresse', $bisher[0]); add_post_meta($post_id, '_samegr_alte_adresse', $alt); // Automatik: Nicht im Speichern selbst abgleichen (das haelt den Editor // bis zu einer Minute auf), sondern kurz danach im Hintergrund. Mehrere // Aenderungen hintereinander ergeben so einen einzigen Abgleich. if (samegr_auto_an()) { update_option('samegr_auto_faellig', time(), false); if (!wp_next_scheduled('samegr_auto_abgleich')) { wp_schedule_single_event(time() + 15, 'samegr_auto_abgleich'); } } } add_action('post_updated', 'samegr_merke_alte_adresse', 10, 3); // --- Schluessel verschluesselt ablegen ------------------------------------- // Hashen geht nicht: Das Plugin muss den Schluessel bei jedem Abgleich // mitschicken. Er liegt deshalb verschluesselt in der Datenbank, mit einem // Schluessel aus den geheimen Werten der wp-config.php (AUTH_KEY/AUTH_SALT). // Wer nur die Datenbank hat (ein Backup, eine SQL-Luecke in einem anderen // Plugin), bekommt damit nichts Brauchbares. Wer den ganzen Server hat, kann // ihn entschluesseln wie WordPress selbst; dagegen hilft nur, dass ein // Website-Schluessel ohnehin nur den Abgleich dieser Website darf. function samegr_kryptoschluessel(): string { return hash('sha256', wp_salt('auth') . '|same-qr-token', true); } function samegr_token_speichern(string $klar): void { if (function_exists('openssl_encrypt')) { $iv = random_bytes(12); $tag = ''; $geheim = openssl_encrypt($klar, 'aes-256-gcm', samegr_kryptoschluessel(), OPENSSL_RAW_DATA, $iv, $tag); if ($geheim !== false) { update_option('samegr_token', 'v1:' . base64_encode($iv . $tag . $geheim), false); samegr_zeitplan(); return; } } // Ohne OpenSSL (sehr selten) bleibt nur der Klartext. update_option('samegr_token', $klar, false); samegr_zeitplan(); } function samegr_token_lesen(): string { $roh = (string) get_option('samegr_token', ''); if ($roh === '' || strpos($roh, 'v1:') !== 0) { // Aus Version 0.3 und frueher noch im Klartext: beim ersten Lesen // verschluesseln. if ($roh !== '' && function_exists('openssl_encrypt')) samegr_token_speichern($roh); return $roh; } $daten = base64_decode(substr($roh, 3), true); if ($daten === false || strlen($daten) < 29) return ''; $klar = openssl_decrypt(substr($daten, 28), 'aes-256-gcm', samegr_kryptoschluessel(), OPENSSL_RAW_DATA, substr($daten, 0, 12), substr($daten, 12, 16)); // Falsch heisst hier meist: Die Salts in wp-config.php wurden erneuert. // Dann muss der Schluessel neu eingetragen werden. return $klar === false ? '' : $klar; } // Verbindung trennen: Schluessel weg und kein Hintergrundauftrag mehr, der // noch etwas senden koennte. function samegr_trennen(): void { delete_option('samegr_token'); delete_option('samegr_auto_faellig'); wp_clear_scheduled_hook('samegr_taeglich'); wp_clear_scheduled_hook('samegr_auto_abgleich'); } // --- Automatik -------------------------------------------------------------- // Uebernommen wird nur "sicher". Das gibt es ausschliesslich aus der // Zuordnung, die WordPress selbst liefert (alte -> neue Adresse desselben // Beitrags), nie aus Textvergleich oder Modell. Alles andere bleibt Vorschlag. function samegr_auto_an(): bool { return get_option('samegr_auto', '1') === '1' && get_option('samegr_token', '') !== ''; } function samegr_auto_ausfuehren() { if (!get_option('samegr_auto_faellig') || !samegr_auto_an()) return; delete_option('samegr_auto_faellig'); $ergebnis = samegr_abgleich('sichere'); update_option('samegr_auto_letzter', is_wp_error($ergebnis) ? ['am' => time(), 'fehler' => $ergebnis->get_error_message()] : [ 'am' => time(), 'uebernommen' => is_array($ergebnis['angewendet'] ?? null) ? count($ergebnis['angewendet']) : 0, 'offen' => count(array_filter( is_array($ergebnis['vorschlaege'] ?? null) ? $ergebnis['vorschlaege'] : [], fn($v) => is_array($v) && ($v['sicherheit'] ?? '') !== 'sicher' )), ], false); } add_action('samegr_auto_abgleich', 'samegr_auto_ausfuehren'); // Einmal am Tag, auch ohne Aenderung: Das Dashboard von Same-QR arbeitet mit // dem, was das Plugin zuletzt geschickt hat ("Ich habe umgebaut", "Code // aktualisieren"). Bleibt das Plugin 30 Tage still, liest Same-QR die Website // wieder von aussen. // // Eingeplant wird der Auftrag erst mit der Verbindung (samegr_token_speichern), // nie beim Aktivieren: Vorher hat der Admin dem Senden nicht zugestimmt // (wordpress.org, Guideline 7). function samegr_zeitplan(): void { if (get_option('samegr_token', '') !== '' && !wp_next_scheduled('samegr_taeglich')) { wp_schedule_event(time() + 300, 'daily', 'samegr_taeglich'); } } add_action('samegr_taeglich', function () { if (!samegr_auto_an()) return; update_option('samegr_auto_faellig', time(), false); samegr_auto_ausfuehren(); }); register_deactivation_hook(__FILE__, function () { wp_clear_scheduled_hook('samegr_taeglich'); wp_clear_scheduled_hook('samegr_auto_abgleich'); }); // Rueckfall, falls WP-Cron auf dem Server nicht laeuft (abgeschaltet, keine // Besucher): Der naechste Aufruf im Adminbereich holt einen faelligen // Abgleich nach. Faellig wird er nur bei bestehender Verbindung. add_action('admin_init', function () { $faellig = (int) get_option('samegr_auto_faellig', 0); if ($faellig && time() - $faellig >= 15) samegr_auto_ausfuehren(); }); /** * Abgleich anstossen. $anwenden === 'sichere' uebernimmt nur, was eindeutig * ist; alles andere wartet auf eine Bestaetigung im Same-QR-Dashboard. */ function samegr_abgleich(string $anwenden = 'keine') { // Der Schluessel geht nur verschluesselt uebers Netz: https, oder http // allein zum eigenen Rechner (lokaler Test). $ziel = wp_parse_url(SAMEGR_API); $lokal = in_array($ziel['host'] ?? '', ['localhost', '127.0.0.1', '::1'], true); if (($ziel['scheme'] ?? '') !== 'https' && !$lokal) { return new WP_Error('samegr_unsicher', __('SAMEGR_API must start with https://.', 'same-qr')); } $token = samegr_token_lesen(); if (!$token) { return new WP_Error('samegr_kein_token', get_option('samegr_token') ? __('The key can no longer be read (were the salts in wp-config.php renewed?). Please connect again.', 'same-qr') : __('Not connected to Same-QR yet.', 'same-qr')); } $antwort = wp_remote_post(SAMEGR_API, [ 'timeout' => 60, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode([ 'origin' => home_url(), 'seiten' => samegr_seiten_sammeln(), 'zuordnung' => samegr_bekannte_zuordnung(), 'anwenden' => $anwenden, ]), ]); if (is_wp_error($antwort)) return $antwort; // Eine Fehlerantwort (401, 403, 429 …) ist kein Ergebnis. Vorher stand // dann "0 Codes geprueft" da, als waere alles in Ordnung. $status = (int) wp_remote_retrieve_response_code($antwort); $daten = json_decode(wp_remote_retrieve_body($antwort), true); if ($status !== 200 || !is_array($daten)) { $grund = is_array($daten) && isset($daten['error']) && is_string($daten['error']) ? $daten['error'] : __('unknown error', 'same-qr'); /* translators: 1: HTTP status code, 2: error message from Same-QR */ return new WP_Error('samegr_antwort', sprintf(__('Same-QR answered with %1$d: %2$s', 'same-qr'), $status, $grund)); } return $daten; } // --- Ein-Klick-Verbindung --------------------------------------------------- // Wie "Mit Google anmelden" (OAuth mit PKCE, Gegenstueck: lib/verbinden.js im // Same-QR-Server). Der Betreiber klickt "Connect with Same-QR", erlaubt es // dort mit einem Klick, und das Plugin holt sich den Website-Schluessel // selbst, Server an Server. Niemand sieht, kopiert oder fuegt ihn ein. function samegr_basis(): string { $u = wp_parse_url(SAMEGR_API); return ($u['scheme'] ?? 'https') . '://' . ($u['host'] ?? 'sameqr.com') . (isset($u['port']) ? ':' . $u['port'] : ''); } function samegr_b64url(string $roh): string { return rtrim(strtr(base64_encode($roh), '+/', '-_'), '='); } function samegr_seitenadresse(array $extra = []): string { return add_query_arg($extra, admin_url('admin.php?page=same-qr')); } add_action('admin_init', function () { // Nur Lesen, welche Seite offen ist. Geschuetzt wird unten: Schritt 1 // mit Nonce, Schritt 3 ueber den state, der zu diesem Benutzer passen muss. $seite = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ($seite !== 'same-qr' || !current_user_can('manage_options')) return; $merker = 'samegr_verbindung_' . get_current_user_id(); // Schritt 1: zu Same-QR schicken. Das Geheimnis (verifier) bleibt hier; // hinaus geht nur sein Hash (challenge). if (isset($_POST['samegr_verbinden_start'])) { check_admin_referer('samegr_speichern'); $verifier = samegr_b64url(random_bytes(32)); $state = samegr_b64url(random_bytes(24)); set_transient($merker, ['verifier' => $verifier, 'state' => $state], 15 * MINUTE_IN_SECONDS); $host = (string) wp_parse_url(samegr_basis(), PHP_URL_HOST); add_filter('allowed_redirect_hosts', function ($hosts) use ($host) { $hosts[] = $host; return $hosts; }); wp_safe_redirect(samegr_basis() . '/verbinden?' . http_build_query([ 'website' => home_url(), 'zurueck' => samegr_seitenadresse(), 'state' => $state, 'challenge' => samegr_b64url(hash('sha256', $verifier, true)), ])); exit; } // Schritt 3: zurueck von Same-QR mit Einmal-Code. state muss zu DIESEM // Benutzer und DIESEM Versuch passen, sonst koennte ein fremder Link // einen Code eines anderen Kontos unterschieben. Der state uebernimmt hier // die Rolle der Nonce (Rueckkehr von einer fremden Seite). // phpcs:disable WordPress.Security.NonceVerification.Recommended if (isset($_GET['samegr_code'], $_GET['samegr_state'])) { $versuch = get_transient($merker); delete_transient($merker); $code = sanitize_text_field(wp_unslash($_GET['samegr_code'])); $state = sanitize_text_field(wp_unslash($_GET['samegr_state'])); // phpcs:enable WordPress.Security.NonceVerification.Recommended if (!is_array($versuch) || !hash_equals((string) $versuch['state'], $state)) { wp_safe_redirect(samegr_seitenadresse(['samegr_fehler' => 'state'])); exit; } $antwort = wp_remote_post(samegr_basis() . '/api/verbinden/tausch', [ 'timeout' => 20, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode(['code' => $code, 'verifier' => $versuch['verifier'], 'website' => home_url()]), ]); $daten = is_wp_error($antwort) ? null : json_decode(wp_remote_retrieve_body($antwort), true); if (!is_array($daten) || empty($daten['token']) || !is_string($daten['token'])) { set_transient($merker . '_fehler', is_array($daten) && isset($daten['error']) && is_string($daten['error']) ? $daten['error'] : __('Same-QR could not be reached.', 'same-qr'), 60); wp_safe_redirect(samegr_seitenadresse(['samegr_fehler' => 'tausch'])); exit; } samegr_token_speichern($daten['token']); // Code und state aus der Adresse, damit sie in keinem Verlauf bleiben. wp_safe_redirect(samegr_seitenadresse(['samegr_verbunden' => '1'])); exit; } }); // --- Einstellungsseite ------------------------------------------------------ // Bewusst minimal: Verbinden, Automatik, ein Knopf zum Abgleichen. Alles // Weitere gehoert ins Dashboard von Same-QR und nicht in dieses Plugin. // Eigener Menuepunkt statt unter "Einstellungen": Wer nach einem Relaunch // die Codes nachziehen will, soll das Plugin auf Anhieb finden. add_action('admin_menu', function () { $icon = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIgMmg3djdIMnptMiAydjNoM1Y0em03LTJoN3Y3aC03em0yIDJ2M2gzVjR6TTIgMTFoN3Y3SDJ6bTIgMnYzaDN2LTN6bTctMmgydjJoLTJ6bTQgMGgzdjJoLTN6bS00IDRoMnYzaC0yem0zLTJoMnYyaC0yem0yIDJoM3YzaC0zeiIvPjwvc3ZnPg=='; add_menu_page('Same-QR', 'Same-QR', 'manage_options', 'same-qr', 'samegr_seite', $icon, 81); }); // Aussehen nur auf der eigenen Seite laden, nirgends sonst im Adminbereich. add_action('admin_enqueue_scripts', function ($hook) { if ($hook !== 'toplevel_page_same-qr') return; wp_enqueue_style('same-qr-admin', plugins_url('css/admin.css', __FILE__), [], SAMEGR_VERSION); }); // "Settings"-Link in der Pluginliste fuehrt direkt auf die Seite. add_filter('plugin_action_links_' . plugin_basename(__FILE__), function ($links) { array_unshift($links, '' . esc_html__('Settings', 'same-qr') . ''); return $links; }); function samegr_hinweis(string $text, string $art = 'success'): void { printf('
%s
' . esc_html__('Your printed QR codes keep working, even when addresses on your website change.', 'same-qr') . '
'; // Kurzanleitung echo '' . esc_html__('All good: no QR code points to an address that no longer exists.', 'same-qr') . '
'; } else { $namen = [ 'sicher' => __('Certain', 'same-qr'), 'wahrscheinlich' => __('Likely', 'same-qr'), 'unklar' => __('Unclear', 'same-qr'), ]; printf( '| %s | %s | %s |
|---|---|---|
| %s | %s | %s%s |