prefix . 'nanoconsent_log'; } public static function install() { global $wpdb; $charset = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$wpdb->prefix}nanoconsent_log ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, time datetime NOT NULL, choice varchar(8) NOT NULL, cats varchar(80) NOT NULL DEFAULT '', ver varchar(8) NOT NULL DEFAULT '', rid varchar(16) NOT NULL DEFAULT '', PRIMARY KEY (id), KEY time (time), KEY choice (choice) ) {$charset};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); update_option( 'nanoconsent_db_version', self::DB_VERSION ); } public function __construct() { add_action( 'rest_api_init', array( $this, 'routes' ) ); // Taulu puuttuu jos lisaosa on kopioitu paikalleen ilman aktivointia. add_action( 'admin_init', array( $this, 'maybe_install' ) ); add_action( 'admin_init', array( $this, 'schedule' ) ); add_action( 'nanoconsent_prune', array( __CLASS__, 'prune' ) ); } public function maybe_install() { if ( get_option( 'nanoconsent_db_version' ) !== self::DB_VERSION ) { self::install(); } } /** * Sailytysajan siivous kerran paivassa - vain jos aika on asetettu. * Ajastus tarkistetaan hallinnassa, ei joka sivulatauksella. */ public function schedule() { $days = (int) Nanoconsent_Settings::get()['log_days']; $next = wp_next_scheduled( 'nanoconsent_prune' ); if ( $days > 0 && ! $next ) { wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'nanoconsent_prune' ); } elseif ( 0 === $days && $next ) { wp_unschedule_event( $next, 'nanoconsent_prune' ); } } public static function prune() { $days = (int) Nanoconsent_Settings::get()['log_days']; if ( $days <= 0 ) { return; } global $wpdb; $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "DELETE FROM {$wpdb->prefix}nanoconsent_log WHERE time < %s", gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ); } /** * Koko loki CSV:na. Todiste on tiedosto, ei kuva hallintapaneelista: * tilintarkastaja ja valvoja pyytavat rivit. Luetaan 2 000 rivin erissa, * jotta iso loki ei kaadu muistiin. */ public static function export_csv() { global $wpdb; nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="nanoconsent-consent-log-' . gmdate( 'Y-m-d' ) . '.csv"' ); $out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen // $escape = '' annetaan aina: PHP 8.4 varoittaa oletusarvosta, ja tyhja arvo // tuottaa RFC 4180 -CSV:n (lainausmerkki kahdennetaan), jonka Excel lukee oikein. fputcsv( $out, array( 'time_utc', 'choice', 'categories', 'text_version', 'record_id' ), ',', '"', '' ); $last = 0; while ( true ) { $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT id, time, choice, cats, ver, rid FROM {$wpdb->prefix}nanoconsent_log WHERE id > %d ORDER BY id LIMIT 2000", $last ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ARRAY_A ); if ( ! $rows ) { break; } foreach ( $rows as $r ) { fputcsv( $out, array( $r['time'], $r['choice'], $r['cats'], $r['ver'], $r['rid'] ), ',', '"', '' ); $last = (int) $r['id']; } } fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose exit; } public function routes() { register_rest_route( 'nanoconsent/v1', '/log', array( 'methods' => 'POST', 'callback' => array( $this, 'record' ), 'permission_callback' => '__return_true', ) ); } /** * Vastaanottaa bannerin lahettaman todisteen. Kaikki kentat tarkistetaan * ja rajataan: tama on julkinen paatepiste, ja siihen voi lahettaa mita * tahansa. */ public function record( WP_REST_Request $req ) { // Banneri lahettaa sendBeaconilla MERKKIJONON, ei Blobia, joten // Content-Type on text/plain eika get_json_params() nae sita. // Runko luetaan raakana; se on joka tapauksessa tarkistettava alla. $p = json_decode( (string) $req->get_body(), true ); if ( ! is_array( $p ) || ( $p['v'] ?? 0 ) !== 1 ) { return new WP_REST_Response( null, 400 ); } $allowed = array( 'analytics', 'marketing', 'preferences' ); $cats = isset( $p['cats'] ) && is_array( $p['cats'] ) ? array_values( array_intersect( $allowed, array_map( 'strval', $p['cats'] ) ) ) : array(); $enabled = Nanoconsent_Settings::get()['categories']; if ( empty( $cats ) ) { $choice = 'none'; } elseif ( count( array_intersect( $enabled, $cats ) ) === count( $enabled ) ) { $choice = 'all'; } else { $choice = 'custom'; } global $wpdb; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery self::table(), array( 'time' => current_time( 'mysql', true ), 'choice' => $choice, 'cats' => implode( ',', $cats ), 'ver' => substr( preg_replace( '/[^0-9.]/', '', (string) ( $p['ver'] ?? '' ) ), 0, 8 ), 'rid' => substr( preg_replace( '/[^a-z0-9]/', '', (string) ( $p['id'] ?? '' ) ), 0, 16 ), ), array( '%s', '%s', '%s', '%s', '%s' ) ); return new WP_REST_Response( null, 204 ); } // ---- tilastot hallintapaneelille ------------------------------------ /** * Paivittaiset maarat valinnoittain viimeisilta N paivalta. Palauttaa * taulukon jossa jokaiselle paivalle on rivi, myos tyhjille - kaavio * tarvitsee tasaisen akselin. */ public static function daily( $days = 30 ) { global $wpdb; $since = gmdate( 'Y-m-d 00:00:00', time() - $days * DAY_IN_SECONDS ); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT DATE(time) AS d, choice, COUNT(*) AS n FROM {$wpdb->prefix}nanoconsent_log WHERE time >= %s GROUP BY DATE(time), choice", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $since ), ARRAY_A ); $by = array(); foreach ( (array) $rows as $r ) { $by[ $r['d'] ][ $r['choice'] ] = (int) $r['n']; } $out = array(); for ( $i = $days - 1; $i >= 0; $i-- ) { $d = gmdate( 'Y-m-d', time() - $i * DAY_IN_SECONDS ); $out[] = array( 'date' => $d, 'all' => $by[ $d ]['all'] ?? 0, 'custom' => $by[ $d ]['custom'] ?? 0, 'none' => $by[ $d ]['none'] ?? 0, ); } return $out; } public static function totals( $days = 30 ) { $t = array( 'all' => 0, 'custom' => 0, 'none' => 0, 'sum' => 0 ); foreach ( self::daily( $days ) as $r ) { $t['all'] += $r['all']; $t['custom'] += $r['custom']; $t['none'] += $r['none']; } $t['sum'] = $t['all'] + $t['custom'] + $t['none']; return $t; } /** * Kuinka moni paattaja hyvaksyi kunkin luokan. */ public static function category_rates( $days = 30 ) { global $wpdb; $since = gmdate( 'Y-m-d 00:00:00', time() - $days * DAY_IN_SECONDS ); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT cats FROM {$wpdb->prefix}nanoconsent_log WHERE time >= %s", $since ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ARRAY_A ); $n = count( (array) $rows ); $count = array( 'analytics' => 0, 'marketing' => 0, 'preferences' => 0 ); foreach ( (array) $rows as $r ) { foreach ( explode( ',', $r['cats'] ) as $c ) { if ( isset( $count[ $c ] ) ) { $count[ $c ]++; } } } $out = array(); foreach ( $count as $c => $k ) { $out[ $c ] = $n ? round( 100 * $k / $n ) : 0; } return array( 'n' => $n, 'rates' => $out ); } public static function recent( $limit = 12 ) { global $wpdb; return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT time, choice, cats FROM {$wpdb->prefix}nanoconsent_log ORDER BY id DESC LIMIT %d", $limit ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ARRAY_A ); } public static function count_all() { global $wpdb; return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}nanoconsent_log" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared } }