$p ) { $p = (string) $p; if ( '' === (string) preg_replace( '/[\s\p{C}\p{Z}]+/u', '', $p ) ) { continue; // blank / whitespace / zero-width → silent skip } // Re-use the single source of truth instead of re-testing here: matching against // an EMPTY subject never backtracks, so it silently declares a catastrophic // pattern healthy. validate_regex_pattern() probes a ~1080-char string and also // treats a compile failure as invalid. // A short dot-less pattern is not a regex at all — it is the too-broad host // fragment OWCO_Core::match_pattern() refuses and records — so keep it as well. $too_short = ( strlen( $p ) < 6 && strpos( $p, '.' ) === false ); if ( $too_short || ! OWCO_Core::validate_regex_pattern( $p ) ) { $still_bad[ $h ] = $p; } } if ( empty( $still_bad ) ) { // Nothing genuinely invalid anymore → clear the stale transient and show nothing. delete_transient( 'owco_invalid_patterns' ); return; } if ( count( $still_bad ) !== count( $list ) ) { set_transient( 'owco_invalid_patterns', $still_bad, DAY_IN_SECONDS ); } echo '

OW Consent: ' . esc_html__( 'Some tracker-catalogue patterns are invalid and were skipped, so whatever they were meant to block is not blocked:', 'ow-consent' ) . '

' . esc_html__( 'Repair them in the tracker catalogue', 'ow-consent' ) . '

'; } /** * Tell the administrator when the ledger signing key lives in the database. * * Without OWCO_LEDGER_KEY in wp-config.php the plugin signs the chain with a random * key it keeps in the database. The hash chain is still built, but anyone with * database access could re-sign it, so the tamper-evidence claim does not hold and * the administrator must be told (FIX-CONTRACT §6). * * Guideline 11: confined to the plugin's own screens since 1.4.4. Nothing is * broken and no consent is being lost — the register is written and the chain is * built; what is reduced is the evidential weight of that chain. It is also a * permanent state until someone edits wp-config.php, so site-wide it would be a * warning that never goes away, on every screen, forever. It is shown where the * register itself is read, which is where the claim it qualifies is being relied * on, and it stays dismissible. * * @return void */ public static function notice_ledger_key() { if ( ! self::is_own_screen() ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } if ( ! class_exists( 'OWCO_Consent' ) || ! method_exists( 'OWCO_Consent', 'ledger_key_warning' ) ) { return; } if ( ! OWCO_Core::setting( 'ledger_enabled' ) ) { return; } $warning = (string) OWCO_Consent::ledger_key_warning(); if ( '' === $warning ) { return; } echo '

OW Consent: ' . esc_html( $warning ) . '

'; } /* ================================================================= * GUIDELINE 11 — WHERE A NOTICE IS ALLOWED TO APPEAR * ============================================================== */ /** * Whether the screen being rendered belongs to this plugin. * * Every notice this class raises except one is gated on this. Read from * WP_Screen rather than from `$_GET['page']`, so that no superglobal is consulted * to decide what is printed and a screen reached by any other route is judged the * same way. * * @return bool */ protected static function is_own_screen() { if ( ! function_exists( 'get_current_screen' ) ) { return false; } $screen = get_current_screen(); if ( ! is_object( $screen ) || ! isset( $screen->id ) ) { return false; } return ( false !== strpos( (string) $screen->id, self::MENU_SLUG ) ); } /** * The only notice this plugin raises outside its own screens. * * It says one thing: visitors are using the cookie banner and this site is * answering that it cannot record their choice. That is the single condition an * administrator must not be left to discover by opening our menu — nothing is * stored for those visitors, so the site can prove neither a consent nor a * refusal for them, and every hour it continues is an hour of collection it * cannot account for. It is also, unlike every other warning here, TRANSIENT: * it appears because something broke, and it stops on its own once the record is * clear or the last refusal ages out of the window. * * What keeps it inside Guideline 11: * - it is printed only while at least one genuine refusal is less than * REFUSAL_NOTICE_DAYS old — never as a standing banner; * - it is dismissible, and the dismissal is REMEMBERED per user, so it does not * come back on the next page load the way `is-dismissible` alone would; * - a dismissal silences the refusals known at that moment, not the condition: * a refusal recorded afterwards raises it again, because that is new * information and not a repetition; * - it never appears on our own screens, which carry the full incident report * (render_write_refusals_panel()) instead of this three-line summary. * * The amber half of the record — events where the choice WAS saved and something * merely interfered — is deliberately excluded. Those belong on the dashboard * panel; raising them here would make a site that is working look broken. * * @return void */ public static function notice_write_refusals() { if ( ! current_user_can( 'manage_options' ) ) { return; } if ( self::is_own_screen() ) { return; } $refused = self::recent_write_refusals(); if ( empty( $refused ) ) { return; } $newest = (int) $refused[0]['time']; $seen = (int) get_user_meta( get_current_user_id(), self::DISMISS_REFUSALS_META, true ); if ( $seen >= $newest ) { return; } $total = count( $refused ); $dismiss_url = add_query_arg( array( 'owco_dismiss' => 'write_refusals', '_owco_dismiss_nonce' => wp_create_nonce( self::DISMISS_NONCE_ACTION ), ) ); ?>

' . esc_html__( 'Settings', 'ow-consent' ) . '' ); return $links; } /* ================================================================= * MENU * ============================================================== */ public static function register_menu() { add_menu_page( // Product name: printed as-is on purpose, so a locale cannot translate the brand. 'OW Consent', 'OW Consent', 'manage_options', self::MENU_SLUG, array( __CLASS__, 'render_page' ), 'dashicons-shield-alt', 76 ); // No submenu : navigation is via in-page tabs. // The auto-created duplicate submenu is removed by cleanup_submenu() on admin_menu priority 999. } /** * Remove the auto-created submenu (WordPress adds a duplicate of the parent menu title). * Hooked late (priority 999) so it runs after all submenu construction. */ public static function cleanup_submenu() { global $submenu; if ( isset( $submenu[ self::MENU_SLUG ] ) ) { unset( $submenu[ self::MENU_SLUG ] ); } } protected static function tabs() { return array( 'dashboard' => __( 'Dashboard', 'ow-consent' ), 'banner' => __( 'Banner', 'ow-consent' ), 'compliance' => __( 'Compliance', 'ow-consent' ), 'legal' => __( 'Legal identity', 'ow-consent' ), 'categories' => __( 'Categories', 'ow-consent' ), 'policies' => __( 'Policies', 'ow-consent' ), 'scanner' => __( 'Scanner', 'ow-consent' ), 'catalog' => __( 'Tracker catalogue', 'ow-consent' ), 'ledger' => __( 'Audit ledger', 'ow-consent' ), 'dsar' => __( 'DSAR requests', 'ow-consent' ), ); } /** * Admin URL of one of the plugin tabs, with optional extra query arguments. * * @param string $tab Tab slug. * @param array $args Extra query arguments. * @return string */ protected static function tab_url( $tab, array $args = array() ) { $args = array_merge( array( 'page' => self::MENU_SLUG, 'tab' => (string) $tab ), $args ); return add_query_arg( $args, admin_url( 'admin.php' ) ); } /* ================================================================= * ASSETS * ============================================================== */ /** * Style handle of the admin stylesheet, assets/css/admin.css. * * The plugin's own handle, not core's `wp-admin`. Attaching to a core handle * makes this plugin's CSS part of a stylesheet every screen of every other * plugin also loads, it is emitted even on screens this enqueue deliberately * skipped, and it cannot be dequeued without taking core's admin CSS with it. * * @var string */ const STYLE_HANDLE = 'owco-admin'; public static function enqueue_assets( $hook ) { if ( strpos( (string) $hook, self::MENU_SLUG ) === false ) { return; } $ver = defined( 'OWCO_VERSION' ) ? (string) OWCO_VERSION : false; wp_enqueue_style( self::STYLE_HANDLE, OWCO_URL . 'assets/css/admin.css', array( 'wp-admin' ), $ver ); } /* ================================================================= * SAVE (admin-post style) * ============================================================== */ public static function handle_save() { // === GET-based actions (URL links with nonce) === if ( ! empty( $_GET['owco_action'] ) && ! empty( $_GET['_owco_nonce_url'] ) ) { if ( ! current_user_can( 'manage_options' ) ) { return; } if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_owco_nonce_url'] ) ), self::SAVE_NONCE_ACTION ) ) { wp_die( esc_html__( 'Invalid nonce.', 'ow-consent' ) ); } $action = sanitize_key( wp_unslash( $_GET['owco_action'] ) ); if ( $action === 'generate_policy_inline' ) { $type = isset( $_GET['policy_type'] ) ? sanitize_key( wp_unslash( $_GET['policy_type'] ) ) : ''; $result = OWCO_Policies::generate_page( $type, array( 'publish' => true ) ); set_transient( 'owco_policy_gen_result', self::policy_result_payload( $result ), 60 ); // No `updated=1` here: nothing was saved, and a WP_Error must not be // reported as "Settings saved." The outcome is rendered from the // transient by render_policy_gen_result() on the target tab. wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'tab' => 'compliance' ), admin_url( 'admin.php' ) ) ); exit; } if ( 0 === strpos( $action, 'export_' ) ) { self::handle_export( substr( $action, 7 ) ); return; } } if ( empty( $_POST['owco_admin_action'] ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } if ( ! isset( $_POST['_owco_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_owco_nonce'] ) ), self::SAVE_NONCE_ACTION ) ) { wp_die( esc_html__( 'Invalid nonce.', 'ow-consent' ) ); } $action = sanitize_key( wp_unslash( $_POST['owco_admin_action'] ) ); if ( $action === 'save_settings' ) { $before_scanner = array( 'enabled' => (bool) OWCO_Core::setting( 'scanner_enabled' ), 'frequency' => (string) OWCO_Core::setting( 'scanner_frequency' ), ); self::save_settings_from_post(); self::maybe_reschedule_scanner( $before_scanner ); $tab = isset( $_POST['_tab'] ) ? sanitize_key( wp_unslash( $_POST['_tab'] ) ) : 'dashboard'; $tabs = self::tabs(); if ( ! isset( $tabs[ $tab ] ) ) { $tab = 'dashboard'; } wp_safe_redirect( add_query_arg( array( 'updated' => '1', 'tab' => $tab ), admin_url( 'admin.php?page=' . self::MENU_SLUG ) ) ); exit; } if ( $action === 'reset_dpa_authority' ) { // Stored as '' rather than as the shipped 'CNIL': both make OWCO_Policies derive // the authority, but an empty value cannot be printed by mistake by anything // that reads the setting directly. $clean = OWCO_Core::sanitize_setting( 'legal_dpa_authority', '' ); OWCO_Core::update_settings( array( 'legal_dpa_authority' => ( null === $clean ? '' : $clean ) ) ); self::set_screen_result( 'success', __( 'The supervisory authority is derived from the compliance profile again.', 'ow-consent' ) ); wp_safe_redirect( self::tab_url( 'dashboard' ) ); exit; } if ( $action === 'clear_write_refusals' ) { if ( class_exists( 'OWCO_REST' ) && method_exists( 'OWCO_REST', 'clear_refusal_log' ) ) { OWCO_REST::clear_refusal_log(); self::set_screen_result( 'success', __( 'The record of refused choices was emptied. If the cause is still there, it fills again as soon as a visitor uses the banner.', 'ow-consent' ) ); } else { self::set_screen_result( 'error', __( 'This record cannot be emptied on this site: the endpoint that keeps it does not offer a way to.', 'ow-consent' ) ); } wp_safe_redirect( self::tab_url( 'dashboard' ) ); exit; } if ( $action === 'verify_ledger' ) { $res = OWCO_Consent::ledger_verify(); set_transient( 'owco_ledger_verify_result', $res, 60 ); wp_safe_redirect( add_query_arg( array( 'tab' => 'ledger' ), admin_url( 'admin.php?page=' . self::MENU_SLUG ) ) ); exit; } if ( $action === 'run_scanner' ) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged,WordPress.PHP.NoSilencedErrors.Discouraged -- a crawl of dozens of URLs needs more than the default limit; the call is disabled on some hosts and its failure is not actionable. @set_time_limit( 120 ); $res = OWCO_Scanner::run_static_scan(); set_transient( 'owco_scanner_run_result', $res, 60 ); wp_safe_redirect( add_query_arg( array( 'tab' => 'scanner' ), admin_url( 'admin.php?page=' . self::MENU_SLUG ) ) ); exit; } if ( $action === 'generate_policy' ) { $type = isset( $_POST['policy_type'] ) ? sanitize_key( wp_unslash( $_POST['policy_type'] ) ) : ''; $publish = ! empty( $_POST['policy_publish'] ); $result = OWCO_Policies::generate_page( $type, array( 'publish' => $publish ) ); set_transient( 'owco_policy_gen_result', self::policy_result_payload( $result ), 60 ); wp_safe_redirect( add_query_arg( array( 'tab' => 'policies' ), admin_url( 'admin.php?page=' . self::MENU_SLUG ) ) ); exit; } if ( $action === 'dsar_resolve' || $action === 'dsar_respond' ) { self::handle_dsar_respond(); return; } if ( $action === 'dsar_resend' || $action === 'dsar_cancel' ) { self::handle_dsar_lifecycle( $action ); return; } if ( $action === 'confirm_finding' ) { $id = isset( $_POST['finding_id'] ) ? absint( wp_unslash( $_POST['finding_id'] ) ) : 0; $cat = isset( $_POST['finding_category'] ) ? sanitize_key( wp_unslash( $_POST['finding_category'] ) ) : 'unknown'; self::classify_findings( array( $id ), $cat ); wp_safe_redirect( self::scanner_return_url() ); exit; } if ( $action === 'bulk_classify' ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability verified at the top of handle_save(); every id is cast with absint() on this line. $raw_ids = isset( $_POST['finding_ids'] ) ? array_map( 'absint', (array) wp_unslash( $_POST['finding_ids'] ) ) : array(); $ids = array(); foreach ( $raw_ids as $raw_id ) { $one = absint( $raw_id ); if ( $one > 0 ) { $ids[] = $one; } } $cat = isset( $_POST['bulk_category'] ) ? sanitize_key( wp_unslash( $_POST['bulk_category'] ) ) : ''; self::classify_findings( $ids, $cat ); wp_safe_redirect( self::scanner_return_url() ); exit; } if ( $action === 'catalog_save' || $action === 'catalog_delete' ) { self::handle_catalog_write( $action ); return; } } /* ================================================================= * SCANNER TRIAGE * ============================================================== */ /** * Apply a category to one or several scanner findings. * * Routed through OWCO_Scanner::classify_finding() rather than a direct UPDATE, because * that method also mirrors the decision into the blocking catalogue. Writing only to * wp_owco_scanner — which is what 1.1.0 did — left the classification with no effect * whatsoever on what the blocker blocks. * * @param array $ids Finding ids. * @param string $category Category slug, or 'unknown' to un-classify. * @return void */ protected static function classify_findings( array $ids, $category ) { $category = sanitize_key( (string) $category ); $allowed = OWCO_Core::categories(); $allowed[] = 'unknown'; if ( empty( $ids ) ) { self::set_screen_result( 'error', __( 'No tracker was selected.', 'ow-consent' ) ); return; } if ( ! in_array( $category, $allowed, true ) ) { self::set_screen_result( 'error', __( 'Choose a category before applying it.', 'ow-consent' ) ); return; } if ( ! class_exists( 'OWCO_Scanner' ) || ! method_exists( 'OWCO_Scanner', 'classify_finding' ) ) { self::set_screen_result( 'error', __( 'The scanner module is unavailable.', 'ow-consent' ) ); return; } $done = 0; foreach ( $ids as $id ) { $res = OWCO_Scanner::classify_finding( (int) $id, $category ); if ( true === $res ) { $done++; } } if ( $done > 0 ) { do_action( 'owco_catalog_updated' ); // 'unknown' writes category_guess = 'unknown', confirmed = 0 and deliberately // skips catalog_entry_for_finding(), so claiming the catalogue was updated // would be false in exactly that case. if ( 'unknown' === $category ) { /* translators: %s: number of trackers moved back to Uncategorized */ $message = _n( '%s tracker moved back to Uncategorized. No blocking rule was written for it.', '%s trackers moved back to Uncategorized. No blocking rule was written for them.', $done, 'ow-consent' ); } else { /* translators: %s: number of trackers whose category was changed */ $message = _n( '%s tracker classified. The blocking catalogue was updated accordingly.', '%s trackers classified. The blocking catalogue was updated accordingly.', $done, 'ow-consent' ); } // Not escaped here: render_screen_result() escapes when it prints. self::set_screen_result( 'success', sprintf( $message, number_format_i18n( $done ) ) ); } else { self::set_screen_result( 'error', __( 'Nothing could be classified — the selected rows no longer exist.', 'ow-consent' ) ); } } /** * Back to the Scanner tab, keeping the filters and the page the admin was on. * * @return string */ protected static function scanner_return_url() { $keep = array(); foreach ( array( 'filter', 'kind', 'state', 'paged' ) as $arg ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- handle_save() verifies the _owco_nonce field and manage_options before dispatching to the handler that calls this helper, so the write is already authorised; this only re-reads the listing state to rebuild the redirect target, unslashed and sanitized on the same line. $val = isset( $_POST[ '_owco_' . $arg ] ) ? sanitize_text_field( wp_unslash( $_POST[ '_owco_' . $arg ] ) ) : ''; if ( '' !== $val ) { $keep[ $arg ] = $val; } } return self::tab_url( 'scanner', $keep ); } /* ================================================================= * DSAR FULFILMENT * ============================================================== */ /** * Record what was done about a request and close it. * * 1.1.0 only flipped `status` to resolved: the site then recorded in its own audit * trail that a right had been honoured, with nothing anywhere saying what was * actually sent or deleted. `response_notes` is the accountability trace GDPR art. 5.2 * expects, so it is now mandatory when closing a request. * * @return void */ protected static function handle_dsar_respond() { global $wpdb; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified by handle_save() before this handler is dispatched; the value is unslashed and cast on this line. $id = isset( $_POST['dsar_id'] ) ? absint( wp_unslash( $_POST['dsar_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- see above; the value is unslashed and sanitized on this line. $notes = isset( $_POST['dsar_notes'] ) ? sanitize_textarea_field( wp_unslash( $_POST['dsar_notes'] ) ) : ''; $notes = ( strlen( $notes ) > 2000 ) ? substr( $notes, 0, 2000 ) : $notes; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- see above; the value is unslashed, sanitized and then checked against an allow-list. $status = isset( $_POST['dsar_status'] ) ? sanitize_key( wp_unslash( $_POST['dsar_status'] ) ) : 'resolved'; if ( ! in_array( $status, array( 'resolved', 'rejected' ), true ) ) { $status = 'resolved'; } if ( $id <= 0 || ! self::table_ready( OWCO_TABLE_DSAR ) ) { self::set_screen_result( 'error', __( 'Unknown request.', 'ow-consent' ) ); wp_safe_redirect( self::tab_url( 'dsar' ) ); exit; } if ( '' === trim( $notes ) ) { self::set_screen_result( 'error', __( 'Say what you did before closing the request: the note is your proof that the right was honoured.', 'ow-consent' ) ); wp_safe_redirect( self::tab_url( 'dsar' ) ); exit; } // The row's own state decides what may be recorded — never the posted form alone. // Without this a replayed POST re-closes a request that was already resolved, // rejected or expired, and "Honoured" can be stamped on an identity nobody ever // confirmed. $table = OWCO_TABLE_DSAR; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table; the identifier is bound as %i and the id as %d. Never cached: the row is about to be written. $current = $wpdb->get_var( $wpdb->prepare( 'SELECT status FROM %i WHERE id = %d', $table, $id ) ); if ( null === $current ) { self::set_screen_result( 'error', __( 'Unknown request.', 'ow-consent' ) ); wp_safe_redirect( self::tab_url( 'dsar' ) ); exit; } $current = (string) $current; if ( ! in_array( $current, array( 'pending', 'verified' ), true ) ) { self::set_screen_result( 'error', __( 'This request is already closed. Its answer cannot be rewritten — the audit trail has to stay as it was recorded.', 'ow-consent' ) ); wp_safe_redirect( self::tab_url( 'dsar' ) ); exit; } if ( 'verified' !== $current && 'resolved' === $status ) { self::set_screen_result( 'error', __( 'A right cannot be recorded as honoured while the requester has never confirmed their identity. Resend the identity link, mark the request expired, or close it as refused.', 'ow-consent' ) ); wp_safe_redirect( self::tab_url( 'dsar' ) ); exit; } $now = gmdate( 'Y-m-d H:i:s' ); $data = array( 'status' => $status, 'handled_at' => $now, 'handled_by_user_id' => get_current_user_id(), 'response_notes' => $notes, 'closed_at' => $now, ); $formats = array( '%s', '%s', '%d', '%s', '%s' ); $suppress = $wpdb->suppress_errors( true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table, single-row admin write; every value is bound by $wpdb->update() and there is nothing to cache. $ok = $wpdb->update( OWCO_TABLE_DSAR, $data, array( 'id' => $id ), $formats, array( '%d' ) ); if ( false === $ok ) { // An install whose 1.2.0 migration has not run yet has no closed_at column. unset( $data['closed_at'] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table, single-row admin write on the pre-1.2.0 column set; values bound by $wpdb->update(). $ok = $wpdb->update( OWCO_TABLE_DSAR, $data, array( 'id' => $id ), array( '%s', '%s', '%d', '%s' ), array( '%d' ) ); } $wpdb->suppress_errors( $suppress ); if ( false === $ok ) { self::set_screen_result( 'error', __( 'The request could not be updated.', 'ow-consent' ) ); } else { do_action( 'owco_dsar_closed', $id, $status, $notes ); self::set_screen_result( 'success', __( 'Request closed and your answer recorded.', 'ow-consent' ) ); } wp_safe_redirect( self::tab_url( 'dsar' ) ); exit; } /** * Give a `pending` request the two actions it never had: resend the identity link, or * abandon it. Before 1.2.0 an unverified request had no control at all and stayed in * the "Pending" card forever. * * @param string $action 'dsar_resend' or 'dsar_cancel'. * @return void */ protected static function handle_dsar_lifecycle( $action ) { global $wpdb; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified by handle_save() before this handler is dispatched; the value is unslashed and cast on this line. $id = isset( $_POST['dsar_id'] ) ? absint( wp_unslash( $_POST['dsar_id'] ) ) : 0; if ( $id <= 0 || ! self::table_ready( OWCO_TABLE_DSAR ) ) { self::set_screen_result( 'error', __( 'Unknown request.', 'ow-consent' ) ); wp_safe_redirect( self::tab_url( 'dsar' ) ); exit; } $table = OWCO_TABLE_DSAR; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table; the identifier is bound as %i and the id as %d. Never cached: the row is about to be written. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT id, request_type, subject_email, status FROM %i WHERE id = %d', $table, $id ), ARRAY_A ); if ( ! $row ) { self::set_screen_result( 'error', __( 'Unknown request.', 'ow-consent' ) ); wp_safe_redirect( self::tab_url( 'dsar' ) ); exit; } if ( 'dsar_cancel' === $action ) { $now = gmdate( 'Y-m-d H:i:s' ); $suppress = $wpdb->suppress_errors( true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table, single-row admin write; values bound by $wpdb->update(). $ok = $wpdb->update( OWCO_TABLE_DSAR, array( 'status' => 'expired', 'handled_at' => $now, 'handled_by_user_id' => get_current_user_id(), 'closed_at' => $now, 'token_hash' => null ), array( 'id' => $id ), array( '%s', '%s', '%d', '%s', '%s' ), array( '%d' ) ); if ( false === $ok ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- same write restricted to the pre-1.2.0 column set; values bound by $wpdb->update(). $ok = $wpdb->update( OWCO_TABLE_DSAR, array( 'status' => 'expired', 'handled_at' => $now ), array( 'id' => $id ), array( '%s', '%s' ), array( '%d' ) ); } $wpdb->suppress_errors( $suppress ); self::set_screen_result( false === $ok ? 'error' : 'success', false === $ok ? __( 'The request could not be updated.', 'ow-consent' ) : __( 'Request marked as expired: the identity of the requester was never confirmed.', 'ow-consent' ) ); wp_safe_redirect( self::tab_url( 'dsar' ) ); exit; } // Resend. if ( 'pending' !== (string) $row['status'] ) { self::set_screen_result( 'error', __( 'Only a request still waiting for identity confirmation can be re-sent.', 'ow-consent' ) ); wp_safe_redirect( self::tab_url( 'dsar' ) ); exit; } if ( ! class_exists( 'OWCO_DSAR' ) || ! method_exists( 'OWCO_DSAR', 'store_token' ) || ! method_exists( 'OWCO_DSAR', 'send_verification_email' ) ) { self::set_screen_result( 'error', __( 'The DSAR module is unavailable.', 'ow-consent' ) ); wp_safe_redirect( self::tab_url( 'dsar' ) ); exit; } // A fresh token invalidates the previous link, which is what "resend" must mean. $token = OWCO_DSAR::store_token( $id ); $sent = OWCO_DSAR::send_verification_email( array( 'id' => $id, 'subject_email' => (string) $row['subject_email'], 'request_type' => (string) $row['request_type'], 'token' => $token, ) ); self::set_screen_result( $sent ? 'success' : 'error', $sent ? __( 'A new identity-verification link was sent; the previous one no longer works.', 'ow-consent' ) : __( 'The verification email could not be sent. Check the site mail configuration.', 'ow-consent' ) ); wp_safe_redirect( self::tab_url( 'dsar' ) ); exit; } /* ================================================================= * TRACKER CATALOGUE WRITES * ============================================================== */ /** * Insert, update or delete one blocking rule in wp_owco_scripts. * * @param string $action 'catalog_save' or 'catalog_delete'. * @return void */ protected static function handle_catalog_write( $action ) { global $wpdb; $back = self::catalog_return_url(); if ( ! self::table_ready( OWCO_TABLE_SCRIPTS ) ) { self::set_screen_result( 'error', __( 'The tracker catalogue table does not exist.', 'ow-consent' ) ); wp_safe_redirect( $back ); exit; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified by handle_save() before this handler is dispatched; the value is unslashed and cast on this line. $id = isset( $_POST['catalog_id'] ) ? absint( wp_unslash( $_POST['catalog_id'] ) ) : 0; if ( 'catalog_delete' === $action ) { if ( $id <= 0 ) { self::set_screen_result( 'error', __( 'Unknown catalogue rule.', 'ow-consent' ) ); wp_safe_redirect( $back ); exit; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table, single-row admin delete; the id is bound by $wpdb->delete(). $deleted = $wpdb->delete( OWCO_TABLE_SCRIPTS, array( 'id' => $id ), array( '%d' ) ); if ( $deleted ) { do_action( 'owco_catalog_updated' ); self::set_screen_result( 'success', __( 'Blocking rule deleted. The tracker it covered is no longer blocked before consent.', 'ow-consent' ) ); } else { self::set_screen_result( 'error', __( 'That rule no longer exists.', 'ow-consent' ) ); } wp_safe_redirect( $back ); exit; } $fields = self::read_catalog_post(); $error = self::validate_catalog_fields( $fields ); if ( '' !== $error ) { self::set_screen_result( 'error', $error ); wp_safe_redirect( $back ); exit; } $data = array( 'pattern' => $fields['pattern'], 'pattern_type' => $fields['pattern_type'], 'match_target' => $fields['match_target'], 'name' => $fields['name'], 'vendor' => $fields['vendor'], 'category' => $fields['category'], 'privacy_policy_url' => $fields['privacy_policy_url'], ); $formats = array( '%s', '%s', '%s', '%s', '%s', '%s', '%s' ); $suppress = $wpdb->suppress_errors( true ); if ( $id > 0 ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table, single-row admin write; every value is bound by $wpdb->update(). $ok = $wpdb->update( OWCO_TABLE_SCRIPTS, $data, array( 'id' => $id ), $formats, array( '%d' ) ); $message = __( 'Blocking rule updated.', 'ow-consent' ); } else { $data['created_at'] = gmdate( 'Y-m-d H:i:s' ); $formats[] = '%s'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table, single-row admin write; every value is bound by $wpdb->insert(). $ok = $wpdb->insert( OWCO_TABLE_SCRIPTS, $data, $formats ); $message = __( 'Blocking rule added. Matching trackers are now held until the visitor consents.', 'ow-consent' ); } $wpdb->suppress_errors( $suppress ); if ( false === $ok ) { self::set_screen_result( 'error', __( 'The rule could not be saved — most likely another rule already uses this exact pattern.', 'ow-consent' ) ); } else { do_action( 'owco_catalog_updated' ); // A repaired pattern must stop being reported as broken. delete_transient( 'owco_invalid_patterns' ); self::set_screen_result( 'success', $message ); } wp_safe_redirect( $back ); exit; } /** * Read the catalogue form fields out of POST. * * @return array */ protected static function read_catalog_post() { $get = array( __CLASS__, 'post_text' ); return array( 'pattern' => call_user_func( $get, 'catalog_pattern', 255 ), 'pattern_type' => sanitize_key( call_user_func( $get, 'catalog_pattern_type', 20 ) ), 'match_target' => sanitize_key( call_user_func( $get, 'catalog_match_target', 10 ) ), 'name' => call_user_func( $get, 'catalog_name', 190 ), 'vendor' => call_user_func( $get, 'catalog_vendor', 190 ), 'category' => sanitize_key( call_user_func( $get, 'catalog_category', 30 ) ), 'privacy_policy_url' => esc_url_raw( call_user_func( $get, 'catalog_policy_url', 500 ) ), ); } /** * One sanitized POST string. * * The only callers are handlers that already verified the nonce and the capability in * handle_save(); the sniff cannot see across that call boundary. * * @param string $key POST key. * @param int $limit Maximum length kept. * @return string */ protected static function post_text( $key, $limit = 190 ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified by handle_save() before any handler runs; the value is unslashed and sanitized on this line. $raw = isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; $raw = trim( $raw ); return ( strlen( $raw ) > $limit ) ? substr( $raw, 0, $limit ) : $raw; } /** * Refuse a catalogue rule the matcher could never apply — or would apply far too widely. * * @param array $f Fields from read_catalog_post(). * @return string Translated error, or '' when the rule is acceptable. */ protected static function validate_catalog_fields( array $f ) { if ( '' === $f['pattern'] ) { return __( 'A blocking rule needs a pattern.', 'ow-consent' ); } if ( ! in_array( $f['pattern_type'], self::catalog_pattern_types(), true ) ) { return __( 'Unknown pattern type.', 'ow-consent' ); } if ( ! in_array( $f['match_target'], array( 'url', 'cookie' ), true ) ) { return __( 'A pattern matches either a resource URL or a cookie name.', 'ow-consent' ); } if ( ! in_array( $f['category'], OWCO_Core::categories(), true ) ) { return __( 'Choose a consent category for this rule.', 'ow-consent' ); } if ( 'regex' === $f['pattern_type'] ) { if ( ! OWCO_Core::validate_regex_pattern( $f['pattern'] ) ) { return __( 'That regular expression does not compile, or it backtracks so much that it would slow every page down. Fix it or use a plain host pattern.', 'ow-consent' ); } } // FIX-CONTRACT §2.5 and OWCO_Core::match_pattern(): a short dot-less host pattern // matches inside unrelated file names ("ads" inside "downloads.js"). if ( 'url' === $f['match_target'] && 'host' === $f['pattern_type'] ) { if ( strlen( $f['pattern'] ) < 6 || strpos( $f['pattern'], '.' ) === false ) { return __( 'A host pattern matched against URLs must contain a dot and be at least 6 characters long, otherwise it matches unrelated files. Use the "Cookie name" target for a short name.', 'ow-consent' ); } } return ''; } /** * Pattern types the catalogue accepts. * * @return array */ protected static function catalog_pattern_types() { return array( 'host', 'regex', 'inline_signature' ); } /** * Back to the catalogue, keeping the filters the admin was using. * * @return string */ protected static function catalog_return_url() { $keep = array(); foreach ( array( 'cat', 'target', 'ptype', 's', 'invalid', 'paged' ) as $arg ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- handle_save() verifies the _owco_nonce field and manage_options before dispatching to the handler that calls this helper, so the write is already authorised; this only re-reads the listing state to rebuild the redirect target, unslashed and sanitized on the same line. $val = isset( $_POST[ '_owco_' . $arg ] ) ? sanitize_text_field( wp_unslash( $_POST[ '_owco_' . $arg ] ) ) : ''; if ( '' !== $val ) { $keep[ $arg ] = $val; } } return self::tab_url( 'catalog', $keep ); } /** * Normalise what OWCO_Policies::generate_page() returned into a displayable array. * * A WP_Error may carry the list of mandatory settings that are still empty as its * error data; that list is what the administrator actually needs to see. * * @param mixed $result Return value of OWCO_Policies::generate_page(). * @return array */ protected static function policy_result_payload( $result ) { if ( ! is_wp_error( $result ) ) { return is_array( $result ) ? $result : array(); } $payload = array( 'error' => $result->get_error_message() ); $data = $result->get_error_data(); if ( is_array( $data ) ) { $missing = isset( $data['missing'] ) ? $data['missing'] : $data; if ( is_array( $missing ) ) { $list = array(); foreach ( $missing as $item ) { if ( is_scalar( $item ) ) { $list[] = (string) $item; } } if ( ! empty( $list ) ) { $payload['missing'] = $list; } } } return $payload; } protected static function save_settings_from_post() { // CRITICAL : only update keys present in this form submit. // Each tab's form has a hidden `value="0"` for every bool it owns → absent keys // mean "this field isn't on the form" (different tab), NOT "user unchecked it". // Otherwise saving one tab would nuke all settings on other tabs. // Nonce already verified by caller handle_save(). // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce + capability verified by the only caller, handle_save(); the array is unslashed here and sanitized per field by OWCO_Core::sanitize_setting(). $incoming = isset( $_POST['owco'] ) ? (array) wp_unslash( $_POST['owco'] ) : array(); $clean = array(); foreach ( $incoming as $k => $v ) { $sanitized = OWCO_Core::sanitize_setting( $k, $v ); if ( $sanitized !== null ) { $clean[ $k ] = $sanitized; } } if ( ! empty( $clean ) ) { OWCO_Core::update_settings( $clean ); } } /** * Re-arm the scanner cron when its schedule settings actually changed. * * `OWCO_Scanner::maybe_schedule()` only ever schedules a *missing* event, so switching * the frequency from Weekly to Daily used to be saved and then ignored until the next * activation. Called right after a settings save, with the values read before it. * * @param array $before array{enabled:bool,frequency:string} state before the save. * @return void */ protected static function maybe_reschedule_scanner( array $before ) { if ( ! class_exists( 'OWCO_Scanner' ) ) { return; } $enabled = (bool) OWCO_Core::setting( 'scanner_enabled' ); $frequency = (string) OWCO_Core::setting( 'scanner_frequency' ); if ( $enabled === $before['enabled'] && $frequency === $before['frequency'] ) { return; // nothing to do. } // Prefer the scanner's own entry point when it exposes one. if ( method_exists( 'OWCO_Scanner', 'reschedule' ) ) { OWCO_Scanner::reschedule(); return; } $hook = defined( 'OWCO_Scanner::CRON_HOOK' ) ? OWCO_Scanner::CRON_HOOK : 'owco_scanner_run'; wp_clear_scheduled_hook( $hook ); if ( ! $enabled ) { return; } $schedules = (array) wp_get_schedules(); $recurrence = isset( $schedules[ $frequency ] ) ? $frequency : 'daily'; wp_schedule_event( time() + ( 10 * MINUTE_IN_SECONDS ), $recurrence, $hook ); } /** * Human label for a cron recurrence, falling back to the slug. * * @param string $slug Recurrence slug. * @return string */ protected static function schedule_label( $slug ) { $schedules = (array) wp_get_schedules(); if ( isset( $schedules[ $slug ]['display'] ) ) { return (string) $schedules[ $slug ]['display']; } return (string) $slug; } /* ================================================================= * RENDER * ============================================================== */ public static function render_page() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin screen tab filter. $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'dashboard'; $tabs = self::tabs(); if ( ! isset( $tabs[ $tab ] ) ) { $tab = 'dashboard'; } ?>
OptionWeb Consent · optionweb.dev

get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $ledger_table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table, bound as %i. Live value, must not be cached. $last_consent = (string) $wpdb->get_var( $wpdb->prepare( 'SELECT created_at FROM %i ORDER BY id DESC LIMIT 1', $ledger_table ) ); } // The 1.1.0 card counted `status = 'pending'` — the one status that is NOT // actionable — and labelled it "to handle within 30 days". $dsar = self::dsar_stats(); $dsar_open = $dsar['pending'] + $dsar['verified']; $profile = OWCO_Core::setting( 'compliance_profile' ); $profile_label = self::profile_label( $profile ); // Diag. $issues = self::compliance_diagnostic(); ?>

0 ) { printf( /* translators: %s: number of requests past their legal deadline */ esc_html( _n( '%s past its legal deadline.', '%s past their legal deadline.', $dsar['overdue'], 'ow-consent' ) ), esc_html( number_format_i18n( $dsar['overdue'] ) ) ); } else { printf( /* translators: %d: number of days the site commits to answer within */ esc_html( _n( 'To answer within %d day.', 'To answer within %d days.', $dsar['response_days'], 'ow-consent' ) ), (int) $dsar['response_days'] ); } ?>

, 2: applied profile in , 3: detection source in */ wp_kses_post( __( 'Visitor detected: country %1$s — applied profile: %2$s — source: %3$s', 'ow-consent' ) ), '' . esc_html( $geo['country'] ?: '—' ) . '', '' . esc_html( $geo['profile'] ) . '', '' . esc_html( $geo['source'] ) . '' ); ?>

✓

0 && 'owco_cookie_not_persisted' !== $row['code'] ) { $refused[] = $row; } else { $notes[] = $row; } } $accent = empty( $refused ) ? '#b45309' : '#b91c1c'; ?>

0 ) { $times[] = $row['time']; } } $newest = empty( $times ) ? 0 : max( $times ); $oldest = empty( $times ) ? 0 : min( $times ); $counts = array(); foreach ( $refused as $row ) { $code = ( '' === $row['code'] ) ? 'unknown' : $row['code']; $counts[ $code ] = isset( $counts[ $code ] ) ? ( $counts[ $code ] + 1 ) : 1; } arsort( $counts ); $top_code = (string) key( $counts ); $top_count = (int) current( $counts ); // The most recent refusal carrying the dominant code: its two host names are // the pair worth printing next to the configured address. $sample = $refused[0]; foreach ( $refused as $row ) { $code = ( '' === $row['code'] ) ? 'unknown' : $row['code']; if ( $code === $top_code ) { $sample = $row; break; } } $explain = self::refusal_explanation( $top_code ); $listed = array_slice( $refused, 0, 10 ); ?>

0 && $newest > 0 && $oldest !== $newest ) { printf( /* translators: 1: number of refused writes. 2: date and time of the oldest one. 3: date and time of the most recent one. */ esc_html( _n( '%1$s choice was refused, between %2$s and %3$s.', '%1$s choices were refused, between %2$s and %3$s.', $total, 'ow-consent' ) ), esc_html( number_format_i18n( $total ) ), esc_html( self::refusal_time_display( $oldest ) ), esc_html( self::refusal_time_display( $newest ) ) ); } elseif ( $newest > 0 ) { printf( /* translators: 1: number of refused writes. 2: date and time of the most recent one. */ esc_html( _n( '%1$s choice was refused, the last one at %2$s.', '%1$s choices were refused, the last one at %2$s.', $total, 'ow-consent' ) ), esc_html( number_format_i18n( $total ) ), esc_html( self::refusal_time_display( $newest ) ) ); } else { printf( /* translators: %s: number of refused writes. */ esc_html( _n( '%s choice was refused.', '%s choices were refused.', $total, 'ow-consent' ) ), esc_html( number_format_i18n( $total ) ) ); } ?>

count( $listed ) ) : ?>

' . esc_html__( 'Recorded, but not cleanly', 'ow-consent' ) . ''; } else { echo '

' . esc_html__( 'Choices are being recorded, but something is interfering', 'ow-consent' ) . '

'; } ?>

0 ? (string) $row['status'] : '—' ); ?>

—

'', 'fix' => '', 'hint' => '' ); switch ( (string) $code ) { case 'owco_bad_origin': $out['cause'] = __( 'This site is being browsed on an address WordPress does not know it by. Writes are refused on the spot, because a request arriving from an address the site does not recognise cannot be told apart from one forged by another site.', 'ow-consent' ); $out['fix'] = __( 'Make the addresses match. The comparison below is the entire answer — one of those lines is different from the others.', 'ow-consent' ); break; case 'owco_missing_origin': $out['cause'] = __( 'The choice arrived with no Origin header, no Referer header and no security token, so nothing whatsoever showed that it came from this site, and it was refused rather than trusted. Something sitting in front of WordPress — a CDN, a reverse proxy, a security or optimisation layer — is removing those headers.', 'ow-consent' ); $out['fix'] = __( 'Ask whoever runs that layer to stop stripping Origin and Referer from POST requests to the address below. If these are instead your own server-to-server calls, allow them deliberately with the owco_allow_headerless_write filter.', 'ow-consent' ); $out['hint'] = '/wp-json/owco/v1/consent'; break; case 'owco_rate_limited': $out['cause'] = __( 'Too many choices were counted against one visitor address within five minutes. This is almost never a real flood: behind a proxy or a CDN the plugin has not been told about, every visitor arrives wearing the same address, so a handful of them exhaust a single budget between them.', 'ow-consent' ); $out['fix'] = __( 'Declare the proxy in wp-config.php so the real visitor address is read instead of the proxy address: give OWCO_TRUSTED_PROXY your proxy or CDN address ranges, comma separated, in place of the example range below.', 'ow-consent' ); $out['hint'] = "define( 'OWCO_TRUSTED_PROXY', '203.0.113.0/24' );"; break; case 'owco_bad_nonce': $out['cause'] = __( 'The one-time security token sent with the choice was rejected as expired or invalid. On a cached site that normally means the banner is being served from a stored copy older than the token it carries, or that the token was issued for one address and sent back to another.', 'ow-consent' ); $out['fix'] = __( 'Exclude the addresses below from every page cache, object cache and CDN copy, then empty the caches once. They have to be answered by WordPress itself, every time.', 'ow-consent' ); $out['hint'] = '/wp-json/owco/v1/'; break; case 'owco_headers_already_sent': $out['cause'] = __( 'By the time the consent cookie had to be written, this site had already started sending the page, so the cookie could not be set at all. A stray blank line or a warning printed by a theme or another plugin is the usual culprit.', 'ow-consent' ); $out['fix'] = __( 'On a staging copy, switch debugging on to find what is printed before the page headers, or deactivate plugins one at a time until it stops. One blank line after a closing PHP tag is enough to cause this.', 'ow-consent' ); break; case 'owco_ledger_write_refused': $out['cause'] = __( 'The choice could not be written into the consent register, so it was refused instead of being reported as saved with no proof behind it. Either the register table is missing, or the database turned the insert down.', 'ow-consent' ); $out['fix'] = __( 'Open the "Audit ledger" tab. If it reports the table as missing, deactivate and reactivate the plugin so the tables are rebuilt. If the table is there, the database user is most likely not allowed to write to it and only the host can grant that.', 'ow-consent' ); break; case 'owco_consent_failed': $out['cause'] = __( 'The site accepted the request but could not apply the choice, and refused it rather than claiming a success it could not deliver.', 'ow-consent' ); $out['fix'] = __( 'The underlying failure is in the server error log at the times listed below. Read it there, or ask your host for that extract.', 'ow-consent' ); break; case 'owco_bad_categories': case 'owco_bad_param': $out['cause'] = __( 'The choice reached this site in a shape the endpoint does not accept. Something between the browser and WordPress rewrote the request: a security module, a script-optimisation plugin, or a cached copy of the banner script older than the plugin itself.', 'ow-consent' ); $out['fix'] = __( 'Empty every cache, the CDN included, and load the site once. If the refusals continue, disable request-filtering and script-optimisation modules one at a time until they stop.', 'ow-consent' ); break; case 'owco_forbidden': case 'rest_forbidden': $out['cause'] = __( 'WordPress refused the request before this plugin ever saw it. On most sites that means a security plugin, or a rule on the server, that blocks anonymous POST requests to the REST API.', 'ow-consent' ); $out['fix'] = __( 'Allow anonymous POST requests to the addresses below, in your security plugin and — if your host runs one — in the server firewall as well.', 'ow-consent' ); $out['hint'] = '/wp-json/owco/v1/'; break; case 'rest_no_route': $out['cause'] = __( 'The address the banner writes to does not exist as far as WordPress is concerned: the REST API is switched off, or the permalink rules that route it were never written.', 'ow-consent' ); $out['fix'] = __( 'Open Settings — Permalinks and save once, without changing anything, to rewrite the rules. If a plugin closes the REST API to visitors who are not logged in, it has to make an exception for this address.', 'ow-consent' ); $out['hint'] = '/wp-json/owco/v1/consent'; break; default: $out['cause'] = __( 'This site refused the write with the code shown above, which is not one of the causes described here. It comes from WordPress itself, or from another plugin answering in place of this one.', 'ow-consent' ); $out['fix'] = __( 'Send this panel, code and all, to whoever maintains the site. The code, the HTTP status and the two addresses in the table below are the whole diagnosis — nothing has to be reproduced in a browser.', 'ow-consent' ); break; } return $out; } /** * The same, for the events that did NOT refuse the write. * * @param string $code Code, already sanitized. * @return array{cause:string,fix:string} */ protected static function refusal_note_explanation( $code ) { switch ( (string) $code ) { case 'rest_cookie_invalid_nonce': return array( 'cause' => __( 'WordPress rejected the login token that came with the choice as belonging to somebody else. The plugin overrode that verdict, so the choice was still recorded — but a visitor receiving another visitor login token means a cache in front of this site is storing and re-serving pages that were built for a signed-in person. That affects far more than the cookie banner.', 'ow-consent' ), 'fix' => __( 'Configure the page cache and the CDN never to store a response that carries a login cookie, and empty both once. If the site is behind a proxy that adds its own caching, it has to be told the same thing.', 'ow-consent' ), ); case 'owco_headers_already_sent': return array( 'cause' => __( 'The choice was recorded, but this site had already started sending the page by the time the consent cookie had to be written, so the server could not set it and the browser had to do it instead. A stray blank line or a warning printed by a theme or another plugin is the usual culprit.', 'ow-consent' ), 'fix' => __( 'On a staging copy, switch debugging on to find what is printed before the page headers, or deactivate plugins one at a time until it stops. Until then, a visitor whose browser refuses that fallback is asked again on every page.', 'ow-consent' ), ); case 'owco_cookie_not_persisted': return array( 'cause' => __( 'What visitors see: they accept or refuse, the banner goes away, and on the very next page it is back and they are asked again — over and over, as if nothing had been recorded. Something was: every one of those choices is in the register, and this site can evidence them. What is missing is on the visitor side. The address people are browsing is not the address WordPress is configured with, so the banner sends the choice to a different address from the one in the address bar. A browser treats that as another site and throws away the cookie that comes back from it, and it is that cookie that remembers the choice from one page to the next.', 'ow-consent' ), 'fix' => __( 'Make the two addresses the same, in Settings — General. The comparison below is the entire answer: one of those lines is different from the others, and until it is not, every visitor is asked again on every page.', 'ow-consent' ), ); default: return array( 'cause' => __( 'The choice was recorded, but the endpoint noted something worth reporting about the way the request arrived.', 'ow-consent' ), 'fix' => __( 'Send this panel, code and all, to whoever maintains the site.', 'ow-consent' ), ); } } /** * The rolling record of refused public writes, normalised for display. * * OWCO_REST::refusal_log() already sanitises what it returns; everything is cast * again here because the record is a stored option that may have been written * by a different version of the plugin. Guarded rather than called outright so * that a build whose REST unit predates the record degrades to printing nothing, * instead of fataling on the one screen that has to open when a site is already * misbehaving. * * @return array[] Rows of time/code/status/origin/host/token, newest first. */ protected static function write_refusals() { if ( ! class_exists( 'OWCO_REST' ) || ! method_exists( 'OWCO_REST', 'refusal_log' ) ) { return array(); } $raw = OWCO_REST::refusal_log(); if ( ! is_array( $raw ) ) { return array(); } $rows = array(); foreach ( $raw as $entry ) { if ( ! is_array( $entry ) || ! isset( $entry['code'] ) ) { continue; } $code = sanitize_key( (string) $entry['code'] ); if ( '' === $code ) { continue; } $rows[] = array( 'time' => ( isset( $entry['at'] ) && is_numeric( $entry['at'] ) ) ? max( 0, (int) $entry['at'] ) : 0, 'code' => $code, 'status' => ( isset( $entry['status'] ) && is_numeric( $entry['status'] ) ) ? max( 0, (int) $entry['status'] ) : 0, 'origin' => isset( $entry['origin'] ) ? self::refusal_host( $entry['origin'] ) : '', 'host' => isset( $entry['host'] ) ? self::refusal_host( $entry['host'] ) : '', 'token' => ! empty( $entry['token'] ), ); } // The record is written newest first and read back in that order; sorting here // as well means a hand-edited or migrated option cannot mislabel the window. usort( $rows, function ( $a, $b ) { if ( $a['time'] === $b['time'] ) { return 0; } return ( $a['time'] < $b['time'] ) ? 1 : -1; } ); return $rows; } /** * Render a refusal timestamp in the site timezone. * * @param int $stamp Unix timestamp. * @return string */ protected static function refusal_time_display( $stamp ) { $stamp = (int) $stamp; if ( $stamp <= 0 ) { return '—'; } return (string) wp_date( 'Y-m-d H:i', $stamp ); } /** * A host name safe to print: bare host, lower case, bounded, nothing else. * * The value originates in a request header, so it is attacker-controlled text. * It is escaped again at print time; it is reduced to host characters here so * that nothing but an address can ever be displayed as one. * * @param string|int|float|bool|null $value Raw value, host name or full URL. * @return string */ protected static function refusal_host( $value ) { if ( null === $value || is_bool( $value ) || is_array( $value ) ) { return ''; } $value = trim( (string) $value ); if ( '' === $value ) { return ''; } if ( false !== strpos( $value, '//' ) ) { $parsed = (string) wp_parse_url( $value, PHP_URL_HOST ); if ( '' !== $parsed ) { $value = $parsed; } } $value = (string) preg_replace( '/[^A-Za-z0-9._:\[\]-]/', '', $value ); return substr( strtolower( $value ), 0, 253 ); } protected static function compliance_diagnostic() { $issues = array(); if ( ! OWCO_Core::setting( 'banner_enabled' ) ) { $issues[] = array( 'level' => 'bad', 'message' => __( 'The banner is disabled: no consent is being collected.', 'ow-consent' ) ); } if ( ! OWCO_Core::setting( 'banner_reject_all' ) ) { $issues[] = array( 'level' => 'bad', 'message' => __( 'GDPR/CNIL: the "Reject all" button is mandatory and must be as visible as "Accept all".', 'ow-consent' ) ); } if ( OWCO_Core::setting( 'banner_close_x' ) ) { $issues[] = array( 'level' => 'warn', 'message' => __( 'The close cross is considered by the CNIL as an ambiguous implicit refusal. Disable it.', 'ow-consent' ) ); } if ( ! OWCO_Core::setting( 'legal_company_name' ) ) { $issues[] = array( 'level' => 'warn', 'message' => __( 'Company name missing — required to generate policies. See the "Legal identity" tab.', 'ow-consent' ) ); } if ( ! OWCO_Core::setting( 'legal_company_reg_number' ) ) { $issues[] = array( 'level' => 'warn', 'message' => __( 'Company registration number missing (RCS / SIREN / BCE-KBO) — mandatory in the legal notice. See the "Legal identity" tab.', 'ow-consent' ) ); } if ( ! OWCO_Core::setting( 'legal_host_name' ) ) { $issues[] = array( 'level' => 'warn', 'message' => __( 'Hosting provider missing — its identity is mandatory in the legal notice (LCEN art. 6-III). See the "Legal identity" tab.', 'ow-consent' ) ); } if ( ! OWCO_Core::setting( 'dsar_enabled' ) ) { $issues[] = array( 'level' => 'warn', 'message' => __( 'The DSAR portal is disabled: visitors cannot exercise their rights through the site.', 'ow-consent' ) ); } if ( ! OWCO_Core::setting( 'dsar_email' ) ) { $issues[] = array( 'level' => 'warn', 'message' => __( 'No DPO email configured: you will not be notified about DSAR requests.', 'ow-consent' ) ); } if ( ! OWCO_Core::setting( 'ledger_enabled' ) ) { $issues[] = array( 'level' => 'warn', 'message' => __( 'The consent ledger is disabled: you keep no proof that consent was given (GDPR art. 7.1).', 'ow-consent' ) ); } // The first banner layer must carry a route to the information (EDPB 5/2020 §62). // `link_privacy_policy` falls back to the WordPress privacy page; when neither // exists, the banner ships with no link to anything at all. if ( '' === trim( (string) OWCO_Core::setting( 'link_privacy_policy' ) ) && '' === (string) get_privacy_policy_url() ) { $issues[] = array( 'level' => 'bad', 'message' => __( 'No privacy policy is linked: the banner gives the visitor no route to any information about the processing. Choose or generate a page under "Compliance" → "Policy links".', 'ow-consent' ) ); } if ( '' === trim( (string) OWCO_Core::setting( 'link_cookie_policy' ) ) ) { $issues[] = array( 'level' => 'warn', 'message' => __( 'No cookie policy is linked: nothing points the visitor at the detailed list of trackers. Choose or generate a page under "Compliance" → "Policy links".', 'ow-consent' ) ); } $dpa_issue = self::dpa_authority_issue(); if ( ! empty( $dpa_issue ) ) { $issues[] = $dpa_issue; } if ( ! is_ssl() ) { $issues[] = array( 'level' => 'bad', 'message' => __( 'The site is not on HTTPS — a consent cookie without HTTPS is not legally valid.', 'ow-consent' ) ); } // Scanner: uncategorized trackers. Guarded, because a failed activation leaves the // table missing and the dashboard must still render. if ( self::table_ready( OWCO_TABLE_SCANNER ) ) { $scanner = OWCO_Scanner::findings_summary(); $unknown = isset( $scanner['unknown'] ) ? (int) $scanner['unknown'] : 0; if ( $unknown > 0 ) { $issues[] = array( 'level' => 'warn', 'message' => sprintf( /* translators: %d: number of un-categorized trackers */ _n( 'Scanner: %d uncategorized tracker detected. Classify it on the "Scanner" tab so it is blocked before consent.', 'Scanner: %d uncategorized trackers detected. Classify them on the "Scanner" tab so they are blocked before consent.', $unknown, 'ow-consent' ), $unknown ), ); } } // DSAR requests that already blew the art. 12.3 deadline. $dsar = self::dsar_stats(); if ( $dsar['overdue'] > 0 ) { $issues[] = array( 'level' => 'bad', 'message' => sprintf( /* translators: %d: number of data-subject requests past their legal deadline */ _n( '%d data-subject request has passed its legal deadline. See the "DSAR requests" tab.', '%d data-subject requests have passed their legal deadline. See the "DSAR requests" tab.', $dsar['overdue'], 'ow-consent' ), $dsar['overdue'] ), ); } // Catalogue rules that can never fire: the blocker silently skips them. $broken = self::count_broken_catalog_rules(); if ( $broken > 0 ) { $issues[] = array( 'level' => 'warn', 'message' => sprintf( /* translators: %d: number of unusable blocking rules */ _n( '%d blocking rule cannot fire and is skipped at runtime. Repair it on the "Tracker catalogue" tab.', '%d blocking rules cannot fire and are skipped at runtime. Repair them on the "Tracker catalogue" tab.', $broken, 'ow-consent' ), $broken ), ); } return $issues; } /** * Diagnostic entry for a hand-typed supervisory authority that the active compliance * profile contradicts. * * `legal_dpa_authority` is honoured verbatim by OWCO_Policies as soon as it differs * from the shipped default, and it survives a change of compliance profile: a value * typed while the site was on GDPR keeps naming the CNIL in a POPIA or PIPL document * whose own prose describes a different regulator. Rather than stop honouring the * administrator's choice, the mismatch is surfaced here with a one-click reset. * * @return array Issue entry for compliance_diagnostic(), or an empty array. */ protected static function dpa_authority_issue() { $chosen = trim( (string) OWCO_Core::setting( 'legal_dpa_authority' ) ); if ( '' === $chosen ) { return array(); } $defaults = OWCO_Core::default_settings(); $shipped = isset( $defaults['legal_dpa_authority'] ) ? trim( (string) $defaults['legal_dpa_authority'] ) : ''; // Still the shipped value: OWCO_Policies already derives the authority itself. if ( $chosen === $shipped ) { return array(); } $derived = self::derived_dpa_authority(); if ( '' === $derived || 0 === strcasecmp( $chosen, $derived ) ) { return array(); } return array( 'level' => 'warn', 'message' => sprintf( /* translators: 1: authority name typed by the administrator, 2: name of the active compliance profile, 3: authority the plugin would derive from that profile */ __( 'The supervisory authority is set by hand to "%1$s", while the active framework (%2$s) designates %3$s. Every generated document prints the value you typed, so it contradicts its own text.', 'ow-consent' ), $chosen, self::profile_label( (string) OWCO_Core::setting( 'compliance_profile' ) ), $derived ), 'action' => 'reset_dpa_authority', /* translators: %s: name of the supervisory authority the plugin would derive */ 'action_label' => sprintf( __( 'Use %s instead', 'ow-consent' ), $derived ), ); } /** * The authority OWCO_Policies would name if `legal_dpa_authority` had never been * customised — i.e. the one the profile and `legal_country` imply. * * The mapping itself is deliberately NOT copied here: OWCO_Policies::authority_info() * is the single authoritative table (fix contract §7), and it is only reachable with * the setting blanked. Blanking is done through the documented `owco_settings` filter * and undone immediately; the settings memo is keyed on the callback set, so both the * probe and the settings afterwards are correct. * * @return string Authority name, or '' when none can be named. */ protected static function derived_dpa_authority() { if ( ! class_exists( 'OWCO_Policies' ) || ! method_exists( 'OWCO_Policies', 'dpa_authority' ) ) { return ''; } $lang = method_exists( 'OWCO_Policies', 'resolve_language' ) ? (string) OWCO_Policies::resolve_language() : 'fr'; $probe = array( __CLASS__, 'filter_blank_dpa_authority' ); add_filter( 'owco_settings', $probe, 9999 ); $derived = (string) OWCO_Policies::dpa_authority( '', '', $lang ); remove_filter( 'owco_settings', $probe, 9999 ); $derived = trim( $derived ); // When nothing is mapped, authority_info() answers a lowercase generic phrase // ("the competent supervisory authority"). That is not a name and must never be // offered as a replacement value. return ( '' !== $derived && preg_match( '/^\p{Lu}/u', $derived ) ) ? $derived : ''; } /** * `owco_settings` callback used only by derived_dpa_authority(), for the length of one * call. Public because add_filter() needs a callable. * * @param mixed $settings Effective settings array. * @return mixed */ public static function filter_blank_dpa_authority( $settings ) { if ( is_array( $settings ) ) { $settings['legal_dpa_authority'] = ''; } return $settings; } /** * How many catalogue rules the matcher would refuse. * * Capped: only the candidates SQL can narrow down are re-checked in PHP, so a large * catalogue does not turn the dashboard into a regex benchmark. * * @return int */ protected static function count_broken_catalog_rules() { global $wpdb; if ( ! self::table_ready( OWCO_TABLE_SCRIPTS ) ) { return 0; } $table = OWCO_TABLE_SCRIPTS; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table, read on an admin screen; it must reflect the catalogue as it is now, so there is nothing to cache. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT pattern, pattern_type, match_target, category FROM %i WHERE pattern_type = 'regex' OR ( match_target = 'url' AND pattern_type = 'host' AND ( CHAR_LENGTH(pattern) < 6 OR pattern NOT LIKE %s ) ) LIMIT 200", $table, '%.%' ), ARRAY_A ); $broken = 0; foreach ( (array) $rows as $row ) { if ( '' !== self::catalog_row_problem( $row ) ) { $broken++; } } return $broken; } /** * Short translated name of a compliance profile. * * Framework acronyms stay as-is; the country/jurisdiction wording around them is * translatable, so a localised admin never sees a raw database slug. * * @param string $p Profile slug. * @return string */ protected static function profile_label( $p ) { $labels = self::profile_labels(); $p = (string) $p; if ( isset( $labels[ $p ] ) ) { return $labels[ $p ]; } return '' !== $p ? $p : __( 'Not set', 'ow-consent' ); } /** * The compliance profiles offered by the plugin, translated, in menu order. * * @return array */ protected static function profile_labels() { return array( 'gdpr' => __( 'GDPR (EU — opt-in)', 'ow-consent' ), 'uk_pecr' => __( 'UK GDPR + PECR (United Kingdom)', 'ow-consent' ), 'ch_nfadp' => __( 'nFADP (Switzerland)', 'ow-consent' ), 'ccpa' => __( 'CCPA/CPRA (California — opt-out)', 'ow-consent' ), 'lgpd' => __( 'LGPD (Brazil)', 'ow-consent' ), 'popia' => __( 'POPIA (South Africa)', 'ow-consent' ), 'pipl' => __( 'PIPL (China — strict opt-in)', 'ow-consent' ), 'dpdp' => __( 'DPDP Act (India)', 'ow-consent' ), 'quebec' => __( 'Law 25 (Quebec)', 'ow-consent' ), 'au' => __( 'Privacy Act (Australia)', 'ow-consent' ), 'us_generic' => __( 'United States — generic (opt-out)', 'ow-consent' ), ); } /** * True when the policy generator owns a privacy-policy template dedicated to $profile. * * When it does not, OWCO_Policies falls back to the GDPR template — the admin must be * told rather than handed a European document for an Indian or Australian site. * * @param string $profile Profile slug. * @return bool */ protected static function profile_has_template( $profile ) { if ( ! class_exists( 'OWCO_Policies' ) ) { return true; } if ( method_exists( 'OWCO_Policies', 'has_template_for' ) ) { return (bool) OWCO_Policies::has_template_for( 'privacy_policy', (string) $profile ); } return method_exists( 'OWCO_Policies', 'tpl_privacy_policy_' . (string) $profile ); } /** * True when a document of this type can actually be generated for the active profile. * * Legal notice and Terms only ship a French body, so every non-francophone profile * hits the language gate in OWCO_Policies::generate_page() and gets a WP_Error. The * Generate button used to render anyway, so the operator clicked it and failed every * single time with no prior warning. * * @param string $policy_type Document type slug. * @return bool */ protected static function policy_type_available( $policy_type ) { $policy_type = (string) $policy_type; if ( '' === $policy_type ) { return false; } if ( ! class_exists( 'OWCO_Policies' ) ) { return false; } if ( ! method_exists( 'OWCO_Policies', 'has_template_for' ) ) { return true; } return (bool) OWCO_Policies::has_template_for( $policy_type ); } /** * The explanation shown in place of a Generate button the generator would refuse. * * @return string Translated, unescaped. */ protected static function policy_language_gap_text() { return __( 'No template of this document exists in the language of the selected legal framework, so it cannot be generated automatically. Write the page yourself and select it above, or supply a body through the owco_policy_template filter.', 'ow-consent' ); } /** * Warning block shown when the selected profile has no dedicated template. * * @param string $profile Profile slug. * @return void */ protected static function render_template_fallback_notice( $profile ) { if ( self::profile_has_template( $profile ) ) { return; } ?>

__( 'Bottom bar (full width)', 'ow-consent' ), 'bottom-card' => __( 'Bottom-left card', 'ow-consent' ), 'center-modal' => __( 'Centered modal', 'ow-consent' ), 'top-bar' => __( 'Top bar', 'ow-consent' ), ) ); ?> __( 'Automatic (follows the visitor system theme)', 'ow-consent' ), 'light' => __( 'Light', 'ow-consent' ), 'dark' => __( 'Dark', 'ow-consent' ), ), __( '"Automatic" follows the visitor operating-system preference; Light and Dark force the palette whatever that preference is.', 'ow-consent' ) ); ?>

__( 'Bottom-left (recommended)', 'ow-consent' ), 'bottom-right' => __( 'Bottom-right', 'ow-consent' ), 'top-left' => __( 'Top-left', 'ow-consent' ), 'top-right' => __( 'Top-right', 'ow-consent' ), ) ); ?> __( 'Pill (icon + text)', 'ow-consent' ), 'icon' => __( 'Icon only', 'ow-consent' ), ) ); ?>

__( 'Allow (fewer breakages)', 'ow-consent' ), 'block' => __( 'Block until consent (strictest)', 'ow-consent' ), ), __( 'Applies to a script tag whose source points at a host the catalogue does not know.', 'ow-consent' ) ); ?> __( 'Allow', 'ow-consent' ), 'block' => __( 'Block until consent (recommended)', 'ow-consent' ), ), __( 'Third-party iframes (video players, maps, social embeds) almost always drop cookies, so blocking is the default.', 'ow-consent' ) ); ?>

:

__( 'Detect automatically', 'ow-consent' ), 'vitrine' => __( 'Showcase site (no online sale)', 'ow-consent' ), 'ecommerce' => __( 'Online shop (goods)', 'ow-consent' ), 'services' => __( 'Services / bookings', 'ow-consent' ), 'rental' => __( 'Rental or hire', 'ow-consent' ), 'saas' => __( 'Software or subscription (SaaS)', 'ow-consent' ), 'content' => __( 'Content, media or membership', 'ow-consent' ), ), __( 'Selects the terms-of-service template. "Detect automatically" analyses your pages and installed plugins — choose the type explicitly if the guess is wrong.', 'ow-consent' ) ); ?>

*/ wp_kses_post( __( 'Every consent is sealed with a %s taken over the row itself plus the hash of the row before it. Editing, deleting or reordering a row breaks that link, which is what makes the ledger usable as proof under GDPR art. 7(1).', 'ow-consent' ) ), 'HMAC-' . esc_html( strtoupper( $algo ) ) . '' ); ?>

#

→

__( 'a dedicated OWCO_LEDGER_KEY constant in wp-config.php', 'ow-consent' ), 'database' => __( 'a random key the plugin keeps in the database, because wp-config.php defines no OWCO_LEDGER_KEY', 'ow-consent' ), ); $label = isset( $labels[ $source ] ) ? $labels[ $source ] : $source; ?>


✓
✗
  • count( $breaks ) ) : ?>

*/ protected static function ledger_break_reasons() { return array( 'hash_mismatch' => __( 'the row content no longer matches its own signature (the row was edited)', 'ow-consent' ), 'awaiting_attestation' => __( 'the row was signed with the previous key and has not been attested yet — the update is still working through the ledger; check again later', 'ow-consent' ), 'chain_break' => __( 'the link to the previous row is wrong (a row was inserted, removed or reordered)', 'ow-consent' ), 'bad_genesis' => __( 'the first row does not start the chain (the beginning of the ledger was removed)', 'ow-consent' ), 'table_emptied' => __( 'the ledger is empty although entries were recorded before', 'ow-consent' ), 'tail_truncated'=> __( 'the most recent rows are missing', 'ow-consent' ), 'head_mismatch' => __( 'the last row differs from the recorded head of the chain', 'ow-consent' ), 'count_mismatch'=> __( 'the number of rows differs from the recorded count', 'ow-consent' ), 'unkeyed_rows' => __( 'rows are sealed with an unkeyed hash that anyone with database access can recompute — they are not evidence', 'ow-consent' ), ); } /* ---------------- DSAR ---------------- */ protected static function render_tab_dsar() { if ( ! self::table_ready( OWCO_TABLE_DSAR ) ) { self::render_missing_table( __( 'Data-subject requests', 'ow-consent' ) ); self::render_dsar_settings_form(); return; } $stats = self::dsar_stats(); $filters = self::dsar_filters(); $where = self::dsar_where( $filters ); $paged = self::current_paged(); $total = self::count_rows( OWCO_TABLE_DSAR, $where ); // Explicit column list — verification_token / token_hash are identity credentials // and must never be loaded into an admin render. subject_email and details ARE // shown on this manage_options-only screen; subject_name is not displayed. $rows = self::fetch_page( OWCO_TABLE_DSAR, 'id, created_at, request_type, subject_email, details, status, verified_at, due_at, handled_at, response_notes', $where, 'id DESC', $paged ); $carry = array_filter( $filters, 'strlen' ); ?>

0 ) : ?>

[owco_dsar_form]

'#92400e', 'verified' => '#b91c1c', 'resolved' => '#15803d', 'rejected' => '#7c3aed', 'expired' => '#64748b', ); $now_ts = time(); foreach ( $rows as $r ) : $rid = (int) $r['id']; $status = isset( $r['status'] ) ? (string) $r['status'] : ''; $status_color = isset( $status_colors[ $status ] ) ? $status_colors[ $status ] : '#0f172a'; $is_open = in_array( $status, array( 'pending', 'verified' ), true ); // due_at is stored in UTC; strtotime() would otherwise read it as // server-local and shift the countdown by the UTC offset. $due_ts = ! empty( $r['due_at'] ) ? (int) strtotime( (string) $r['due_at'] . ' UTC' ) : 0; $overdue = ( $is_open && $due_ts > 0 && $due_ts < $now_ts ); $days_left = $due_ts > 0 ? (int) ceil( ( $due_ts - $now_ts ) / DAY_IN_SECONDS ) : 0; $type = isset( $r['request_type'] ) ? (string) $r['request_type'] : ''; ?>
#
#
0 ? self::utc_to_local_display( (string) $r['due_at'] ) : '—' ); ?> 0 && $is_open ) : ?>

*/ protected static function dsar_stats() { global $wpdb; // The dashboard asks for these twice (cards + diagnostic) in the same render. static $memo = null; if ( null !== $memo ) { return $memo; } $out = array( 'pending' => 0, 'verified' => 0, 'resolved' => 0, 'rejected' => 0, 'expired' => 0, 'closed' => 0, 'overdue' => 0, 'response_days' => 30, ); $days = (int) OWCO_Core::setting( 'dsar_response_days' ); $out['response_days'] = ( $days > 0 && $days <= 30 ) ? $days : 30; if ( ! self::table_ready( OWCO_TABLE_DSAR ) ) { $memo = $out; return $memo; } $table = OWCO_TABLE_DSAR; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table, bound as %i. Legal-deadline counters must never be served from cache. $counts = $wpdb->get_results( $wpdb->prepare( 'SELECT status, COUNT(*) AS c FROM %i GROUP BY status', $table ), ARRAY_A ); $statuses = array( 'pending', 'verified', 'resolved', 'rejected', 'expired' ); foreach ( (array) $counts as $row ) { $status = isset( $row['status'] ) ? (string) $row['status'] : ''; if ( in_array( $status, $statuses, true ) ) { $out[ $status ] = (int) $row['c']; } } $out['closed'] = $out['resolved'] + $out['rejected'] + $out['expired']; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table; a legal-deadline counter must reflect the queue right now and must never be served from cache. $out['overdue'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status IN ('pending','verified') AND due_at IS NOT NULL AND due_at <> '' AND due_at < %s", $table, gmdate( 'Y-m-d H:i:s' ) ) ); $memo = $out; return $memo; } /** * The controls that actually fulfil one request. * * Export and erasure are handled by OWCO_DSAR through admin-post.php with their own * per-request nonces; closing the request and the pending-state controls are handled * here. Before 1.2.0 the only control was a "Resolve" button that flipped the status * while nothing was exported or deleted — the site recorded that a right had been * honoured when it had not. * * @param int $id Request id. * @param string $status Current status. * @param string $type Request type slug. * @param array $row Full row. * @return void */ protected static function render_dsar_row_actions( $id, $status, $type, array $row ) { $id = (int) $id; $has_dsar = class_exists( 'OWCO_DSAR' ); $post_url = admin_url( 'admin-post.php' ); $is_open = in_array( (string) $status, array( 'pending', 'verified' ), true ); $exportable = in_array( (string) $type, array( 'access', 'portability' ), true ); ?>

__( 'Pending', 'ow-consent' ), 'verified' => __( 'Verified', 'ow-consent' ), 'resolved' => __( 'Resolved', 'ow-consent' ), 'expired' => __( 'Expired', 'ow-consent' ), 'rejected' => __( 'Rejected', 'ow-consent' ), ); return isset( $labels[ $status ] ) ? $labels[ $status ] : $status; } /* ---------------- Policies ---------------- */ /** * Show the outcome of the last document generation, then forget it. * * Called by both tabs that can trigger a generation, so an inline "Generate" click on * the Compliance tab reports its own success — or its WP_Error — instead of silently * leaving the result behind for a later visit to the Policies tab. * * @return void */ protected static function render_policy_gen_result() { $result = get_transient( 'owco_policy_gen_result' ); if ( ! is_array( $result ) ) { return; } delete_transient( 'owco_policy_gen_result' ); ?>
✗

✓ — · · …
array( 'label' => __( 'Cookie policy', 'ow-consent' ), 'desc' => __( 'Detailed up-to-date list of cookies/trackers in use (generated from the scanner). Recommended for all jurisdictions.', 'ow-consent' ), ), 'privacy_policy' => array( 'label' => __( 'Privacy policy', 'ow-consent' ), 'desc' => __( 'Full GDPR/CCPA/LGPD document: data controller, purposes, legal bases, rights, non-EU transfers.', 'ow-consent' ), ), 'legal_notice' => array( 'label' => __( 'Legal notice', 'ow-consent' ), 'desc' => __( 'Publisher, publication director, hosting, intellectual property. Mandatory in France (LCEN).', 'ow-consent' ), ), 'terms' => array( 'label' => __( 'Terms of service', 'ow-consent' ), 'desc' => __( 'Terms adapted to the business type (rental, e-commerce, SaaS, services, content) — detected automatically by analysing the site pages.', 'ow-consent' ), ), ); // Find existing generated pages. $existing = array(); foreach ( $types as $type => $info ) { $found = get_posts( array( 'post_type' => 'page', 'post_status' => array( 'publish', 'draft', 'private' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- postmeta is indexed on meta_key, and a taxonomy would not model "the page generated for this document type". 'meta_key' => OWCO_Policies::POLICY_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- the pair meta_key + meta_value IS the identity of the generated page; $type is one of the plugin's own document slugs, the meta_key half is indexed, and this runs once per document type on an admin screen only. 'meta_value' => $type, 'numberposts' => 1, ) ); $existing[ $type ] = $found ? $found[0] : null; } self::render_policy_gen_result(); $jur = (string) OWCO_Core::setting( 'compliance_profile' ); ?>

$info ) : $page = $existing[ $type ]; ?>

ID, OWCO_Policies::VERSION_META, true ); $gen = (string) get_post_meta( $page->ID, OWCO_Policies::GENERATED_META, true ); $hash = (string) get_post_meta( $page->ID, OWCO_Policies::HASH_META, true ); ?>

ID ) ); ?> ·
post_status ) ); ?> ID, OWCO_Policies::EDITED_META, true ) : ''; if ( '' !== $edited ) : // Only link when a revision really exists: revision.php with anything // else than a revision id answers an error screen. $revisions_url = ''; $last_revision = wp_get_post_revisions( $page->ID, array( 'numberposts' => 1, 'fields' => 'ids' ) ); if ( ! empty( $last_revision ) ) { $revisions_url = (string) get_edit_post_link( (int) reset( $last_revision ) ); } ?>

0, 'unknown' => 0, 'last' => null ); $last = get_transient( 'owco_scanner_last_run' ); $run_res = get_transient( 'owco_scanner_run_result' ); $filters = self::scanner_filters(); $filter = $filters['filter']; $paged = self::current_paged(); $where = $table_ok ? self::scanner_where( $filters ) : ''; $total = $table_ok ? self::count_rows( OWCO_TABLE_SCANNER, $where ) : 0; $rows = $table_ok ? self::fetch_page( OWCO_TABLE_SCANNER, 'id, discovered_at, kind, name, domain, category_guess, page_url, confirmed', $where, 'discovered_at DESC, id DESC', $paged ) : array(); $carry = array_filter( $filters, 'strlen' ); ?>

  • 0 ? wp_date( 'd/m/Y H:i', $cov_last ) : __( 'date unknown', 'ow-consent' ) ) ); ?>
  • 0 ) : ?>

  • 0 ? wp_date( 'd/m/Y H:i', $cov_probe ) : __( 'date unknown', 'ow-consent' ) ) ); ?>

✗
✗
✓ ⏳ 0 ) : ?>
__( 'Hourly', 'ow-consent' ), 'twicedaily' => __( 'Twice daily', 'ow-consent' ), 'daily' => __( 'Daily', 'ow-consent' ), 'weekly' => __( 'Weekly (recommended)', 'ow-consent' ), ); // Only offer recurrences WordPress actually knows about on this install. $available_schedules = (array) wp_get_schedules(); foreach ( array_keys( $frequencies ) as $slug ) { if ( ! isset( $available_schedules[ $slug ] ) ) { unset( $frequencies[ $slug ] ); } } if ( empty( $frequencies ) ) { $frequencies = array( 'daily' => __( 'Daily', 'ow-consent' ) ); } self::field_select( 'scanner_frequency', __( 'Frequency', 'ow-consent' ), $frequencies, __( 'Saving reschedules the cron event immediately — the new frequency applies from the next run.', 'ow-consent' ) ); ?> __( 'Run for logged-in administrators', 'ow-consent' ), 'off' => __( 'Off', 'ow-consent' ), ), __( 'The server-side scan cannot see cookies written by JavaScript. The probe fills that gap by observing what actually runs in a real browser — but only in an administrator\'s own session, never a visitor\'s. Switch it off if you would rather ship no extra script at all; the tracker list then reflects HTML inspection only.', 'ow-consent' ) ); ?>

__( 'All', 'ow-consent' ), // Glyph concatenated outside the gettext call so it cannot be lost in translation. 'unknown' => '⚠ ' . __( 'Uncategorized', 'ow-consent' ), ); foreach ( OWCO_Core::categories() as $filter_cat ) { $cat_links[ $filter_cat ] = OWCO_Core::category_label( $filter_cat ); } foreach ( $cat_links as $val => $lbl ) : $link_args = array_diff_key( $carry, array( 'filter' => '', 'paged' => '' ) ); if ( '' !== $val ) { $link_args['filter'] = $val; } $url = self::tab_url( 'scanner', $link_args ); $style = ( $filter === $val ) ? 'background:#0f172a;color:#fff;' : ''; ?>

__( 'Uncategorized', 'ow-consent' ) ); foreach ( OWCO_Core::categories() as $opt_slug ) { $cat_options[ $opt_slug ] = OWCO_Core::category_label( $opt_slug ); } $kind_labels = self::scanner_kind_labels(); ?>



in a
, or inside a , is invalid HTML and // the browser silently drops it. The row controls point at them by id. foreach ( $rows as $r ) : $fid_row = (int) $r['id']; ?>
*/ protected static function scanner_kind_labels() { return array( 'script' => __( 'Script', 'ow-consent' ), 'inline_script' => __( 'Inline script', 'ow-consent' ), 'iframe' => __( 'Iframe', 'ow-consent' ), 'stylesheet' => __( 'Stylesheet', 'ow-consent' ), 'pixel' => __( 'Tracking pixel', 'ow-consent' ), 'cookie' => __( 'Cookie', 'ow-consent' ), 'link' => __( 'Link', 'ow-consent' ), 'preconnect' => __( 'Resource hint', 'ow-consent' ), ); } /* ---------------- Tracker catalogue ---------------- */ /** * The blocking catalogue (wp_owco_scripts) — what the blocker actually consults. * * Until 1.2.0 this table had no interface at all: the plugin raised an admin notice * asking the operator to repair invalid patterns on a screen that did not exist, and a * tracker the catalogue did not know could never be blocked by hand. * * @return void */ protected static function render_tab_catalog() { if ( ! self::table_ready( OWCO_TABLE_SCRIPTS ) ) { self::render_missing_table( __( 'Tracker catalogue', 'ow-consent' ) ); return; } global $wpdb; $filters = self::catalog_filters(); $where = self::catalog_where( $filters ); $paged = self::current_paged(); $columns = array( 'id', 'pattern', 'pattern_type', 'match_target', 'name', 'vendor', 'category', 'privacy_policy_url' ); if ( '1' === $filters['invalid'] ) { // Only PCRE can say whether a regex compiles, so the "needs attention" view is // decided in PHP. Paginating the SQL candidates first would hand the admin empty // pages, so the (deliberately small) candidate set is filtered whole instead. $select = self::select_clause( OWCO_TABLE_SCRIPTS, $columns ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table and columns are bound as %i by select_clause(); $where is the clause build_where() already prepared and must not be rescanned. A repair screen must show the catalogue as it is right now. $candidates = $wpdb->get_results( $select . $where . ' ORDER BY category ASC, pattern ASC LIMIT 300', ARRAY_A ); $rows = array(); foreach ( (array) $candidates as $candidate ) { if ( '' !== self::catalog_row_problem( $candidate ) ) { $rows[] = $candidate; } } $total = count( $rows ); $paged = 1; } else { $total = self::count_rows( OWCO_TABLE_SCRIPTS, $where ); $rows = self::fetch_page( OWCO_TABLE_SCRIPTS, $columns, $where, 'category ASC, pattern ASC', $paged ); } $categories = OWCO_Core::categories(); $carry = array_filter( array( 'cat' => $filters['cat'], 'target' => $filters['target'], 'ptype' => $filters['ptype'], 's' => $filters['s'], 'invalid' => $filters['invalid'], ), 'strlen' ); ?>

per row, kept OUTSIDE the table: a
between and // is invalid HTML and every browser hoists it out of the table, which silently // detaches the controls from it. The rows reference these by the `form` // attribute instead. foreach ( $rows as $row ) : $rid = (int) $row['id']; ?>
⚠

$value ) { echo ''; } echo ''; } /** * Translated names of the pattern types. * * @return array */ protected static function catalog_pattern_type_labels() { return array( 'host' => __( 'Text fragment (recommended)', 'ow-consent' ), 'regex' => __( 'Regular expression', 'ow-consent' ), 'inline_signature' => __( 'Inline script signature', 'ow-consent' ), ); } /** * Translated names of the match targets. * * @return array */ protected static function catalog_target_labels() { return array( 'url' => __( 'Resource URL', 'ow-consent' ), 'cookie' => __( 'Cookie name', 'ow-consent' ), ); } /* ================================================================= * SHARED SCREEN INFRASTRUCTURE * ============================================================== */ /** @var int Rows per page on every paginated listing. */ const PER_PAGE = 25; /** * True when a plugin-owned table really exists in this database. * * Every listing screen calls this first: a failed activation, a half-finished * migration or a manually dropped table used to turn the whole admin page into a * white screen of fatal errors instead of a readable "not installed yet" message. * * @param string $table Fully-qualified table name. * @return bool */ protected static function table_ready( $table ) { static $cache = array(); $table = (string) $table; if ( '' === $table ) { return false; } if ( isset( $cache[ $table ] ) ) { return $cache[ $table ]; } global $wpdb; $suppress = $wpdb->suppress_errors( true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- schema probe on a plugin-owned table; the answer is memoised for the whole request and there is nothing to cache between requests. $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); $wpdb->suppress_errors( $suppress ); $cache[ $table ] = ( (string) $found === $table ); return $cache[ $table ]; } /** * Message shown in place of a listing whose storage is not there. * * @param string $title Translated section title. * @return void */ protected static function render_missing_table( $title ) { ?>

$limit ) ? substr( $raw, 0, $limit ) : $raw; } /** * A GET date filter, normalised to YYYY-MM-DD or ''. * * @param string $name Query argument name. * @return string */ protected static function get_filter_date( $name ) { $raw = self::get_filter_text( $name, 10 ); if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw ) ) { return ''; } $parts = explode( '-', $raw ); if ( ! checkdate( (int) $parts[1], (int) $parts[2], (int) $parts[0] ) ) { return ''; } return $raw; } /** * Turn a local calendar day into the UTC bound stored in the ledger / DSAR tables. * * Those tables store UTC while the administrator types a date in the site timezone, * so a naive comparison silently shifts the window by the UTC offset. * * @param string $date YYYY-MM-DD, already validated. * @param bool $start True for 00:00:00, false for 23:59:59. * @return string MySQL datetime in UTC. */ protected static function local_day_to_utc( $date, $start = true ) { $local = $date . ( $start ? ' 00:00:00' : ' 23:59:59' ); $gmt = get_gmt_from_date( $local, 'Y-m-d H:i:s' ); return is_string( $gmt ) && '' !== $gmt ? $gmt : $local; } /** * Render a UTC datetime in the site timezone, so admins do not read a shifted clock. * * @param string $utc MySQL datetime stored in UTC. * @return string */ protected static function utc_to_local_display( $utc ) { $utc = (string) $utc; if ( '' === $utc || '0000-00-00 00:00:00' === $utc ) { return '—'; } $ts = strtotime( $utc . ' UTC' ); if ( ! $ts ) { return $utc; } return (string) wp_date( 'Y-m-d H:i', $ts ); } /** * Standard pager under a listing. * * @param int $total Total matching rows. * @param int $paged Current page. * @param string $tab Tab slug the links point back to. * @param array $base_args Filter arguments to preserve across pages. * @return void */ protected static function render_pagination( $total, $paged, $tab, array $base_args = array() ) { $total = max( 0, (int) $total ); $per_page = self::PER_PAGE; $pages = (int) ceil( $total / $per_page ); if ( $pages < 2 ) { return; } $base_args = array_merge( $base_args, array( 'page' => self::MENU_SLUG, 'tab' => (string) $tab ) ); $base_args['paged'] = '%#%'; $links = paginate_links( array( 'base' => add_query_arg( $base_args, admin_url( 'admin.php' ) ), 'format' => '', 'current' => min( max( 1, (int) $paged ), $pages ), 'total' => $pages, 'type' => 'plain', 'prev_text' => '«', 'next_text' => '»', ) ); if ( ! $links ) { return; } ?>
prepare( $sql, $values ); } /** * Assemble a `SELECT FROM ` head with every identifier bound. * * The listings and the exports all read a fixed, code-owned column list, but until * now that list and the table name reached SQL as raw text. WordPress 6.2 lets an * identifier be bound like any other value, so each column and the table travel as * an %i replacement and $wpdb does the quoting — nothing is interpolated by hand, * and nothing here is backticked in the query string, since %i brings its own. * * One prepare() per identifier keeps every query string a literal, which is what * makes the call auditable: there is no fragment here that a reader has to trust. * * @param string $table Table name from a plugin constant. * @param string|array $columns Column names, as an array or a comma-separated list. * @return string Ready-to-concatenate SELECT head, already escaped. */ protected static function select_clause( $table, $columns ) { global $wpdb; if ( ! is_array( $columns ) ) { $columns = explode( ',', (string) $columns ); } $parts = array(); foreach ( $columns as $column ) { $column = trim( (string) $column ); if ( '' === $column ) { continue; } $parts[] = (string) $wpdb->prepare( '%i', $column ); } if ( empty( $parts ) ) { $parts[] = (string) $wpdb->prepare( '%i', 'id' ); } return 'SELECT ' . implode( ', ', $parts ) . ' FROM ' . (string) $wpdb->prepare( '%i', (string) $table ); } /** * Count the rows a prepared WHERE clause selects. * * @param string $table Table name from a plugin constant. * @param string $where Prepared WHERE clause, possibly ''. * @return int */ protected static function count_rows( $table, $where ) { global $wpdb; if ( ! self::table_ready( $table ) ) { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- the table identifier is bound as %i; $where is the clause build_where() already prepared, and re-preparing it here would rescan the values it bound. A live counter must not be cached. return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) . $where ); } /** * Fetch one page of a listing. * * @param string $table Table name from a plugin constant. * @param string|array $columns Explicit column list. * @param string $where Prepared WHERE clause, possibly ''. * @param string $order ORDER BY fragment, from a literal in the calling method. * @param int $paged 1-based page number. * @return array> */ protected static function fetch_page( $table, $columns, $where, $order, $paged ) { global $wpdb; if ( ! self::table_ready( $table ) ) { return array(); } $offset = ( max( 1, (int) $paged ) - 1 ) * self::PER_PAGE; $limit = $wpdb->prepare( ' LIMIT %d OFFSET %d', self::PER_PAGE, $offset ); $select = self::select_clause( $table, $columns ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table and columns are bound as %i by select_clause(); $where and $limit are already prepared and must not be rescanned; $order is an ORDER BY fragment written as a literal at each of the four call sites in this class. A triage/audit listing must reflect the table as it is right now. $rows = $wpdb->get_results( $select . $where . ' ORDER BY ' . $order . $limit, ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * Ledger filter state read from the query string. * * @return array */ protected static function ledger_filters() { $token = self::get_filter_text( 'token', 40 ); if ( ! preg_match( '/^[a-f0-9]{1,32}$/i', $token ) ) { $token = ''; } return array( 'event' => self::get_filter_key( 'event', self::ledger_events(), '' ), 'profile' => self::get_filter_key( 'profile', array_keys( self::profile_labels() ), '' ), 'token' => strtolower( $token ), 'from' => self::get_filter_date( 'from' ), 'to' => self::get_filter_date( 'to' ), ); } /** * Prepared WHERE clause for the ledger listing. * * `created_at` is stored in UTC while the administrator types a date in the site * timezone, so both bounds are converted before they are bound. * * @param array $f Filter state. * @return string */ protected static function ledger_where( array $f ) { $clauses = array(); $values = array(); if ( '' !== $f['event'] ) { $clauses[] = 'event = %s'; $values[] = $f['event']; } if ( '' !== $f['profile'] ) { $clauses[] = 'profile = %s'; $values[] = $f['profile']; } if ( '' !== $f['token'] ) { $clauses[] = 'visitor_token LIKE %s'; global $wpdb; $values[] = $wpdb->esc_like( $f['token'] ) . '%'; } if ( '' !== $f['from'] ) { $clauses[] = 'created_at >= %s'; $values[] = self::local_day_to_utc( $f['from'], true ); } if ( '' !== $f['to'] ) { $clauses[] = 'created_at <= %s'; $values[] = self::local_day_to_utc( $f['to'], false ); } return self::build_where( $clauses, $values ); } /** * Event slugs the ledger can hold, in a stable order. * * @return array */ protected static function ledger_events() { return array( 'accept_all', 'reject_all', 'save_preferences', 'withdraw', 'renew', 'gpc', 'optout', 'form', 'import', 'retention_anchor' ); } /** * Translated name of a ledger event, falling back to the stored slug. * * @param string $event Event slug. * @return string */ protected static function ledger_event_label( $event ) { $labels = array( 'accept_all' => __( 'Accept all', 'ow-consent' ), 'reject_all' => __( 'Reject all', 'ow-consent' ), 'save_preferences' => __( 'Saved preferences', 'ow-consent' ), 'withdraw' => __( 'Withdrawal', 'ow-consent' ), 'renew' => __( 'Renewal', 'ow-consent' ), 'gpc' => __( 'Global Privacy Control signal', 'ow-consent' ), 'optout' => __( 'Opt-out', 'ow-consent' ), 'form' => __( 'Form submission', 'ow-consent' ), 'import' => __( 'Imported', 'ow-consent' ), 'retention_anchor' => __( 'Retention anchor', 'ow-consent' ), ); $event = (string) $event; return isset( $labels[ $event ] ) ? $labels[ $event ] : $event; } /** * DSAR filter state read from the query string. * * @return array */ protected static function dsar_filters() { return array( 'status' => self::get_filter_key( 'status', array( 'pending', 'verified', 'resolved', 'rejected', 'expired' ), '' ), 'type' => self::get_filter_key( 'type', array_keys( self::dsar_type_map() ), '' ), 'overdue' => self::get_filter_key( 'overdue', array( '1' ), '' ), 's' => self::get_filter_text( 's', 190 ), 'from' => self::get_filter_date( 'from' ), 'to' => self::get_filter_date( 'to' ), ); } /** * Prepared WHERE clause for the DSAR queue. * * @param array $f Filter state. * @return string */ protected static function dsar_where( array $f ) { global $wpdb; $clauses = array(); $values = array(); if ( '' !== $f['status'] ) { $clauses[] = 'status = %s'; $values[] = $f['status']; } if ( '' !== $f['type'] ) { $clauses[] = 'request_type = %s'; $values[] = $f['type']; } if ( '' !== $f['s'] ) { $clauses[] = 'subject_email LIKE %s'; $values[] = '%' . $wpdb->esc_like( $f['s'] ) . '%'; } if ( '' !== $f['from'] ) { $clauses[] = 'created_at >= %s'; $values[] = self::local_day_to_utc( $f['from'], true ); } if ( '' !== $f['to'] ) { $clauses[] = 'created_at <= %s'; $values[] = self::local_day_to_utc( $f['to'], false ); } if ( '1' === $f['overdue'] ) { $clauses[] = "status IN ('pending','verified')"; $clauses[] = 'due_at < %s'; $values[] = gmdate( 'Y-m-d H:i:s' ); } return self::build_where( $clauses, $values ); } /** * Scanner filter state read from the query string. * * @return array */ protected static function scanner_filters() { $cats = OWCO_Core::categories(); $cats[] = 'unknown'; return array( 'filter' => self::get_filter_key( 'filter', $cats, '' ), 'kind' => self::get_filter_key( 'kind', array( 'script', 'inline_script', 'iframe', 'stylesheet', 'pixel', 'cookie', 'link', 'preconnect' ), '' ), 'state' => self::get_filter_key( 'state', array( 'confirmed', 'unconfirmed' ), '' ), 's' => self::get_filter_text( 's', 190 ), ); } /** * Prepared WHERE clause for the scanner findings. * * @param array $f Filter state. * @return string */ protected static function scanner_where( array $f ) { global $wpdb; $clauses = array(); $values = array(); if ( '' !== $f['filter'] ) { $clauses[] = 'category_guess = %s'; $values[] = $f['filter']; } if ( '' !== $f['kind'] ) { $clauses[] = 'kind = %s'; $values[] = $f['kind']; } if ( 'confirmed' === $f['state'] ) { $clauses[] = 'confirmed = 1'; } elseif ( 'unconfirmed' === $f['state'] ) { $clauses[] = 'confirmed = 0'; } if ( '' !== $f['s'] ) { $like = '%' . $wpdb->esc_like( $f['s'] ) . '%'; $clauses[] = '( domain LIKE %s OR name LIKE %s )'; $values[] = $like; $values[] = $like; } return self::build_where( $clauses, $values ); } /** * Catalogue filter state read from the query string. * * @return array */ protected static function catalog_filters() { return array( 'cat' => self::get_filter_key( 'cat', OWCO_Core::categories(), '' ), 'target' => self::get_filter_key( 'target', array( 'url', 'cookie' ), '' ), 'ptype' => self::get_filter_key( 'ptype', self::catalog_pattern_types(), '' ), 'invalid' => self::get_filter_key( 'invalid', array( '1' ), '' ), 's' => self::get_filter_text( 's', 190 ), ); } /** * Prepared WHERE clause for the tracker catalogue. * * The "needs attention" filter is expressed in SQL for the part MySQL can decide * (a dot-less short host pattern) and widened to every regex row, which PHP then * re-validates one by one in render_tab_catalog(). * * @param array $f Filter state. * @return string */ protected static function catalog_where( array $f ) { global $wpdb; $clauses = array(); $values = array(); if ( '' !== $f['cat'] ) { $clauses[] = 'category = %s'; $values[] = $f['cat']; } if ( '' !== $f['target'] ) { $clauses[] = 'match_target = %s'; $values[] = $f['target']; } if ( '' !== $f['ptype'] ) { $clauses[] = 'pattern_type = %s'; $values[] = $f['ptype']; } if ( '' !== $f['s'] ) { $like = '%' . $wpdb->esc_like( $f['s'] ) . '%'; $clauses[] = '( pattern LIKE %s OR name LIKE %s OR vendor LIKE %s )'; $values[] = $like; $values[] = $like; $values[] = $like; } if ( '1' === $f['invalid'] ) { $clauses[] = "( pattern_type = 'regex' OR ( match_target = 'url' AND pattern_type = 'host' AND ( CHAR_LENGTH(pattern) < 6 OR pattern NOT LIKE %s ) ) )"; $values[] = '%.%'; } return self::build_where( $clauses, $values ); } /** * Why a catalogue rule can never fire, or '' when it is fine. * * @param array $row Catalogue row. * @return string Translated explanation. */ protected static function catalog_row_problem( array $row ) { $pattern = isset( $row['pattern'] ) ? (string) $row['pattern'] : ''; $type = isset( $row['pattern_type'] ) ? (string) $row['pattern_type'] : 'host'; $target = isset( $row['match_target'] ) && '' !== (string) $row['match_target'] ? (string) $row['match_target'] : 'url'; if ( '' === (string) preg_replace( '/[\s\p{C}\p{Z}]+/u', '', $pattern ) ) { return __( 'the pattern is empty or invisible, so the rule matches nothing', 'ow-consent' ); } if ( 'regex' === $type && ! OWCO_Core::validate_regex_pattern( $pattern ) ) { return __( 'this regular expression does not compile, or backtracks catastrophically — the matcher skips it at runtime', 'ow-consent' ); } if ( 'url' === $target && 'host' === $type && ( strlen( $pattern ) < 6 || strpos( $pattern, '.' ) === false ) ) { return __( 'too short and dot-less for a URL: it would match unrelated file names, so the matcher refuses it. Switch it to the "Cookie name" target or give the real host.', 'ow-consent' ); } $category = isset( $row['category'] ) ? (string) $row['category'] : ''; if ( '' !== $category && ! in_array( $category, OWCO_Core::categories(), true ) ) { return __( 'the category no longer exists, so consent can never be granted for it', 'ow-consent' ); } return ''; } /** * DSAR request types, slug => translated short label. * * @return array */ protected static function dsar_type_map() { return array( 'access' => __( 'Access', 'ow-consent' ), 'rectification' => __( 'Rectification', 'ow-consent' ), 'erasure' => __( 'Erasure', 'ow-consent' ), 'portability' => __( 'Portability', 'ow-consent' ), 'restrict' => __( 'Restriction', 'ow-consent' ), 'object' => __( 'Objection', 'ow-consent' ), 'optout' => __( 'Opt-out', 'ow-consent' ), 'withdraw' => __( 'Withdrawal', 'ow-consent' ), ); } /* ================================================================= * EXPORTS * ============================================================== */ /** * Stream one of the four listings as CSV or JSON. * * Reached only through handle_save(), which has already checked `manage_options` and * the nonce. Every field goes through csv_cell(), so a hostile page URL or cookie name * cannot become a live formula in the compliance officer's spreadsheet. * * @param string $what 'ledger', 'dsar', 'scanner' or 'catalog'. * @return void Exits on success. */ protected static function handle_export( $what ) { $format = self::get_filter_key( 'owco_format', array( 'csv', 'json' ), 'csv' ); $stamp = gmdate( 'Ymd-His' ); if ( 'ledger' === $what ) { if ( ! self::table_ready( OWCO_TABLE_LEDGER ) ) { return; } $table = OWCO_TABLE_LEDGER; $where = self::ledger_where( self::ledger_filters() ); $columns = array( 'id', 'created_at', 'visitor_token', 'event', 'categories', 'profile', 'source', 'ip_pseudonymous', 'page_url', 'prev_hash', 'row_hash', 'policies_hash', 'banner_revision', 'plugin_version' ); $sql = self::select_clause( $table, $columns ) . $where . ' ORDER BY id ASC'; $mapper = array( __CLASS__, 'map_ledger_export_row' ); if ( 'json' === $format ) { self::stream_json( 'owco-ledger-' . $stamp . '.json', self::ledger_export_meta(), $sql, $mapper ); } self::stream_csv( 'owco-ledger-' . $stamp . '.csv', self::ledger_export_headers(), $sql, $mapper ); return; } if ( 'dsar' === $what ) { if ( ! self::table_ready( OWCO_TABLE_DSAR ) ) { return; } $table = OWCO_TABLE_DSAR; $where = self::dsar_where( self::dsar_filters() ); // verification_token / token_hash are identity credentials and never leave the table. $columns = array( 'id', 'created_at', 'request_type', 'subject_email', 'status', 'verified_at', 'due_at', 'handled_at', 'handled_by_user_id', 'response_notes' ); $sql = self::select_clause( $table, $columns ) . $where . ' ORDER BY id ASC'; $mapper = array( __CLASS__, 'map_dsar_export_row' ); if ( 'json' === $format ) { self::stream_json( 'owco-dsar-' . $stamp . '.json', array( 'format' => 'ow-consent/dsar-register', 'generated_at' => gmdate( 'c' ) ), $sql, $mapper ); } self::stream_csv( 'owco-dsar-' . $stamp . '.csv', array( __( 'ID', 'ow-consent' ), __( 'Received (UTC)', 'ow-consent' ), __( 'Type', 'ow-consent' ), __( 'Email', 'ow-consent' ), __( 'Status', 'ow-consent' ), __( 'Verified (UTC)', 'ow-consent' ), __( 'Deadline (UTC)', 'ow-consent' ), __( 'Handled (UTC)', 'ow-consent' ), __( 'Handled by (user ID)', 'ow-consent' ), __( 'Response notes', 'ow-consent' ), ), $sql, $mapper ); return; } if ( 'scanner' === $what ) { if ( ! self::table_ready( OWCO_TABLE_SCANNER ) ) { return; } $table = OWCO_TABLE_SCANNER; $where = self::scanner_where( self::scanner_filters() ); $columns = array( 'id', 'discovered_at', 'kind', 'name', 'domain', 'category_guess', 'confirmed', 'vendor', 'page_url' ); $sql = self::select_clause( $table, $columns ) . $where . ' ORDER BY id ASC'; $mapper = array( __CLASS__, 'map_scanner_export_row' ); if ( 'json' === $format ) { self::stream_json( 'owco-trackers-' . $stamp . '.json', array( 'format' => 'ow-consent/scanner-findings', 'generated_at' => gmdate( 'c' ) ), $sql, $mapper ); } self::stream_csv( 'owco-trackers-' . $stamp . '.csv', array( __( 'ID', 'ow-consent' ), __( 'Discovered', 'ow-consent' ), __( 'Type', 'ow-consent' ), __( 'Name', 'ow-consent' ), __( 'Domain', 'ow-consent' ), __( 'Category', 'ow-consent' ), __( 'Confirmed', 'ow-consent' ), __( 'Vendor', 'ow-consent' ), __( 'Seen on', 'ow-consent' ), ), $sql, $mapper ); return; } if ( 'catalog' === $what ) { if ( ! self::table_ready( OWCO_TABLE_SCRIPTS ) ) { return; } $table = OWCO_TABLE_SCRIPTS; $where = self::catalog_where( self::catalog_filters() ); $columns = array( 'id', 'pattern', 'pattern_type', 'match_target', 'name', 'vendor', 'category', 'privacy_policy_url' ); $sql = self::select_clause( $table, $columns ) . $where . ' ORDER BY id ASC'; $mapper = array( __CLASS__, 'map_catalog_export_row' ); if ( 'json' === $format ) { self::stream_json( 'owco-catalog-' . $stamp . '.json', array( 'format' => 'ow-consent/tracker-catalog', 'generated_at' => gmdate( 'c' ) ), $sql, $mapper ); } self::stream_csv( 'owco-catalog-' . $stamp . '.csv', array( __( 'ID', 'ow-consent' ), __( 'Pattern', 'ow-consent' ), __( 'Pattern type', 'ow-consent' ), __( 'Matched against', 'ow-consent' ), __( 'Name', 'ow-consent' ), __( 'Vendor', 'ow-consent' ), __( 'Category', 'ow-consent' ), __( 'Privacy policy', 'ow-consent' ), ), $sql, $mapper ); } } /** * Column headers of the ledger export. * * @return array */ protected static function ledger_export_headers() { return array( __( 'ID', 'ow-consent' ), __( 'Date (UTC)', 'ow-consent' ), __( 'Visitor token', 'ow-consent' ), __( 'Event', 'ow-consent' ), __( 'Categories granted', 'ow-consent' ), __( 'Profile', 'ow-consent' ), __( 'Source', 'ow-consent' ), __( 'Pseudonymised IP', 'ow-consent' ), __( 'Page', 'ow-consent' ), __( 'Previous hash', 'ow-consent' ), __( 'Row hash', 'ow-consent' ), __( 'Policies hash', 'ow-consent' ), __( 'Banner revision', 'ow-consent' ), __( 'Plugin version', 'ow-consent' ), ); } /** * Envelope of the JSON ledger export: what a supervisory authority needs to re-verify * the extract without the rest of the chain. * * @return array */ protected static function ledger_export_meta() { $algo = (string) OWCO_Core::setting( 'ledger_hash_algo' ); if ( '' === $algo ) { $algo = 'sha256'; } $key_source = ''; if ( class_exists( 'OWCO_Consent' ) && method_exists( 'OWCO_Consent', 'ledger_key_source' ) ) { $key_source = (string) OWCO_Consent::ledger_key_source(); } return array( 'format' => 'ow-consent/consent-ledger', 'generated_at' => gmdate( 'c' ), 'site' => home_url( '/' ), 'plugin_version' => defined( 'OWCO_VERSION' ) ? OWCO_VERSION : '', 'chain' => array( 'scheme' => 'HMAC-' . strtoupper( $algo ), 'key_source' => $key_source, 'note' => 'Each row_hash is an HMAC over the row fields plus the previous row_hash.', ), ); } /** * @param array $r Ledger row. * @return array */ protected static function map_ledger_export_row( $r ) { return array( isset( $r['id'] ) ? (int) $r['id'] : 0, isset( $r['created_at'] ) ? $r['created_at'] : '', isset( $r['visitor_token'] ) ? $r['visitor_token'] : '', isset( $r['event'] ) ? $r['event'] : '', isset( $r['categories'] ) ? $r['categories'] : '', isset( $r['profile'] ) ? $r['profile'] : '', isset( $r['source'] ) ? $r['source'] : '', isset( $r['ip_pseudonymous'] ) ? $r['ip_pseudonymous'] : '', isset( $r['page_url'] ) ? $r['page_url'] : '', isset( $r['prev_hash'] ) ? $r['prev_hash'] : '', isset( $r['row_hash'] ) ? $r['row_hash'] : '', isset( $r['policies_hash'] ) ? $r['policies_hash'] : '', isset( $r['banner_revision'] ) ? $r['banner_revision'] : '', isset( $r['plugin_version'] ) ? $r['plugin_version'] : '', ); } /** * @param array $r DSAR row. * @return array */ protected static function map_dsar_export_row( $r ) { return array( isset( $r['id'] ) ? (int) $r['id'] : 0, isset( $r['created_at'] ) ? $r['created_at'] : '', isset( $r['request_type'] ) ? $r['request_type'] : '', isset( $r['subject_email'] ) ? $r['subject_email'] : '', isset( $r['status'] ) ? $r['status'] : '', isset( $r['verified_at'] ) ? $r['verified_at'] : '', isset( $r['due_at'] ) ? $r['due_at'] : '', isset( $r['handled_at'] ) ? $r['handled_at'] : '', isset( $r['handled_by_user_id'] ) ? (int) $r['handled_by_user_id'] : 0, isset( $r['response_notes'] ) ? $r['response_notes'] : '', ); } /** * @param array $r Scanner row. * @return array */ protected static function map_scanner_export_row( $r ) { return array( isset( $r['id'] ) ? (int) $r['id'] : 0, isset( $r['discovered_at'] ) ? $r['discovered_at'] : '', isset( $r['kind'] ) ? $r['kind'] : '', isset( $r['name'] ) ? $r['name'] : '', isset( $r['domain'] ) ? $r['domain'] : '', isset( $r['category_guess'] ) ? $r['category_guess'] : '', ! empty( $r['confirmed'] ) ? 1 : 0, isset( $r['vendor'] ) ? $r['vendor'] : '', isset( $r['page_url'] ) ? $r['page_url'] : '', ); } /** * @param array $r Catalogue row. * @return array */ protected static function map_catalog_export_row( $r ) { return array( isset( $r['id'] ) ? (int) $r['id'] : 0, isset( $r['pattern'] ) ? $r['pattern'] : '', isset( $r['pattern_type'] ) ? $r['pattern_type'] : '', isset( $r['match_target'] ) ? $r['match_target'] : '', isset( $r['name'] ) ? $r['name'] : '', isset( $r['vendor'] ) ? $r['vendor'] : '', isset( $r['category'] ) ? $r['category'] : '', isset( $r['privacy_policy_url'] ) ? $r['privacy_policy_url'] : '', ); } /** * Nonced URL of one of the streaming exports. * * @param string $what Export slug: ledger, dsar, scanner or catalog. * @param string $format 'csv' or 'json'. * @param array $args Filter arguments to reproduce in the export. * @return string */ protected static function export_url( $what, $format = 'csv', array $args = array() ) { $args = array_merge( $args, array( 'page' => self::MENU_SLUG, 'owco_action' => 'export_' . (string) $what, 'owco_format' => ( 'json' === $format ) ? 'json' : 'csv', ) ); return wp_nonce_url( add_query_arg( $args, admin_url( 'admin.php' ) ), self::SAVE_NONCE_ACTION, '_owco_nonce_url' ); } /** * Neutralise spreadsheet formula injection in an exported field. * * A cell whose first character is `=`, `+`, `-`, `@`, a tab or a carriage return is * executed as a formula by Excel, LibreOffice and Google Sheets. Since the ledger and * the scanner store third-party strings (page URLs, cookie names, vendor names), a * hostile value could otherwise run when the compliance officer opens the export. * FIX-CONTRACT §6. * * @param mixed $value Raw field value. * @return string */ protected static function csv_cell( $value ) { if ( is_array( $value ) || is_object( $value ) ) { $encoded = wp_json_encode( $value ); $value = is_string( $encoded ) ? $encoded : ''; } if ( null === $value || false === $value ) { $value = ''; } if ( true === $value ) { $value = '1'; } $value = (string) $value; if ( '' === $value ) { return ''; } if ( strpos( "=+-@\t\r", $value[0] ) !== false ) { return "'" . $value; } return $value; } /** * Recursively apply csv_cell() to a JSON export, so a downstream "save as CSV" is safe too. * * @param mixed $value Value to sanitise. * @return mixed */ protected static function json_cell( $value ) { if ( is_array( $value ) ) { $out = array(); foreach ( $value as $k => $v ) { $out[ $k ] = self::json_cell( $v ); } return $out; } if ( is_int( $value ) || is_float( $value ) || is_bool( $value ) || null === $value ) { return $value; } return self::csv_cell( $value ); } /** * Send download headers and drop any buffering, so the body is streamed row by row * instead of assembled in memory (a 200 000-row ledger does not fit in the PHP limit). * * @param string $filename Suggested file name. * @param string $mime MIME type. * @return void */ protected static function start_download( $filename, $mime ) { nocache_headers(); $guard = 0; while ( ob_get_level() > 0 && $guard < 20 ) { if ( ! @ob_end_clean() ) { break; } // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unremovable buffer (zlib.output_compression) must not abort the download. $guard++; } header( 'Content-Type: ' . $mime . '; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header( 'X-Content-Type-Options: nosniff' ); } /** * Stream a query as CSV, page by page, with formula-injection-safe cells. * * @param string $filename Suggested file name. * @param array $headers Column headers (already translated). * @param string $sql Prepared SQL WITHOUT the LIMIT clause; must be safe to append to. * @param callable $mapper Maps one row (associative) to a flat array of cells. * @return void Exits. */ protected static function stream_csv( $filename, array $headers, $sql, $mapper ) { global $wpdb; // Headers are already out: a printed database error would land inside the file. $wpdb->suppress_errors( true ); self::start_download( $filename, 'text/csv' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- writing to the PHP output stream is the only way to stream a download; WP_Filesystem cannot target php://output. $out = fopen( 'php://output', 'w' ); if ( ! $out ) { exit; } // UTF-8 BOM so Excel opens accented vendor names correctly. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- php://output stream, see above. fwrite( $out, "\xEF\xBB\xBF" ); fputcsv( $out, $headers ); $offset = 0; $chunk = 500; do { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is assembled by the only caller, handle_export(): a SELECT head whose table and columns select_clause() bound as %i, plus a WHERE clause build_where() already prepared, which must not be rescanned. Only the numeric LIMIT is appended here, itself prepared. An export must read the table as it is now. $rows = $wpdb->get_results( $sql . $wpdb->prepare( ' LIMIT %d OFFSET %d', $chunk, $offset ), ARRAY_A ); if ( empty( $rows ) ) { break; } foreach ( $rows as $row ) { $cells = call_user_func( $mapper, $row ); fputcsv( $out, array_map( array( __CLASS__, 'csv_cell' ), (array) $cells ) ); } $offset += $chunk; // Long exports must not die on max_execution_time halfway through a file. // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged,WordPress.PHP.NoSilencedErrors.Discouraged -- streaming an arbitrarily large table needs more than the default limit; the call is disabled on some hosts and its failure is not actionable. @set_time_limit( 60 ); } while ( count( $rows ) === $chunk ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- php://output stream, see above. fclose( $out ); exit; } /** * Stream a query as a JSON array, page by page. * * @param string $filename Suggested file name. * @param array $meta Envelope metadata. * @param string $sql Prepared SQL WITHOUT the LIMIT clause. * @param callable $mapper Maps one row (associative) to an exportable array. * @return void Exits. */ protected static function stream_json( $filename, array $meta, $sql, $mapper ) { global $wpdb; // Headers are already out: a printed database error would land inside the file. $wpdb->suppress_errors( true ); self::start_download( $filename, 'application/json' ); $envelope = wp_json_encode( $meta ); $prefix = is_string( $envelope ) ? rtrim( $envelope, '}' ) . ',"rows":[' : '{"rows":['; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON download body produced by wp_json_encode(); HTML escaping would corrupt the file. echo $prefix; $offset = 0; $chunk = 500; $first = true; do { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is assembled by the only caller, handle_export(): a SELECT head whose table and columns select_clause() bound as %i, plus a WHERE clause build_where() already prepared, which must not be rescanned. Only the numeric LIMIT is appended here, itself prepared. An export must read the table as it is now. $rows = $wpdb->get_results( $sql . $wpdb->prepare( ' LIMIT %d OFFSET %d', $chunk, $offset ), ARRAY_A ); if ( empty( $rows ) ) { break; } foreach ( $rows as $row ) { $json = wp_json_encode( self::json_cell( call_user_func( $mapper, $row ) ) ); if ( ! is_string( $json ) ) { continue; } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON download body produced by wp_json_encode(); HTML escaping would corrupt the file. echo ( $first ? '' : ',' ) . $json; $first = false; } $offset += $chunk; // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged,WordPress.PHP.NoSilencedErrors.Discouraged -- streaming an arbitrarily large table needs more than the default limit; the call is disabled on some hosts and its failure is not actionable. @set_time_limit( 60 ); } while ( count( $rows ) === $chunk ); echo ']}'; exit; } /** * Store a one-shot admin message for the screen we are about to redirect to. * * @param string $type 'success' or 'error'. * @param string $message Translated message. * @return void */ protected static function set_screen_result( $type, $message ) { set_transient( 'owco_admin_screen_result', array( 'type' => ( 'error' === $type ? 'error' : 'success' ), 'message' => (string) $message ), 60 ); } /** * Render and forget the message stored by set_screen_result(). * * @return void */ protected static function render_screen_result() { $result = get_transient( 'owco_admin_screen_result' ); if ( ! is_array( $result ) || empty( $result['message'] ) ) { return; } delete_transient( 'owco_admin_screen_result' ); $class = ( isset( $result['type'] ) && 'error' === $result['type'] ) ? 'bad' : 'ok'; ?>
' . esc_html( $help ) . '

'; } /** * Translated default for the settings whose empty value resolves to a translation. * * Only the banner copy and the category labels behave that way: OWCO_Core::texts() and * category_label()/category_description() return the translated string whenever the * stored value is empty or still equals the untranslated English source. Every other * key keeps its literal default, so this answers '' for them and the field stays * bound to the effective value. * * @param string $key Setting key. * @return string Translated default, or '' when the key has no such fallback. */ protected static function field_i18n_default( $key ) { $key = (string) $key; $slots = array( 'text_title' => 'title', 'text_message' => 'message', 'text_accept_all' => 'accept_all', 'text_reject_all' => 'reject_all', 'text_preferences' => 'preferences', 'text_save' => 'save', 'floating_button_label' => 'floating_label', ); if ( isset( $slots[ $key ] ) ) { $texts = (array) OWCO_Core::texts(); return isset( $texts[ $slots[ $key ] ] ) ? (string) $texts[ $slots[ $key ] ] : ''; } foreach ( OWCO_Core::categories() as $slug ) { if ( 'cat_' . $slug . '_label' === $key ) { return (string) OWCO_Core::category_label( $slug ); } if ( 'cat_' . $slug . '_desc' === $key ) { return (string) OWCO_Core::category_description( $slug ); } } return ''; } /** * What to show in a field that has a translated default: the administrator's own * override, or nothing. * * Binding those fields to OWCO_Core::setting() printed the untranslated English source * into the input, so a French administrator who saved the form froze English into * wp_options for every locale — and the help text promising "leave empty to use the * translated default" described a field that was never empty. The value therefore * comes from the stored option, and a stored value that still equals the shipped * English default counts as "not overridden", exactly as OWCO_Core reads it. * * @param string $key Setting key. * @return string */ protected static function field_stored_override( $key ) { $stored = OWCO_Core::stored_setting( $key, '' ); $stored = is_scalar( $stored ) ? (string) $stored : ''; if ( '' === $stored ) { return ''; } $defaults = OWCO_Core::default_settings(); $english = isset( $defaults[ $key ] ) && is_scalar( $defaults[ $key ] ) ? (string) $defaults[ $key ] : ''; return ( '' !== $english && $stored === $english ) ? '' : $stored; } /** * Checkbox bound to a boolean setting. * * The hidden `0` sibling is what tells save_settings_from_post() that this key belongs * to the submitted form: a bool missing from the POST means "another tab", never * "unchecked". * * @param string $key Setting key. * @param string $label Translated label. * @param string $help Optional translated help text. * @return void */ protected static function field_bool( $key, $label, $help = '' ) { $v = (bool) OWCO_Core::setting( $key ); ?>
'page', 'post_status' => array( 'publish', 'draft', 'private' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- postmeta is indexed on meta_key, and a taxonomy would not model "the page generated for this document type". 'meta_key' => '_owco_policy_type', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- the pair meta_key + meta_value IS the identity of the generated page; $policy_type is one of the plugin's own document slugs, the meta_key half is indexed, the query asks for one id only, and it runs on an admin settings row. 'meta_value' => $policy_type, 'numberposts' => 1, 'fields' => 'ids', ) ); $generated_id = $found ? (int) $found[0] : 0; } // All pages dropdown. $pages = get_pages( array( 'post_status' => 'publish,draft,private', 'sort_column' => 'post_title' ) ); ?> translated label. * @param string $help Optional translated help text. * @return void */ protected static function field_select( $key, $label, array $options, $help = '' ) { $v = OWCO_Core::setting( $key ); $v = is_scalar( $v ) ? (string) $v : ''; ?>
>
>
self::MENU_SLUG, 'tab' => 'compliance', 'owco_action' => 'generate_policy_inline', 'policy_type' => $policy_type, ), admin_url( 'admin.php' ) ), self::SAVE_NONCE_ACTION, '_owco_nonce_url' ); ?>

↗