Plugin Directory

Changeset 3704935


Ignore:
Timestamp:
09/21/2026 06:19:26 AM (7 weeks ago)
Author:
DvanKooten
Message:

v2.5.3

Location:
koko-analytics
Files:
2 deleted
36 edited
1 copied

Legend:

Unmodified
Added
Removed
  • koko-analytics/tags/2.5.3/CHANGELOG.md

    r3634028 r3704935  
    11# Changelog
     2
     3= 2.5.3 =
     4
     5- security: enforce the same access checks for public dashboard pages and REST API requests, fixing bypasses of Pro password protection.
     6- dashboard: reduce crawler and cache load by only accepting read requests, canonicalizing query parameters, and hiding pagination and date navigation on public dashboards.
     7- dashboard: only enable component sorting in the admin dashboard, where changes can be saved.
     8- perf: process aggregation buffers in chunks to reduce peak memory usage on busy sites.
     9- docs: show the dedicated support address to Pro users on the help page.
     10
    211
    312= 2.5.2 =
    … …  
    716- data: increase the aggregation interval to 65 seconds to reduce the frequency of scheduling conflicts.
    817- ux: show admin notice with location of analytics dashboard after initial plugin activation.
     18
    919
    1020= 2.5.1 =
  • koko-analytics/tags/2.5.3/assets/css/dashboard.css

    r3605984 r3704935  
    244244}
    245245.page-filter-active tr,
    246 .page-filter-active .ka-pagination  {
     246.page-filter-active .ka-pagination2  {
    247247  opacity: 0.5;
    248248}
    … …  
    587587
    588588/* pagination */
    589 
    590 /* old style pagination, used in Pro components */
    591 .ka-pagination {
    592   display: flex;
    593   padding: 0.5rem 1rem;
    594   border-top: 1px solid rgb(240, 240, 240);
    595 }
    596 .ka-pagination--next {
    597   margin-left: auto;
    598 }
    599589
    600590/* new style pagination (2026-07-03) */
  • koko-analytics/tags/2.5.3/assets/js/dashboard.js

    r3548865 r3704935  
    1 (()=>{function l(t){this.root=t,this.barWidth=0,this.closeTooltipTimeout=null,this.root&&(this.tooltip=document.querySelector(".ka-chart--tooltip"),this.tooltip.remove(),document.body.appendChild(this.tooltip),[this.root,this.tooltip].forEach(i=>{i.addEventListener("mouseleave",this.closeTooltip.bind(this)),i.addEventListener("mouseenter",this.keepTooltip.bind(this))}),this.root.addEventListener("mouseover",i=>{i.target.tagName==="rect"&&this.showTooltip(i.target)}),this.redraw())}l.prototype.closeTooltip=function(){this.closeTooltipTimeout=window.setTimeout(()=>{this.tooltip.style.display="none"},20)};l.prototype.keepTooltip=function(){window.clearTimeout(this.closeTooltipTimeout)};l.prototype.showTooltip=function(t){let i=t.parentElement.dataset;this.tooltip.querySelector(".ka-chart--tooltip-heading").textContent=i.date,this.tooltip.querySelector(".ka--visitors").children[0].textContent=i.visitors,this.tooltip.querySelector(".ka--pageviews").children[0].textContent=i.pageviews;let o=document.body.clientWidth;this.tooltip.style.display="block";let e=t.parentElement.getBoundingClientRect(),r=Math.round(e.left-.5*this.tooltip.clientWidth+.5*this.barWidth),a=Math.round(e.top+window.scrollY-this.tooltip.clientHeight),s=0;r<12?s=-r+12:r+this.tooltip.clientWidth>o-12&&(s=o-(r+this.tooltip.clientWidth)-12),r+=s;let y=this.tooltip.querySelector(".ka-chart--tooltip-arrow");y.style.marginLeft=s===0?"auto":.5*this.tooltip.clientWidth-6-s+"px",this.tooltip.style.left=r+"px",this.tooltip.style.top=a+"px"};l.prototype.redraw=function(){let t=Array.from(this.root.querySelectorAll(".axes-y text")).reduce((e,r)=>Math.max(e,8+Math.max(5,r.textContent.length*8)),0),i=this.root.querySelectorAll(".bars g"),o=Math.max(1,(this.root.clientWidth-t)/i.length);this.barWidth=Math.max(1,o-2);for(let e=0;e<i.length;e++){let r=e*o+t+1,a=i[e].children;a[0].setAttribute("x",r),a[0].setAttribute("width",this.barWidth),a[1].setAttribute("x",r),a[1].setAttribute("width",this.barWidth);let s=e*o+t+.5*o;a[2].style.display=this.barWidth===1?"none":"",a[2].setAttribute("x1",s),a[2].setAttribute("x2",s)}};var n=document.getElementById("ka-components");if(n&&n.querySelector(".ka-col[data-sortable]")){let t=null,i=n.querySelectorAll(".ka-col[data-sortable]");n.addEventListener("mousedown",function(o){if(o.target.closest("th")){let e=o.target.closest(".ka-col");e&&(e.draggable=!0)}}),document.addEventListener("mouseup",function(){i.forEach(o=>{o.draggable=!1})}),n.addEventListener("dragstart",function(o){t=o.target.closest(".ka-col"),t&&(t.classList.add("ka-dragging"),o.dataTransfer.effectAllowed="move")}),n.addEventListener("dragover",function(o){o.preventDefault(),o.dataTransfer.dropEffect="move";let e=o.target.closest(".ka-col");!e||e===t||(t.compareDocumentPosition(e)&Node.DOCUMENT_POSITION_FOLLOWING?n.insertBefore(t,e.nextSibling):n.insertBefore(t,e))}),n.addEventListener("dragend",function(){if(!t)return;t.classList.remove("ka-dragging"),t=null,i.forEach(function(r){r.draggable=!1});let o=Array.from(n.querySelectorAll(".ka-col[data-sortable]")).map(r=>r.id),e=new FormData;e.append("koko_analytics_action","save_component_order"),e.append("_nonce",n.dataset.nonce),o.forEach(r=>{e.append("component_order[]",r)}),window.fetch(window.location.href,{method:"POST",body:e})})}var d=window.setTimeout(c,61e3);function c(){document.hidden?d=window.setTimeout(c,61e3):window.location.reload()}document.addEventListener("mouseover",function(){window.clearTimeout(d),d=window.setTimeout(c,61e3)});var u=document.querySelector("#ka-date-presets"),h=document.querySelector("#ka-date-start"),p=document.querySelector("#ka-date-end");u&&u.addEventListener("change",function(){h.disabled=!0,p.disabled=!0,this.form.submit()});function f(){u.value="custom"}h&&h.addEventListener("change",f);p&&p.addEventListener("change",f);document.addEventListener("keydown",function(t){if(!t.defaultPrevented)switch(t.key){case"ArrowLeft":document.querySelector(".js-quicknav-prev").click();break;case"ArrowRight":document.querySelector(".js-quicknav-next").click();break}});document.querySelectorAll("a[data-href]").forEach(function(t){t.addEventListener("click",function(i){i.preventDefault(),window.location.href=t.getAttribute("data-href")})});document.addEventListener("DOMContentLoaded",()=>{new l(document.getElementById("ka-chart"))});function w(){sessionStorage.setItem("scrollX",window.pageXOffset),sessionStorage.setItem("scrollY",window.pageYOffset)}document.addEventListener("click",w);window.addEventListener("beforeunload",w);var m=parseInt(sessionStorage.getItem("scrollX")??0),g=parseInt(sessionStorage.getItem("scrollY")??0);(m!=0||g!=0)&&window.scroll(m,g);})();
     1(()=>{function d(t){this.root=t,this.barWidth=0,this.closeTooltipTimeout=null,this.root&&(this.tooltip=document.querySelector(".ka-chart--tooltip"),this.tooltip.remove(),document.body.appendChild(this.tooltip),[this.root,this.tooltip].forEach(r=>{r.addEventListener("mouseleave",this.closeTooltip.bind(this)),r.addEventListener("mouseenter",this.keepTooltip.bind(this))}),this.root.addEventListener("mouseover",r=>{r.target.tagName==="rect"&&this.showTooltip(r.target)}),this.redraw())}d.prototype.closeTooltip=function(){this.closeTooltipTimeout=window.setTimeout(()=>{this.tooltip.style.display="none"},20)};d.prototype.keepTooltip=function(){window.clearTimeout(this.closeTooltipTimeout)};d.prototype.showTooltip=function(t){let r=t.parentElement.dataset;this.tooltip.querySelector(".ka-chart--tooltip-heading").textContent=r.date,this.tooltip.querySelector(".ka--visitors").children[0].textContent=r.visitors,this.tooltip.querySelector(".ka--pageviews").children[0].textContent=r.pageviews;let o=document.body.clientWidth;this.tooltip.style.display="block";let e=t.parentElement.getBoundingClientRect(),i=Math.round(e.left-.5*this.tooltip.clientWidth+.5*this.barWidth),a=Math.round(e.top+window.scrollY-this.tooltip.clientHeight),s=0;i<12?s=-i+12:i+this.tooltip.clientWidth>o-12&&(s=o-(i+this.tooltip.clientWidth)-12),i+=s;let b=this.tooltip.querySelector(".ka-chart--tooltip-arrow");b.style.marginLeft=s===0?"auto":.5*this.tooltip.clientWidth-6-s+"px",this.tooltip.style.left=i+"px",this.tooltip.style.top=a+"px"};d.prototype.redraw=function(){let t=Array.from(this.root.querySelectorAll(".axes-y text")).reduce((e,i)=>Math.max(e,8+Math.max(5,i.textContent.length*8)),0),r=this.root.querySelectorAll(".bars g"),o=Math.max(1,(this.root.clientWidth-t)/r.length);this.barWidth=Math.max(1,o-2);for(let e=0;e<r.length;e++){let i=e*o+t+1,a=r[e].children;a[0].setAttribute("x",i),a[0].setAttribute("width",this.barWidth),a[1].setAttribute("x",i),a[1].setAttribute("width",this.barWidth);let s=e*o+t+.5*o;a[2].style.display=this.barWidth===1?"none":"",a[2].setAttribute("x1",s),a[2].setAttribute("x2",s)}};var n=document.getElementById("ka-components");if(n&&n.querySelector(".ka-col[data-sortable]")){let t=null,r=n.querySelectorAll(".ka-col[data-sortable]");n.addEventListener("mousedown",function(o){if(o.target.closest("th")){let e=o.target.closest(".ka-col");e&&(e.draggable=!0)}}),document.addEventListener("mouseup",function(){r.forEach(o=>{o.draggable=!1})}),n.addEventListener("dragstart",function(o){t=o.target.closest(".ka-col"),t&&(t.classList.add("ka-dragging"),o.dataTransfer.effectAllowed="move")}),n.addEventListener("dragover",function(o){o.preventDefault(),o.dataTransfer.dropEffect="move";let e=o.target.closest(".ka-col");!e||e===t||(t.compareDocumentPosition(e)&Node.DOCUMENT_POSITION_FOLLOWING?n.insertBefore(t,e.nextSibling):n.insertBefore(t,e))}),n.addEventListener("dragend",function(){if(!t)return;t.classList.remove("ka-dragging"),t=null,r.forEach(function(i){i.draggable=!1});let o=Array.from(n.querySelectorAll(".ka-col[data-sortable]")).map(i=>i.id),e=new FormData;e.append("koko_analytics_action","save_component_order"),e.append("_nonce",n.dataset.nonce),o.forEach(i=>{e.append("component_order[]",i)}),window.fetch(window.location.href,{method:"POST",body:e})})}var l=window.setTimeout(c,61e3);function c(){document.hidden?l=window.setTimeout(c,61e3):window.location.reload()}document.addEventListener("mouseover",function(){window.clearTimeout(l),l=window.setTimeout(c,61e3)});function m(t){var r=new URL(window.location.href),o=new URLSearchParams(new FormData(t));o.sort(),r.search=o.toString(),window.location.href=r.toString()}var f=document.querySelector("#ka-datepicker-dropdown form");f&&f.addEventListener("submit",function(t){t.preventDefault(),m(this)});var u=document.querySelector("#ka-date-presets"),h=document.querySelector("#ka-date-start"),p=document.querySelector("#ka-date-end");u&&u.addEventListener("change",function(){h.disabled=!0,p.disabled=!0,m(this.form)});function w(){u.value="custom"}h&&h.addEventListener("change",w);p&&p.addEventListener("change",w);document.addEventListener("keydown",function(t){if(!t.defaultPrevented){var r;switch(t.key){case"ArrowLeft":r=document.querySelector(".js-quicknav-prev");break;case"ArrowRight":r=document.querySelector(".js-quicknav-next");break}r&&r.click()}});document.querySelectorAll("a[data-start-date][data-end-date]").forEach(function(t){t.addEventListener("click",function(r){r.preventDefault();var o=new URL(window.location.href),e=new URLSearchParams;["page","koko-analytics-dashboard"].forEach(function(i){o.searchParams.has(i)&&e.set(i,o.searchParams.get(i))}),e.set("start_date",t.getAttribute("data-start-date")),e.set("end_date",t.getAttribute("data-end-date")),e.sort(),o.search=e.toString(),window.location.href=o.toString()})});document.addEventListener("DOMContentLoaded",()=>{new d(document.getElementById("ka-chart"))});function y(){sessionStorage.setItem("scrollX",window.pageXOffset),sessionStorage.setItem("scrollY",window.pageYOffset)}document.addEventListener("click",y);window.addEventListener("beforeunload",y);var g=parseInt(sessionStorage.getItem("scrollX")??0),v=parseInt(sessionStorage.getItem("scrollY")??0);(g!=0||v!=0)&&window.scroll(g,v);})();
  • koko-analytics/tags/2.5.3/assets/js/src/imports/datepicker.js

    r3500426 r3704935  
     1
     2// navigate to the date range form its target URL ourselves, so that we can sort the query string
     3// keeping the query args in a fixed order means the same view always maps to the same URL, which is
     4// friendlier to any cache sitting in front of the (public) dashboard
     5function submitDateForm(form) {
     6  var url = new URL(window.location.href);
     7  var params = new URLSearchParams(new FormData(form));
     8  params.sort();
     9  url.search = params.toString();
     10  window.location.href = url.toString();
     11}
     12
     13var dateForm = document.querySelector('#ka-datepicker-dropdown form');
     14dateForm && dateForm.addEventListener('submit', function(evt) {
     15  evt.preventDefault();
     16  submitDateForm(this);
     17});
    118
    219// update date_start and date_end <input>'s whenever a preset is selected
    … …  
    724  dateStartInput.disabled = true;
    825  dateEndInput.disabled = true;
    9   this.form.submit();
     26  submitDateForm(this.form);
    1027});
    1128
    … …  
    1936
    2037// click "prev date range" or "next date range" when using arrow keys
     38// note these links are not always there, eg on the public dashboard or at the edges of the available date range
    2139document.addEventListener('keydown', function (evt) {
    2240  if (evt.defaultPrevented) {
    … …  
    2442  }
    2543
     44  var quicknav;
    2645  switch (evt.key) {
    2746  case 'ArrowLeft':
    28     document.querySelector('.js-quicknav-prev').click();
     47    quicknav = document.querySelector('.js-quicknav-prev');
    2948    break;
    3049  case 'ArrowRight':
    31     document.querySelector('.js-quicknav-next').click();
     50    quicknav = document.querySelector('.js-quicknav-next');
    3251    break;
    3352  }
     53
     54  quicknav && quicknav.click();
    3455})
    3556
    3657// fake <a> elements to stop bots from crawling infinitely
    37 document.querySelectorAll('a[data-href]').forEach(function(el) {
     58// the HTML only carries the target dates, we assemble the URL here so the document holds no crawlable link
     59// note we keep just the query args identifying the dashboard itself, dropping any active filter or pagination
     60document.querySelectorAll('a[data-start-date][data-end-date]').forEach(function(el) {
    3861  el.addEventListener('click', function(evt) {
    3962    evt.preventDefault();
    40     window.location.href = el.getAttribute('data-href');
     63
     64    var url = new URL(window.location.href);
     65    var params = new URLSearchParams();
     66    ['page', 'koko-analytics-dashboard'].forEach(function(key) {
     67      if (url.searchParams.has(key)) {
     68        params.set(key, url.searchParams.get(key));
     69      }
     70    });
     71    params.set('start_date', el.getAttribute('data-start-date'));
     72    params.set('end_date', el.getAttribute('data-end-date'));
     73    params.sort();
     74    url.search = params.toString();
     75    window.location.href = url.toString();
    4176  });
    4277});
  • koko-analytics/tags/2.5.3/data/referrer-blocklist

    r3597329 r3704935  
    489489concretepol.com
    490490connectikastudio.com
     491considerin.net
    491492constanceonline.top
    492493cookie-law-enforcement-aa.xyz
    … …  
    14901491oracle-patches.ru
    14911492orakul.spb.ru
     1493osdzc.com
    14921494osteochondrosis.ru
    14931495otdbiaxaem-vmeste.ru
    … …  
    16541656rcb101.ru
    16551657rczhan.com
     1658readlife.net
    16561659realresultslist.com
    16571660recinziireale.com
    … …  
    19391942suckoutlet.xyz
    19401943sudachitravel.com
     1944sunblog.asia
    19411945sundrugstore.com
    19421946super-seo-guru.com
    … …  
    23332337zelena-mriya.com.ua
    23342338zexhgoods.xyz
     2339zeyao.net
    23352340zhcsapp.net
    23362341zhoobintravel.com
  • koko-analytics/tags/2.5.3/koko-analytics.php

    r3634028 r3704935  
    44Plugin Name: Koko Analytics
    55Plugin URI: https://www.kokoanalytics.com/#utm_source=koko-analytics&utm_medium=link&utm_campaign=free-plugin-plugin-header
    6 Version: 2.5.2
     6Version: 2.5.3
    77Description: Privacy-friendly and efficient statistics for your WordPress site.
    88Author: ibericode
    … …  
    4343PHP_VERSION_ID >= 70400 || exit;
    4444
    45 define('KOKO_ANALYTICS_VERSION', '2.5.2');
     45define('KOKO_ANALYTICS_VERSION', '2.5.3');
    4646define('KOKO_ANALYTICS_PLUGIN_FILE', __FILE__);
    4747define('KOKO_ANALYTICS_PLUGIN_DIR', __DIR__);
  • koko-analytics/tags/2.5.3/readme.txt

    r3674437 r3704935  
    33Tags: analytics, google analytics, privacy, statistics, website statistics
    44Requires at least: 6.2
    5 Tested up to: 7.1
    6 Stable tag: 2.5.2
     5Tested up to: 7.0
     6Stable tag: 2.5.3
    77License: GPL-3.0-or-later
    88License URI: https://www.gnu.org/licenses/gpl-3.0.html
    … …  
    1414
    1515Koko Analytics is a privacy-friendly and lightweight Google Analytics alternative for WordPress sites. It is a plug and play solution to get GDPR compliant insights into your website visitors without requiring any third-party services.
    16 
    17 [youtube https://www.youtube.com/watch?v=4o0Rp3F4qh8]
    1816
    1917== Why choose Koko Analytics? ==
    … …  
    126124== Changelog ==
    127125
     126= 2.5.3 =
     127
     128- security: enforce the same access checks for public dashboard pages and REST API requests, fixing bypasses of Pro password protection.
     129- dashboard: reduce crawler and cache load by only accepting read requests, canonicalizing query parameters, and hiding pagination and date navigation on public dashboards.
     130- dashboard: only enable component sorting in the admin dashboard, where changes can be saved.
     131- perf: process aggregation buffers in chunks to reduce peak memory usage on busy sites.
     132- docs: show the dedicated support address to Pro users on the help page.
     133
     134
    128135= 2.5.2 =
    129136
    … …  
    132139- data: increase the aggregation interval to 65 seconds to reduce the frequency of scheduling conflicts.
    133140- ux: show admin notice with location of analytics dashboard after initial plugin activation.
     141
    134142
    135143= 2.5.1 =
    … …  
    156164- compatibility: raise the minimum supported WordPress version to 6.2.
    157165
    158 
    159 = 2.4.0 =
    160 
    161 - tracking: hook into the visibilitychange event again to ignore prerender requests.
    162 - data: rewrite exporter and importer to use NDJSON instead of raw SQL.
    163 - import: fix column names for the referrer stats table.
    164 - rest: clamp date range for unauthenticated users to prevent large table scans.
    165 - review notice: simplify the review notice to one primary action.
    166 
    167166[View the full changelog on GitHub](https://github.com/ibericode/koko-analytics/blob/main/CHANGELOG.md)
  • koko-analytics/tags/2.5.3/src/Aggregator.php

    r3581336 r3704935  
    1313class Aggregator
    1414{
     15    private const CHUNK_SIZE = 10000;
     16
    1517    /**
    16      * Reads the buffer file into memory and moves data into the MySQL database (in bulk)
     18     * Reads the buffer file in chunks and moves data into the MySQL database.
    1719     *
    1820     * @throws Exception
    … …  
    2931        // init pageview aggregator
    3032        $pageview_aggregator = new Pageview_Aggregator();
     33        $records_in_chunk    = 0;
    3134
    3235        // rename file to temporary location so nothing new is written to it while we process it
    … …  
    7174            // add-on aggregators
    7275            do_action('koko_analytics_aggregate_line', $type, $params);
     76
     77            ++$records_in_chunk;
     78            if ($records_in_chunk === self::CHUNK_SIZE) {
     79                $this->flush($pageview_aggregator);
     80                $records_in_chunk = 0;
     81            }
    7382        }
     83
     84        if ($records_in_chunk > 0) {
     85            $this->flush($pageview_aggregator);
     86        }
     87
     88        // Finalize state that remains bounded across chunks, such as realtime counts.
     89        $pageview_aggregator->finish();
    7490
    7591        // close file & remove it from filesystem
    … …  
    7793        \unlink($tmp_filename);
    7894
    79         // tell aggregators to write their results to the database
    80         $pageview_aggregator->finish();
     95        // signal that the entire buffer file was processed
    8196        do_action('koko_analytics_aggregate_finish');
    8297
    8398        update_option('koko_analytics_last_aggregation_at', \time(), false);
    8499    }
     100
     101    private function flush(Pageview_Aggregator $pageview_aggregator): void
     102    {
     103        $pageview_aggregator->finish(false);
     104
     105        // Allow add-on aggregators to persist and reset their in-memory state.
     106        do_action('koko_analytics_aggregate_chunk_finish');
     107    }
    85108}
  • koko-analytics/tags/2.5.3/src/Chart_View.php

    r3597329 r3704935  
    4444                <div class="ka-chart-group">
    4545                    <?php foreach ($group_options as $key => $option) : ?>
    46                         <a href="<?= esc_attr(add_query_arg(['group' => $key])); ?>" class="<?php echo $group === $key ? 'on' : ''; ?>" rel="nofollow"><?php echo esc_html($option['label']); ?></a>
     46                        <a href="<?= esc_attr(dashboard_url(['group' => $key])); ?>" class="<?php echo $group === $key ? 'on' : ''; ?>" rel="nofollow"><?php echo esc_html($option['label']); ?></a>
    4747                    <?php endforeach; ?>
    4848                </div>
  • koko-analytics/tags/2.5.3/src/Dashboard.php

    r3597329 r3704935  
    1717    public const MAX_OFFSET = 10000;
    1818
     19    private static bool $show_pagination_links = true;
     20
    1921    public static function clamp_limit($value, int $default = 10, int $minimum = 1): int
    2022    {
    … …  
    3941        $items_per_page = (int) apply_filters('koko_analytics_items_per_page', 20);
    4042        $date_format    = get_option('date_format', 'Y-m-d');
    41         $dashboard_url  = $this->get_base_url();
    4243
    4344        // parse query params
    … …  
    272273    }
    273274
    274     public function pagination(string $key, int $offset, int $limit, int $count): void
     275    /**
     276     * Hides the previous/next links in the datepicker and in every pagination component rendered from here on.
     277     */
     278    public static function hide_pagination_links(): void
     279    {
     280        self::$show_pagination_links = false;
     281    }
     282
     283    /**
     284     * Whether previous/next links should be rendered at all.
     285     */
     286    public static function shows_pagination_links(): bool
     287    {
     288        return self::$show_pagination_links;
     289    }
     290
     291    /**
     292     * Renders the pagination component for a dashboard component.
     293     *
     294     * @param string $key        The query string key holding the offset & limit for this component.
     295     * @param array  $extra_args Additional query args to preserve in the previous/next links.
     296     */
     297    public static function pagination(string $key, int $offset, int $limit, int $count, array $extra_args = []): void
    275298    {
    276299        if ($offset >= $limit || $offset + $limit < $count) {
    … …  
    283306                ?>
    284307            </span>
     308            <?php if (self::$show_pagination_links) : ?>
    285309            <span>
    286310                <?php if ($offset >= $limit) : ?>
    287                     <a  href="<?php echo esc_attr(add_query_arg(['p' => null, $key => $offset >= $limit * 2 ? ['offset' => $offset - $limit, 'limit' => $limit] : null ])); ?>" rel="nofollow">← <?php esc_html_e('Previous', 'koko-analytics'); ?></a>
     311                    <a  href="<?php echo esc_attr(dashboard_url(['p' => null, $key => $offset >= $limit * 2 ? array_merge($extra_args, ['offset' => $offset - $limit, 'limit' => $limit]) : ($extra_args ?: null) ])); ?>" rel="nofollow">← <?php esc_html_e('Previous', 'koko-analytics'); ?></a>
    288312                <?php else : ?>
    289313                    <span class="ka-pagination2-muted">← <?php esc_html_e('Previous', 'koko-analytics'); ?></span>
    … …  
    291315                <span> · </span>
    292316                <?php if ($offset + $limit < $count) : ?>
    293                 <a  href="<?php echo esc_attr(add_query_arg(['p' => null, $key => ['offset' => $offset + $limit, 'limit' => $limit]])); ?>" rel="nofollow"><?php esc_html_e('Next', 'koko-analytics'); ?> →</a>
     317                <a  href="<?php echo esc_attr(dashboard_url(['p' => null, $key => array_merge($extra_args, ['offset' => $offset + $limit, 'limit' => $limit])])); ?>" rel="nofollow"><?php esc_html_e('Next', 'koko-analytics'); ?> →</a>
    294318                <?php else : ?>
    295319                    <span class="ka-pagination2-muted"><?php esc_html_e('Next', 'koko-analytics'); ?> →</span>
    296320                <?php endif; ?>
    297321            </span>
     322            <?php endif; ?>
    298323        </div>
    299324            <?php
    … …  
    340365                        <td class="text-muted"><?= esc_html((string) ($offset + $i + 1)); ?></td>
    341366                        <td class="text-truncate">
    342                             <a href="<?= esc_attr(add_query_arg(['p' => $page_filter])); ?>"><?= esc_html($p->label); ?></a>
     367                            <a href="<?= esc_attr(dashboard_url(['p' => $page_filter])); ?>"><?= esc_html($p->label); ?></a>
    343368                            <a class="ka-visit-link" href="<?= esc_url($p->post_permalink); ?>" target="_blank" rel="noopener" title="<?php esc_attr_e('View page', 'koko-analytics'); ?>"><i class="icon icon-sm icon-external-link" aria-hidden="true"></i></a>
    344369                        </td>
    … …  
    354379        <?php } ?>
    355380
    356         <?php $this->pagination('posts', $offset, $limit, $count); ?>
     381        <?php self::pagination('posts', $offset, $limit, $count); ?>
    357382           
    358383        <?php
    … …  
    406431        <?php } ?>
    407432
    408         <?php $this->pagination('referrers', $offset, $limit, $count); ?>
     433        <?php self::pagination('referrers', $offset, $limit, $count); ?>
    409434       
    410435        <?php
  • koko-analytics/tags/2.5.3/src/Dashboard_Public.php

    r3581336 r3704935  
    2020        $settings = get_settings();
    2121        $today    = (new DateTimeImmutable('now', wp_timezone()))->format('Y-m-d');
    22         if (!$settings['is_dashboard_public'] && !current_user_can('view_koko_analytics')) {
    23             return;
     22
     23        // this dashboard is a read-only view, so only ever answer GET (and HEAD) requests
     24        // any other method is uncacheable, and would be a way to force a full render past every cache
     25        $method = wp_unslash($_SERVER['REQUEST_METHOD'] ?? 'GET');
     26        if ($method !== 'GET' && $method !== 'HEAD') {
     27            status_header(405);
     28            header('Allow: GET, HEAD');
     29            exit;
    2430        }
    2531
    … …  
    3238        }
    3339
     40        // redirect to the canonical URL for this view, so that visitors arriving through a link that
     41        // was shared or crawled hit the same cache entry as everyone else
     42        $request_uri = wp_unslash($_SERVER['REQUEST_URI'] ?? '');
     43        $canonical   = sort_query_string($request_uri);
     44        if ($canonical !== $request_uri) {
     45            wp_safe_redirect($canonical, 301);
     46            exit;
     47        }
     48
     49        if (! can_view_dashboard($settings)) {
     50            do_action('koko_analytics_public_dashboard_access_denied', $settings);
     51            return;
     52        }
     53
     54        // the public dashboard is crawlable, so don't hand out paginated URLs for bots to follow
     55        self::hide_pagination_links();
     56
    3457        do_action('koko_analytics_public_dashboard_headers');
    3558
    … …  
    3760        header("X-Robots-Tag: noindex, nofollow");
    3861        if (is_user_logged_in()) {
    39             header("Cache-Control: no-store, must-revalidate, no-cache, max-age=0, private");
     62            $cache_control = 'no-store, must-revalidate, no-cache, max-age=0, private';
    4063        } elseif (isset($_GET['end_date'], $_GET['start_date']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', wp_unslash($_GET['start_date'])) && preg_match('/^\d{4}-\d{2}-\d{2}$/', wp_unslash($_GET['end_date'])) && wp_unslash($_GET['end_date']) < $today) {
    41             header("Cache-Control: public, max-age=68400");
     64            $cache_control = 'public, max-age=68400';
    4265        } else {
    43             header("Cache-Control: public, max-age=60");
     66            $cache_control = 'public, max-age=60';
    4467        }
     68        $cache_control = (string) apply_filters('koko_analytics_public_dashboard_cache_control', $cache_control, $settings);
     69        header("Cache-Control: {$cache_control}");
    4570
    4671        require KOKO_ANALYTICS_PLUGIN_DIR . '/src/Resources/views/dashboard-public.php';
  • koko-analytics/tags/2.5.3/src/Pageview_Aggregator.php

    r3581336 r3704935  
    1717class Pageview_Aggregator
    1818{
     19    private const DATABASE_BATCH_SIZE = 500;
     20
    1921    protected wpdb $db;
    2022    protected DateTimeZone $timezone;
    … …  
    8486    }
    8587
    86     public function finish(): void
     88    public function finish(bool $update_realtime = true): void
    8789    {
    8890        $this->commit_site_stats();
    8991        $this->commit_post_stats();
    9092        $this->commit_referrer_stats();
    91         $this->update_realtime_pageview_count();
     93        if ($update_realtime) {
     94            $this->update_realtime_pageview_count();
     95        }
    9296    }
    9397
    … …  
    103107    private function commit_post_stats(): void
    104108    {
    105         $upserter = new Upserter('paths', 'path');
     109        $upserter = new Upserter('paths', 'path', $this->db);
    106110
    107111        // insert page-specific stats
    108112        foreach ($this->post_stats as $date => $stats) {
    109             $path_ids = $upserter->upsert(array_keys($stats));
    110             $values   = [];
    111             foreach ($stats as $path => $r) {
    112                 array_push($values, $date, $path_ids[$path], $r->post_id, $r->visitors, $r->pageviews);
     113            foreach (array_chunk($stats, self::DATABASE_BATCH_SIZE, true) as $batch) {
     114                $path_ids = $upserter->upsert(array_keys($batch));
     115                $values   = [];
     116                foreach ($batch as $path => $r) {
     117                    array_push($values, $date, $path_ids[$path], $r->post_id, $r->visitors, $r->pageviews);
     118                }
     119                $placeholders = rtrim(str_repeat('(%s,%d,%d,%d,%d),', count($batch)), ',');
     120                $this->db->query($this->db->prepare("INSERT INTO {$this->db->prefix}koko_analytics_post_stats(date, path_id, post_id, visitors, pageviews) VALUES {$placeholders} ON DUPLICATE KEY UPDATE visitors = visitors + VALUES(visitors), pageviews = pageviews + VALUES(pageviews)", $values));
    113121            }
    114             $placeholders = rtrim(str_repeat('(%s,%d,%d,%d,%d),', count($stats)), ',');
    115             $this->db->query($this->db->prepare("INSERT INTO {$this->db->prefix}koko_analytics_post_stats(date, path_id, post_id, visitors, pageviews) VALUES {$placeholders} ON DUPLICATE KEY UPDATE visitors = visitors + VALUES(visitors), pageviews = pageviews + VALUES(pageviews)", $values));
    116122        }
    117123
    … …  
    121127    private function commit_referrer_stats(): void
    122128    {
    123         $upserter = new Upserter('referrer_labels', 'value');
     129        $upserter = new Upserter('referrer_labels', 'value', $this->db);
    124130
    125131        // insert referrer stats
    126132        foreach ($this->referrer_stats as $date => $stats) {
    127             $referrer_ids = $upserter->upsert(array_keys($stats));
    128             $values       = [];
    129             foreach ($stats as $url => $r) {
    130                 array_push($values, $date, $referrer_ids[$url], $r->visitors, $r->pageviews);
     133            foreach (array_chunk($stats, self::DATABASE_BATCH_SIZE, true) as $batch) {
     134                $referrer_ids = $upserter->upsert(array_keys($batch));
     135                $values       = [];
     136                foreach ($batch as $url => $r) {
     137                    array_push($values, $date, $referrer_ids[$url], $r->visitors, $r->pageviews);
     138                }
     139                $placeholders = rtrim(str_repeat('(%s,%d,%d,%d),', count($batch)), ',');
     140                $this->db->query($this->db->prepare("INSERT INTO {$this->db->prefix}koko_analytics_referrer_stats(date, id, unique_hits, hits) VALUES {$placeholders} ON DUPLICATE KEY UPDATE unique_hits = unique_hits + VALUES(unique_hits), hits = hits + VALUES(hits)", $values));
    131141            }
    132             $placeholders = rtrim(str_repeat('(%s,%d,%d,%d),', count($stats)), ',');
    133             $this->db->query($this->db->prepare("INSERT INTO {$this->db->prefix}koko_analytics_referrer_stats(date, id, unique_hits, hits) VALUES {$placeholders} ON DUPLICATE KEY UPDATE unique_hits = unique_hits + VALUES(unique_hits), hits = hits + VALUES(hits)", $values));
    134142        }
    135143
  • koko-analytics/tags/2.5.3/src/Resources/external-strings.php

    r3605984 r3704935  
    33// i18n strings for koko-analytics-pro
    44
    5 // ../koko-analytics-pro/src/UTM/views/dashboard.php#18
     5// ../koko-analytics-pro/src/UTM/views/dashboard.php#20
    66__('Top referrers', 'koko-analytics');
    77
    8 // ../koko-analytics-pro/src/UTM/views/dashboard.php#19
     8// ../koko-analytics-pro/src/UTM/views/dashboard.php#21
    99__('Where your visitors came from', 'koko-analytics');
    1010
    11 // ../koko-analytics-pro/src/UTM/views/dashboard.php#27
     11// ../koko-analytics-pro/src/UTM/views/dashboard.php#29
    1212__('Referrers report type', 'koko-analytics');
    1313
    14 // ../koko-analytics-pro/src/UTM/views/dashboard.php#34
     14// ../koko-analytics-pro/src/UTM/views/dashboard.php#36
    1515__('A visitor represents the number of sessions during which a page was viewed one or more times.', 'koko-analytics');
    1616
    17 // ../koko-analytics-pro/src/UTM/views/dashboard.php#34
     17// ../koko-analytics-pro/src/UTM/views/dashboard.php#36
    1818__('Visitors', 'koko-analytics');
    1919
    20 // ../koko-analytics-pro/src/UTM/views/dashboard.php#35
     20// ../koko-analytics-pro/src/UTM/views/dashboard.php#37
    2121__('A pageview is defined as a view of a page on your site. If a user clicks reload after reaching the page, this is counted as an additional pageview. If a visitor navigates to a different page and then returns to the original page, a second pageview is recorded as well.', 'koko-analytics');
    2222
    23 // ../koko-analytics-pro/src/UTM/views/dashboard.php#35
     23// ../koko-analytics-pro/src/UTM/views/dashboard.php#37
    2424__('Pageviews', 'koko-analytics');
    2525
    26 // ../koko-analytics-pro/src/UTM/views/dashboard.php#52
     26// ../koko-analytics-pro/src/UTM/views/dashboard.php#54
    2727__('There is nothing here. Yet!', 'koko-analytics');
    28 
    29 // ../koko-analytics-pro/src/UTM/views/dashboard.php#61
    30 __('Previous', 'koko-analytics');
    31 
    32 // ../koko-analytics-pro/src/UTM/views/dashboard.php#73
    33 __('Next', 'koko-analytics');
    3428
    3529// ../koko-analytics-pro/src/UTM/views/setting.php#8
    … …  
    165159__('Pageviews', 'koko-analytics');
    166160
    167 // ../koko-analytics-pro/src/Password/views/password-form.php#80
     161// ../koko-analytics-pro/src/Password/views/password-form.php#84
    168162__('Enter password to access the dashboard', 'koko-analytics');
    169163
    170 // ../koko-analytics-pro/src/Password/views/password-form.php#82
    171 __('Incorrect password. Please try again.', 'koko-analytics');
    172 
    173 // ../koko-analytics-pro/src/Password/views/password-form.php#84
     164// ../koko-analytics-pro/src/Password/views/password-form.php#89
    174165__('Password', 'koko-analytics');
    175166
    176 // ../koko-analytics-pro/src/Password/views/password-form.php#85
     167// ../koko-analytics-pro/src/Password/views/password-form.php#90
    177168__('Submit', 'koko-analytics');
    178169
    179 // ../koko-analytics-pro/src/Password/bootstrap.php#55
     170// ../koko-analytics-pro/src/Password/bootstrap.php#42
     171__('Unable to sign in. Check the password and try again.', 'koko-analytics');
     172
     173// ../koko-analytics-pro/src/Password/bootstrap.php#57
    180174__('Dashboard password', 'koko-analytics');
    181175
    182 // ../koko-analytics-pro/src/Password/bootstrap.php#57
     176// ../koko-analytics-pro/src/Password/bootstrap.php#59
    183177__('Set a password to protect your public dashboard. Leave empty for no password protection.', 'koko-analytics');
    184178
    … …  
    234228__('Add event', 'koko-analytics');
    235229
    236 // ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#19
     230// ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#22
    237231__('Event:', 'koko-analytics');
    238232
    239 // ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#20
     233// ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#23
    240234__('Unique', 'koko-analytics');
    241235
    242 // ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#21
     236// ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#24
    243237__('Total', 'koko-analytics');
    244238
    245 // ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#52
     239// ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#55
    246240__('There is nothing here. Yet!', 'koko-analytics');
    247 
    248 // ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#58
    249 __('Previous', 'koko-analytics');
    250 
    251 // ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#61
    252 __('Next', 'koko-analytics');
    253241
    254242// ../koko-analytics-pro/src/Events/views/event-details.php#24
    … …  
    309297__('Back to event settings', 'koko-analytics');
    310298
    311 // ../koko-analytics-pro/src/Events/bootstrap.php#51
     299// ../koko-analytics-pro/src/Events/bootstrap.php#52
    312300__('Events', 'koko-analytics');
    313301
    314 // ../koko-analytics-pro/src/Events/Rest.php#59
     302// ../koko-analytics-pro/src/Events/Rest.php#71
    315303__('start_date must be before or equal to end_date.', 'koko-analytics');
    316304
    317 // ../koko-analytics-pro/src/Events/Rest.php#63
     305// ../koko-analytics-pro/src/Events/Rest.php#75
    318306__('Date range is too large.', 'koko-analytics');
    319307
    … …  
    321309__('Pageviews', 'koko-analytics');
    322310
    323 // ../koko-analytics-pro/src/Devices/views/dashboard.php#39
     311// ../koko-analytics-pro/src/Devices/views/dashboard.php#43
    324312__('There is nothing here. Yet!', 'koko-analytics');
    325 
    326 // ../koko-analytics-pro/src/Devices/views/dashboard.php#45
    327 __('Previous', 'koko-analytics');
    328 
    329 // ../koko-analytics-pro/src/Devices/views/dashboard.php#48
    330 __('Next', 'koko-analytics');
    331313
    332314// ../koko-analytics-pro/src/Devices/views/setting.php#8
    … …  
    378360__('Send periodic email reports?', 'koko-analytics');
    379361
    380 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#18
     362// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#20
    381363__('Daily', 'koko-analytics');
    382364
    383 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#19
     365// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#21
    384366__('Weekly', 'koko-analytics');
    385367
    386 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#20
     368// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#22
    387369__('Monthly', 'koko-analytics');
    388370
    389 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#22
     371// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#24
    390372__('Select the timeframes for which you want to receive a summary of your most important statistics.', 'koko-analytics');
    391373
    392 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#26
     374// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#28
    393375__('Send to these email addresses', 'koko-analytics');
    394376
    395 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#28
     377// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#30
    396378__('Enter a comma separated list of email addresses to which the reports should be sent.', 'koko-analytics');
    397379
    398 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#32
     380// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#34
    399381__('Save Changes', 'koko-analytics');
    400382
    … …  
    402384__('Email reports', 'koko-analytics');
    403385
    404 // ../koko-analytics-pro/src/Geolocation/views/dashboard.php#20
     386// ../koko-analytics-pro/src/Geolocation/views/dashboard.php#21
    405387__('Country', 'koko-analytics');
    406388
    407 // ../koko-analytics-pro/src/Geolocation/views/dashboard.php#21
     389// ../koko-analytics-pro/src/Geolocation/views/dashboard.php#22
    408390__('Count', 'koko-analytics');
    409391
    410 // ../koko-analytics-pro/src/Geolocation/views/dashboard.php#40
     392// ../koko-analytics-pro/src/Geolocation/views/dashboard.php#41
    411393__('There is nothing here. Yet!', 'koko-analytics');
    412 
    413 // ../koko-analytics-pro/src/Geolocation/views/dashboard.php#46
    414 __('Previous', 'koko-analytics');
    415 
    416 // ../koko-analytics-pro/src/Geolocation/views/dashboard.php#49
    417 __('Next', 'koko-analytics');
    418394
    419395// ../koko-analytics-pro/src/Geolocation/views/setting.php#8
  • koko-analytics/tags/2.5.3/src/Resources/functions/functions.php

    r3581336 r3704935  
    5555}
    5656
     57/**
     58 * Returns whether the current request may view the analytics dashboard.
     59 */
     60function can_view_dashboard(?array $settings = null): bool
     61{
     62    if (current_user_can('view_koko_analytics')) {
     63        return true;
     64    }
     65
     66    $settings = $settings ?? get_settings();
     67    if (empty($settings['is_dashboard_public'])) {
     68        return false;
     69    }
     70
     71    return (bool) apply_filters('koko_analytics_can_view_public_dashboard', true, $settings);
     72}
     73
     74/**
     75 * Wrapper around add_query_arg() that returns the URL with its query string in a canonical order.
     76 *
     77 * All dashboard links should be created through this function. Since add_query_arg() keeps the query
     78 * parameters of the current request in whatever order they came in, links pointing at the exact same
     79 * view can otherwise end up with a different query string on every request. Sorting the parameters
     80 * keeps the number of distinct dashboard URLs to a minimum, which helps any cache (browser, page
     81 * cache or CDN) in front of the public dashboard.
     82 *
     83 * Takes the same arguments as add_query_arg(), so a value of null removes that parameter.
     84 *
     85 * @param array<string, mixed> $args
     86 * @param string|null $url URL to add the query args to. Defaults to the URL of the current request.
     87 */
     88function dashboard_url(array $args = [], ?string $url = null): string
     89{
     90    return sort_query_string($url === null ? add_query_arg($args) : add_query_arg($args, $url));
     91}
     92
     93/**
     94 * Sorts the query string of the given URL by parameter name, leaving the rest of the URL untouched.
     95 */
     96function sort_query_string(string $url): string
     97{
     98    $start = strpos($url, '?');
     99    if ($start === false) {
     100        return $url;
     101    }
     102
     103    $query    = substr($url, $start + 1);
     104    $fragment = '';
     105    $hash     = strpos($query, '#');
     106    if ($hash !== false) {
     107        $fragment = substr($query, $hash);
     108        $query    = substr($query, 0, $hash);
     109    }
     110
     111    if ($query === '') {
     112        return $url;
     113    }
     114
     115    $pairs = explode('&', $query);
     116    $names = array_map(function ($pair) {
     117        return explode('=', $pair, 2)[0];
     118    }, $pairs);
     119    $order = array_keys($pairs);
     120
     121    // sort by parameter name, keeping parameters that share a name in their original order
     122    array_multisort($names, SORT_ASC, SORT_STRING, $order, SORT_ASC, SORT_NUMERIC, $pairs);
     123
     124    return substr($url, 0, $start + 1) . implode('&', $pairs) . $fragment;
     125}
     126
    57127function get_most_viewed_post_ids(array $args)
    58128{
  • koko-analytics/tags/2.5.3/src/Resources/views/dashboard-page.php

    r3597329 r3704935  
    33use KokoAnalytics\Chart_View;
    44use KokoAnalytics\Fmt;
     5
     6use function KokoAnalytics\dashboard_url;
    57
    68defined('ABSPATH') || exit;
    … …  
    1416 * @var int $realtime
    1517 * @var string $date_format
    16  * @var string $dashboard_url
    1718 * @var array $next_dates
    1819 * @var array $prev_dates
    … …  
    3536                    — <?php echo esc_html(wp_date($date_format, $date_end->getTimestamp())); ?>
    3637                </div>
    37 
    3838                <div id="ka-datepicker-dropdown" class="rounded bg-white shadow" style="display: none; position: absolute; width:360px; z-index: 9992;">
    39                     <div class="mb-3 bg-dark text-white p-3 rounded-top fw-bold d-flex justify-content-between">
    40                         <?php
    41                         // only output pagination for date ranges between reasonable dates... to prevent ever-crawling bots from going wild
    42                         ?>
    43                         <?php if ($date_start > $total_start_date) { ?>
    44                             <a class="js-quicknav-prev text-decoration-none text-white me-2" href="" data-href="<?= esc_attr(add_query_arg(['start_date' => $prev_dates[0]->format('Y-m-d'), 'end_date' => $prev_dates[1]->format('Y-m-d')], $dashboard_url)); ?>" rel="nofollow">◂</a>
    45                         <?php } else { ?>
    46                             <a class="text-decoration-none text-white me-2">◂</a>
     39                    <?php $show_date_navigation = KokoAnalytics\Dashboard::shows_pagination_links(); ?>
     40                    <div class="mb-3 bg-dark text-white p-3 rounded-top fw-bold d-flex <?= $show_date_navigation ? 'justify-content-between' : 'justify-content-center'; ?>">
     41                        <?php if ($show_date_navigation) { ?>
     42                            <?php
     43                            // only output pagination for date ranges between reasonable dates... to prevent ever-crawling bots from going wild
     44                            // the target URL is assembled in JS from these dates, so no crawlable URL ever appears in the document
     45                            ?>
     46                            <?php if ($date_start > $total_start_date) { ?>
     47                                <a class="js-quicknav-prev text-decoration-none text-white me-2" href="" data-start-date="<?= esc_attr($prev_dates[0]->format('Y-m-d')); ?>" data-end-date="<?= esc_attr($prev_dates[1]->format('Y-m-d')); ?>" rel="nofollow">◂</a>
     48                            <?php } else { ?>
     49                                <a class="text-decoration-none text-white me-2">◂</a>
     50                            <?php } ?>
    4751                        <?php } ?>
    4852                        <span><?php echo esc_html(wp_date($date_format, $date_start->getTimestamp())); ?> — <?= esc_html(wp_date($date_format, $date_end->getTimestamp())); ?></span>
    49                         <?php if ($date_end < $total_end_date) { ?>
    50                             <a class="js-quicknav-next text-decoration-none text-white ms-2" href="" data-href="<?= esc_attr(add_query_arg(['start_date' => $next_dates[0]->format('Y-m-d'), 'end_date' => $next_dates[1]->format('Y-m-d')], $dashboard_url)); ?>" rel="nofollow">▸</a>
    51                         <?php } else { ?>
    52                             <a class="text-decoration-none text-white ms-2">▸</a>
     53                        <?php if ($show_date_navigation) { ?>
     54                            <?php if ($date_end < $total_end_date) { ?>
     55                                <a class="js-quicknav-next text-decoration-none text-white ms-2" href="" data-start-date="<?= esc_attr($next_dates[0]->format('Y-m-d')); ?>" data-end-date="<?= esc_attr($next_dates[1]->format('Y-m-d')); ?>" rel="nofollow">▸</a>
     56                            <?php } else { ?>
     57                                <a class="text-decoration-none text-white ms-2">▸</a>
     58                            <?php } ?>
    5359                        <?php } ?>
    5460                    </div>
    … …  
    98104                <?php $filtered_label = $filtered_post_id > 0 ? get_the_title($filtered_post_id) : $page; ?>
    99105                <a class="" href="<?= esc_url($filtered_permalink); ?>"><?= esc_html($filtered_label); ?></a>
    100                 <a class="text-decoration-none text-reset ms-2" aria-label="<?php esc_attr_e('Clear page filter', 'koko-analytics'); ?>" title="<?php esc_attr_e('Clear page filter', 'koko-analytics'); ?>" href="<?= esc_attr(remove_query_arg('p')); ?>">✕</a>
     106                <a class="text-decoration-none text-reset ms-2" aria-label="<?php esc_attr_e('Clear page filter', 'koko-analytics'); ?>" title="<?php esc_attr_e('Clear page filter', 'koko-analytics'); ?>" href="<?= esc_attr(dashboard_url(['p' => null])); ?>">✕</a>
    101107            </div>
    102108
    … …  
    191197    <?php } ?>
    192198
    193     <?php $can_sort = current_user_can('manage_koko_analytics'); ?>
     199    <?php // only offer drag & drop where the action that stores the new order runs: it is registered from the admin controller, so a save from the public dashboard would silently do nothing ?>
     200    <?php $can_sort = is_admin() && current_user_can('manage_koko_analytics'); ?>
    194201    <div id="ka-components" class="ka-row ka-row-cols-1 ka-row-cols-xl-2 g-3 mb-3 <?= $page !== 0 ? 'page-filter-active' : ''; ?>" <?= $can_sort ? 'data-nonce="' . esc_attr(wp_create_nonce('koko_analytics_save_component_order')) . '"' : ''; ?>>
    195202        <?php foreach ($this->get_components() as $id => $callback) : ?>
  • koko-analytics/tags/2.5.3/src/Resources/views/settings/help.php

    r3581336 r3704935  
    2424<div class="mb-5">
    2525    <ul class="ul-square">
    26         <li><a href="https://www.kokoanalytics.com/pricing/?utm_source=koko-analytics&utm_medium=link&utm_campaign=free-plugin-settings-help">Upgrade to Koko Analytics Pro</a> to get direct email support from the plugin builder.</li>
     26        <?php if (defined('KOKO_ANALYTICS_PRO_VERSION')) : ?>
     27            <?php /* translators: 1: opening anchor tag, 2: closing anchor tag. */ ?>
     28            <li><?php printf(esc_html__('As a Koko Analytics Pro user, you can email us directly at %1$ssupport@kokoanalytics.com%2$s for help.', 'koko-analytics'), '<a href="mailto:support@kokoanalytics.com">', '</a>'); ?></li>
     29        <?php else : ?>
     30            <li><a href="https://www.kokoanalytics.com/pricing/?utm_source=koko-analytics&utm_medium=link&utm_campaign=free-plugin-settings-help">Upgrade to Koko Analytics Pro</a> to get direct email support from the plugin builder.</li>
     31        <?php endif; ?>
     32
    2733        <?php /* translators: 1: opening anchor tag, 2: closing anchor tag. */ ?>
    2834        <li><?php printf(esc_html__('Have a look at our %1$sknowledge base%2$s for help with configuring and using Koko Analytics.', 'koko-analytics'), '<a href="https://www.kokoanalytics.com/docs/#utm_source=koko-analytics&amp;utm_medium=link&amp;utm_campaign=free-plugin-settings-help-docs" target="_blank">', '</a>'); ?></li>
  • koko-analytics/tags/2.5.3/src/Rest.php

    r3581336 r3704935  
    113113    public function permission_callback(): bool
    114114    {
    115         $settings            = get_settings();
    116         $is_dashboard_public = $settings['is_dashboard_public'];
    117         return $is_dashboard_public || current_user_can('view_koko_analytics');
     115        return can_view_dashboard();
    118116    }
    119117
  • koko-analytics/tags/2.5.3/src/Upserter.php

    r3581336 r3704935  
    77class Upserter
    88{
     9    private const DATABASE_BATCH_SIZE = 500;
     10
    911    protected wpdb $db;
    1012    protected string $table;
    … …  
    2325    /**
    2426     * @param array<string> $values
    25      * @return array<string, int> Map of value to ID in database
     27     * @return array<array-key, int> Map of value to ID in database
    2628     */
    2729    public function upsert(array $values): array
    … …  
    3537        $values = array_unique($values);
    3638
    37         // INSERT IGNORE all deduplicated values into the database table
    38         $placeholders = rtrim(str_repeat('(%s),', count($values)), ',');
    39         $this->db->query($this->db->prepare("INSERT IGNORE INTO {$this->table}({$this->column}) VALUES {$placeholders}", $values));
     39        $ids = [];
     40        foreach (array_chunk($values, self::DATABASE_BATCH_SIZE) as $batch) {
     41            // INSERT IGNORE all deduplicated values into the database table
     42            $placeholders = rtrim(str_repeat('(%s),', count($batch)), ',');
     43            $this->db->query($this->db->prepare("INSERT IGNORE INTO {$this->table}({$this->column}) VALUES {$placeholders}", $batch));
    4044
    41         // retrieve all entries from the database table to get their normalized ID's
    42         $placeholders = rtrim(str_repeat('%s,', count($values)), ',');
    43         $results      = $this->db->get_results($this->db->prepare("SELECT id, {$this->column} FROM {$this->table} WHERE {$this->column} IN({$placeholders})", $values));
     45            // retrieve all entries from the database table to get their normalized ID's
     46            $placeholders = rtrim(str_repeat('%s,', count($batch)), ',');
     47            $results      = $this->db->get_results($this->db->prepare("SELECT id, {$this->column} FROM {$this->table} WHERE {$this->column} IN({$placeholders})", $batch));
     48            $ids          = array_replace($ids, array_column($results, 'id', $this->column));
     49        }
    4450
    45         return array_column($results, 'id', $this->column);
     51        return $ids;
    4652    }
    4753}
  • koko-analytics/trunk/CHANGELOG.md

    r3634028 r3704935  
    11# Changelog
     2
     3= 2.5.3 =
     4
     5- security: enforce the same access checks for public dashboard pages and REST API requests, fixing bypasses of Pro password protection.
     6- dashboard: reduce crawler and cache load by only accepting read requests, canonicalizing query parameters, and hiding pagination and date navigation on public dashboards.
     7- dashboard: only enable component sorting in the admin dashboard, where changes can be saved.
     8- perf: process aggregation buffers in chunks to reduce peak memory usage on busy sites.
     9- docs: show the dedicated support address to Pro users on the help page.
     10
    211
    312= 2.5.2 =
    … …  
    716- data: increase the aggregation interval to 65 seconds to reduce the frequency of scheduling conflicts.
    817- ux: show admin notice with location of analytics dashboard after initial plugin activation.
     18
    919
    1020= 2.5.1 =
  • koko-analytics/trunk/assets/css/dashboard.css

    r3605984 r3704935  
    244244}
    245245.page-filter-active tr,
    246 .page-filter-active .ka-pagination  {
     246.page-filter-active .ka-pagination2  {
    247247  opacity: 0.5;
    248248}
    … …  
    587587
    588588/* pagination */
    589 
    590 /* old style pagination, used in Pro components */
    591 .ka-pagination {
    592   display: flex;
    593   padding: 0.5rem 1rem;
    594   border-top: 1px solid rgb(240, 240, 240);
    595 }
    596 .ka-pagination--next {
    597   margin-left: auto;
    598 }
    599589
    600590/* new style pagination (2026-07-03) */
  • koko-analytics/trunk/assets/js/dashboard.js

    r3548865 r3704935  
    1 (()=>{function l(t){this.root=t,this.barWidth=0,this.closeTooltipTimeout=null,this.root&&(this.tooltip=document.querySelector(".ka-chart--tooltip"),this.tooltip.remove(),document.body.appendChild(this.tooltip),[this.root,this.tooltip].forEach(i=>{i.addEventListener("mouseleave",this.closeTooltip.bind(this)),i.addEventListener("mouseenter",this.keepTooltip.bind(this))}),this.root.addEventListener("mouseover",i=>{i.target.tagName==="rect"&&this.showTooltip(i.target)}),this.redraw())}l.prototype.closeTooltip=function(){this.closeTooltipTimeout=window.setTimeout(()=>{this.tooltip.style.display="none"},20)};l.prototype.keepTooltip=function(){window.clearTimeout(this.closeTooltipTimeout)};l.prototype.showTooltip=function(t){let i=t.parentElement.dataset;this.tooltip.querySelector(".ka-chart--tooltip-heading").textContent=i.date,this.tooltip.querySelector(".ka--visitors").children[0].textContent=i.visitors,this.tooltip.querySelector(".ka--pageviews").children[0].textContent=i.pageviews;let o=document.body.clientWidth;this.tooltip.style.display="block";let e=t.parentElement.getBoundingClientRect(),r=Math.round(e.left-.5*this.tooltip.clientWidth+.5*this.barWidth),a=Math.round(e.top+window.scrollY-this.tooltip.clientHeight),s=0;r<12?s=-r+12:r+this.tooltip.clientWidth>o-12&&(s=o-(r+this.tooltip.clientWidth)-12),r+=s;let y=this.tooltip.querySelector(".ka-chart--tooltip-arrow");y.style.marginLeft=s===0?"auto":.5*this.tooltip.clientWidth-6-s+"px",this.tooltip.style.left=r+"px",this.tooltip.style.top=a+"px"};l.prototype.redraw=function(){let t=Array.from(this.root.querySelectorAll(".axes-y text")).reduce((e,r)=>Math.max(e,8+Math.max(5,r.textContent.length*8)),0),i=this.root.querySelectorAll(".bars g"),o=Math.max(1,(this.root.clientWidth-t)/i.length);this.barWidth=Math.max(1,o-2);for(let e=0;e<i.length;e++){let r=e*o+t+1,a=i[e].children;a[0].setAttribute("x",r),a[0].setAttribute("width",this.barWidth),a[1].setAttribute("x",r),a[1].setAttribute("width",this.barWidth);let s=e*o+t+.5*o;a[2].style.display=this.barWidth===1?"none":"",a[2].setAttribute("x1",s),a[2].setAttribute("x2",s)}};var n=document.getElementById("ka-components");if(n&&n.querySelector(".ka-col[data-sortable]")){let t=null,i=n.querySelectorAll(".ka-col[data-sortable]");n.addEventListener("mousedown",function(o){if(o.target.closest("th")){let e=o.target.closest(".ka-col");e&&(e.draggable=!0)}}),document.addEventListener("mouseup",function(){i.forEach(o=>{o.draggable=!1})}),n.addEventListener("dragstart",function(o){t=o.target.closest(".ka-col"),t&&(t.classList.add("ka-dragging"),o.dataTransfer.effectAllowed="move")}),n.addEventListener("dragover",function(o){o.preventDefault(),o.dataTransfer.dropEffect="move";let e=o.target.closest(".ka-col");!e||e===t||(t.compareDocumentPosition(e)&Node.DOCUMENT_POSITION_FOLLOWING?n.insertBefore(t,e.nextSibling):n.insertBefore(t,e))}),n.addEventListener("dragend",function(){if(!t)return;t.classList.remove("ka-dragging"),t=null,i.forEach(function(r){r.draggable=!1});let o=Array.from(n.querySelectorAll(".ka-col[data-sortable]")).map(r=>r.id),e=new FormData;e.append("koko_analytics_action","save_component_order"),e.append("_nonce",n.dataset.nonce),o.forEach(r=>{e.append("component_order[]",r)}),window.fetch(window.location.href,{method:"POST",body:e})})}var d=window.setTimeout(c,61e3);function c(){document.hidden?d=window.setTimeout(c,61e3):window.location.reload()}document.addEventListener("mouseover",function(){window.clearTimeout(d),d=window.setTimeout(c,61e3)});var u=document.querySelector("#ka-date-presets"),h=document.querySelector("#ka-date-start"),p=document.querySelector("#ka-date-end");u&&u.addEventListener("change",function(){h.disabled=!0,p.disabled=!0,this.form.submit()});function f(){u.value="custom"}h&&h.addEventListener("change",f);p&&p.addEventListener("change",f);document.addEventListener("keydown",function(t){if(!t.defaultPrevented)switch(t.key){case"ArrowLeft":document.querySelector(".js-quicknav-prev").click();break;case"ArrowRight":document.querySelector(".js-quicknav-next").click();break}});document.querySelectorAll("a[data-href]").forEach(function(t){t.addEventListener("click",function(i){i.preventDefault(),window.location.href=t.getAttribute("data-href")})});document.addEventListener("DOMContentLoaded",()=>{new l(document.getElementById("ka-chart"))});function w(){sessionStorage.setItem("scrollX",window.pageXOffset),sessionStorage.setItem("scrollY",window.pageYOffset)}document.addEventListener("click",w);window.addEventListener("beforeunload",w);var m=parseInt(sessionStorage.getItem("scrollX")??0),g=parseInt(sessionStorage.getItem("scrollY")??0);(m!=0||g!=0)&&window.scroll(m,g);})();
     1(()=>{function d(t){this.root=t,this.barWidth=0,this.closeTooltipTimeout=null,this.root&&(this.tooltip=document.querySelector(".ka-chart--tooltip"),this.tooltip.remove(),document.body.appendChild(this.tooltip),[this.root,this.tooltip].forEach(r=>{r.addEventListener("mouseleave",this.closeTooltip.bind(this)),r.addEventListener("mouseenter",this.keepTooltip.bind(this))}),this.root.addEventListener("mouseover",r=>{r.target.tagName==="rect"&&this.showTooltip(r.target)}),this.redraw())}d.prototype.closeTooltip=function(){this.closeTooltipTimeout=window.setTimeout(()=>{this.tooltip.style.display="none"},20)};d.prototype.keepTooltip=function(){window.clearTimeout(this.closeTooltipTimeout)};d.prototype.showTooltip=function(t){let r=t.parentElement.dataset;this.tooltip.querySelector(".ka-chart--tooltip-heading").textContent=r.date,this.tooltip.querySelector(".ka--visitors").children[0].textContent=r.visitors,this.tooltip.querySelector(".ka--pageviews").children[0].textContent=r.pageviews;let o=document.body.clientWidth;this.tooltip.style.display="block";let e=t.parentElement.getBoundingClientRect(),i=Math.round(e.left-.5*this.tooltip.clientWidth+.5*this.barWidth),a=Math.round(e.top+window.scrollY-this.tooltip.clientHeight),s=0;i<12?s=-i+12:i+this.tooltip.clientWidth>o-12&&(s=o-(i+this.tooltip.clientWidth)-12),i+=s;let b=this.tooltip.querySelector(".ka-chart--tooltip-arrow");b.style.marginLeft=s===0?"auto":.5*this.tooltip.clientWidth-6-s+"px",this.tooltip.style.left=i+"px",this.tooltip.style.top=a+"px"};d.prototype.redraw=function(){let t=Array.from(this.root.querySelectorAll(".axes-y text")).reduce((e,i)=>Math.max(e,8+Math.max(5,i.textContent.length*8)),0),r=this.root.querySelectorAll(".bars g"),o=Math.max(1,(this.root.clientWidth-t)/r.length);this.barWidth=Math.max(1,o-2);for(let e=0;e<r.length;e++){let i=e*o+t+1,a=r[e].children;a[0].setAttribute("x",i),a[0].setAttribute("width",this.barWidth),a[1].setAttribute("x",i),a[1].setAttribute("width",this.barWidth);let s=e*o+t+.5*o;a[2].style.display=this.barWidth===1?"none":"",a[2].setAttribute("x1",s),a[2].setAttribute("x2",s)}};var n=document.getElementById("ka-components");if(n&&n.querySelector(".ka-col[data-sortable]")){let t=null,r=n.querySelectorAll(".ka-col[data-sortable]");n.addEventListener("mousedown",function(o){if(o.target.closest("th")){let e=o.target.closest(".ka-col");e&&(e.draggable=!0)}}),document.addEventListener("mouseup",function(){r.forEach(o=>{o.draggable=!1})}),n.addEventListener("dragstart",function(o){t=o.target.closest(".ka-col"),t&&(t.classList.add("ka-dragging"),o.dataTransfer.effectAllowed="move")}),n.addEventListener("dragover",function(o){o.preventDefault(),o.dataTransfer.dropEffect="move";let e=o.target.closest(".ka-col");!e||e===t||(t.compareDocumentPosition(e)&Node.DOCUMENT_POSITION_FOLLOWING?n.insertBefore(t,e.nextSibling):n.insertBefore(t,e))}),n.addEventListener("dragend",function(){if(!t)return;t.classList.remove("ka-dragging"),t=null,r.forEach(function(i){i.draggable=!1});let o=Array.from(n.querySelectorAll(".ka-col[data-sortable]")).map(i=>i.id),e=new FormData;e.append("koko_analytics_action","save_component_order"),e.append("_nonce",n.dataset.nonce),o.forEach(i=>{e.append("component_order[]",i)}),window.fetch(window.location.href,{method:"POST",body:e})})}var l=window.setTimeout(c,61e3);function c(){document.hidden?l=window.setTimeout(c,61e3):window.location.reload()}document.addEventListener("mouseover",function(){window.clearTimeout(l),l=window.setTimeout(c,61e3)});function m(t){var r=new URL(window.location.href),o=new URLSearchParams(new FormData(t));o.sort(),r.search=o.toString(),window.location.href=r.toString()}var f=document.querySelector("#ka-datepicker-dropdown form");f&&f.addEventListener("submit",function(t){t.preventDefault(),m(this)});var u=document.querySelector("#ka-date-presets"),h=document.querySelector("#ka-date-start"),p=document.querySelector("#ka-date-end");u&&u.addEventListener("change",function(){h.disabled=!0,p.disabled=!0,m(this.form)});function w(){u.value="custom"}h&&h.addEventListener("change",w);p&&p.addEventListener("change",w);document.addEventListener("keydown",function(t){if(!t.defaultPrevented){var r;switch(t.key){case"ArrowLeft":r=document.querySelector(".js-quicknav-prev");break;case"ArrowRight":r=document.querySelector(".js-quicknav-next");break}r&&r.click()}});document.querySelectorAll("a[data-start-date][data-end-date]").forEach(function(t){t.addEventListener("click",function(r){r.preventDefault();var o=new URL(window.location.href),e=new URLSearchParams;["page","koko-analytics-dashboard"].forEach(function(i){o.searchParams.has(i)&&e.set(i,o.searchParams.get(i))}),e.set("start_date",t.getAttribute("data-start-date")),e.set("end_date",t.getAttribute("data-end-date")),e.sort(),o.search=e.toString(),window.location.href=o.toString()})});document.addEventListener("DOMContentLoaded",()=>{new d(document.getElementById("ka-chart"))});function y(){sessionStorage.setItem("scrollX",window.pageXOffset),sessionStorage.setItem("scrollY",window.pageYOffset)}document.addEventListener("click",y);window.addEventListener("beforeunload",y);var g=parseInt(sessionStorage.getItem("scrollX")??0),v=parseInt(sessionStorage.getItem("scrollY")??0);(g!=0||v!=0)&&window.scroll(g,v);})();
  • koko-analytics/trunk/assets/js/src/imports/datepicker.js

    r3500426 r3704935  
     1
     2// navigate to the date range form its target URL ourselves, so that we can sort the query string
     3// keeping the query args in a fixed order means the same view always maps to the same URL, which is
     4// friendlier to any cache sitting in front of the (public) dashboard
     5function submitDateForm(form) {
     6  var url = new URL(window.location.href);
     7  var params = new URLSearchParams(new FormData(form));
     8  params.sort();
     9  url.search = params.toString();
     10  window.location.href = url.toString();
     11}
     12
     13var dateForm = document.querySelector('#ka-datepicker-dropdown form');
     14dateForm && dateForm.addEventListener('submit', function(evt) {
     15  evt.preventDefault();
     16  submitDateForm(this);
     17});
    118
    219// update date_start and date_end <input>'s whenever a preset is selected
    … …  
    724  dateStartInput.disabled = true;
    825  dateEndInput.disabled = true;
    9   this.form.submit();
     26  submitDateForm(this.form);
    1027});
    1128
    … …  
    1936
    2037// click "prev date range" or "next date range" when using arrow keys
     38// note these links are not always there, eg on the public dashboard or at the edges of the available date range
    2139document.addEventListener('keydown', function (evt) {
    2240  if (evt.defaultPrevented) {
    … …  
    2442  }
    2543
     44  var quicknav;
    2645  switch (evt.key) {
    2746  case 'ArrowLeft':
    28     document.querySelector('.js-quicknav-prev').click();
     47    quicknav = document.querySelector('.js-quicknav-prev');
    2948    break;
    3049  case 'ArrowRight':
    31     document.querySelector('.js-quicknav-next').click();
     50    quicknav = document.querySelector('.js-quicknav-next');
    3251    break;
    3352  }
     53
     54  quicknav && quicknav.click();
    3455})
    3556
    3657// fake <a> elements to stop bots from crawling infinitely
    37 document.querySelectorAll('a[data-href]').forEach(function(el) {
     58// the HTML only carries the target dates, we assemble the URL here so the document holds no crawlable link
     59// note we keep just the query args identifying the dashboard itself, dropping any active filter or pagination
     60document.querySelectorAll('a[data-start-date][data-end-date]').forEach(function(el) {
    3861  el.addEventListener('click', function(evt) {
    3962    evt.preventDefault();
    40     window.location.href = el.getAttribute('data-href');
     63
     64    var url = new URL(window.location.href);
     65    var params = new URLSearchParams();
     66    ['page', 'koko-analytics-dashboard'].forEach(function(key) {
     67      if (url.searchParams.has(key)) {
     68        params.set(key, url.searchParams.get(key));
     69      }
     70    });
     71    params.set('start_date', el.getAttribute('data-start-date'));
     72    params.set('end_date', el.getAttribute('data-end-date'));
     73    params.sort();
     74    url.search = params.toString();
     75    window.location.href = url.toString();
    4176  });
    4277});
  • koko-analytics/trunk/data/referrer-blocklist

    r3597329 r3704935  
    489489concretepol.com
    490490connectikastudio.com
     491considerin.net
    491492constanceonline.top
    492493cookie-law-enforcement-aa.xyz
    … …  
    14901491oracle-patches.ru
    14911492orakul.spb.ru
     1493osdzc.com
    14921494osteochondrosis.ru
    14931495otdbiaxaem-vmeste.ru
    … …  
    16541656rcb101.ru
    16551657rczhan.com
     1658readlife.net
    16561659realresultslist.com
    16571660recinziireale.com
    … …  
    19391942suckoutlet.xyz
    19401943sudachitravel.com
     1944sunblog.asia
    19411945sundrugstore.com
    19421946super-seo-guru.com
    … …  
    23332337zelena-mriya.com.ua
    23342338zexhgoods.xyz
     2339zeyao.net
    23352340zhcsapp.net
    23362341zhoobintravel.com
  • koko-analytics/trunk/koko-analytics.php

    r3634028 r3704935  
    44Plugin Name: Koko Analytics
    55Plugin URI: https://www.kokoanalytics.com/#utm_source=koko-analytics&utm_medium=link&utm_campaign=free-plugin-plugin-header
    6 Version: 2.5.2
     6Version: 2.5.3
    77Description: Privacy-friendly and efficient statistics for your WordPress site.
    88Author: ibericode
    … …  
    4343PHP_VERSION_ID >= 70400 || exit;
    4444
    45 define('KOKO_ANALYTICS_VERSION', '2.5.2');
     45define('KOKO_ANALYTICS_VERSION', '2.5.3');
    4646define('KOKO_ANALYTICS_PLUGIN_FILE', __FILE__);
    4747define('KOKO_ANALYTICS_PLUGIN_DIR', __DIR__);
  • koko-analytics/trunk/readme.txt

    r3674437 r3704935  
    33Tags: analytics, google analytics, privacy, statistics, website statistics
    44Requires at least: 6.2
    5 Tested up to: 7.1
    6 Stable tag: 2.5.2
     5Tested up to: 7.0
     6Stable tag: 2.5.3
    77License: GPL-3.0-or-later
    88License URI: https://www.gnu.org/licenses/gpl-3.0.html
    … …  
    1414
    1515Koko Analytics is a privacy-friendly and lightweight Google Analytics alternative for WordPress sites. It is a plug and play solution to get GDPR compliant insights into your website visitors without requiring any third-party services.
    16 
    17 [youtube https://www.youtube.com/watch?v=4o0Rp3F4qh8]
    1816
    1917== Why choose Koko Analytics? ==
    … …  
    126124== Changelog ==
    127125
     126= 2.5.3 =
     127
     128- security: enforce the same access checks for public dashboard pages and REST API requests, fixing bypasses of Pro password protection.
     129- dashboard: reduce crawler and cache load by only accepting read requests, canonicalizing query parameters, and hiding pagination and date navigation on public dashboards.
     130- dashboard: only enable component sorting in the admin dashboard, where changes can be saved.
     131- perf: process aggregation buffers in chunks to reduce peak memory usage on busy sites.
     132- docs: show the dedicated support address to Pro users on the help page.
     133
     134
    128135= 2.5.2 =
    129136
    … …  
    132139- data: increase the aggregation interval to 65 seconds to reduce the frequency of scheduling conflicts.
    133140- ux: show admin notice with location of analytics dashboard after initial plugin activation.
     141
    134142
    135143= 2.5.1 =
    … …  
    156164- compatibility: raise the minimum supported WordPress version to 6.2.
    157165
    158 
    159 = 2.4.0 =
    160 
    161 - tracking: hook into the visibilitychange event again to ignore prerender requests.
    162 - data: rewrite exporter and importer to use NDJSON instead of raw SQL.
    163 - import: fix column names for the referrer stats table.
    164 - rest: clamp date range for unauthenticated users to prevent large table scans.
    165 - review notice: simplify the review notice to one primary action.
    166 
    167166[View the full changelog on GitHub](https://github.com/ibericode/koko-analytics/blob/main/CHANGELOG.md)
  • koko-analytics/trunk/src/Aggregator.php

    r3581336 r3704935  
    1313class Aggregator
    1414{
     15    private const CHUNK_SIZE = 10000;
     16
    1517    /**
    16      * Reads the buffer file into memory and moves data into the MySQL database (in bulk)
     18     * Reads the buffer file in chunks and moves data into the MySQL database.
    1719     *
    1820     * @throws Exception
    … …  
    2931        // init pageview aggregator
    3032        $pageview_aggregator = new Pageview_Aggregator();
     33        $records_in_chunk    = 0;
    3134
    3235        // rename file to temporary location so nothing new is written to it while we process it
    … …  
    7174            // add-on aggregators
    7275            do_action('koko_analytics_aggregate_line', $type, $params);
     76
     77            ++$records_in_chunk;
     78            if ($records_in_chunk === self::CHUNK_SIZE) {
     79                $this->flush($pageview_aggregator);
     80                $records_in_chunk = 0;
     81            }
    7382        }
     83
     84        if ($records_in_chunk > 0) {
     85            $this->flush($pageview_aggregator);
     86        }
     87
     88        // Finalize state that remains bounded across chunks, such as realtime counts.
     89        $pageview_aggregator->finish();
    7490
    7591        // close file & remove it from filesystem
    … …  
    7793        \unlink($tmp_filename);
    7894
    79         // tell aggregators to write their results to the database
    80         $pageview_aggregator->finish();
     95        // signal that the entire buffer file was processed
    8196        do_action('koko_analytics_aggregate_finish');
    8297
    8398        update_option('koko_analytics_last_aggregation_at', \time(), false);
    8499    }
     100
     101    private function flush(Pageview_Aggregator $pageview_aggregator): void
     102    {
     103        $pageview_aggregator->finish(false);
     104
     105        // Allow add-on aggregators to persist and reset their in-memory state.
     106        do_action('koko_analytics_aggregate_chunk_finish');
     107    }
    85108}
  • koko-analytics/trunk/src/Chart_View.php

    r3597329 r3704935  
    4444                <div class="ka-chart-group">
    4545                    <?php foreach ($group_options as $key => $option) : ?>
    46                         <a href="<?= esc_attr(add_query_arg(['group' => $key])); ?>" class="<?php echo $group === $key ? 'on' : ''; ?>" rel="nofollow"><?php echo esc_html($option['label']); ?></a>
     46                        <a href="<?= esc_attr(dashboard_url(['group' => $key])); ?>" class="<?php echo $group === $key ? 'on' : ''; ?>" rel="nofollow"><?php echo esc_html($option['label']); ?></a>
    4747                    <?php endforeach; ?>
    4848                </div>
  • koko-analytics/trunk/src/Dashboard.php

    r3597329 r3704935  
    1717    public const MAX_OFFSET = 10000;
    1818
     19    private static bool $show_pagination_links = true;
     20
    1921    public static function clamp_limit($value, int $default = 10, int $minimum = 1): int
    2022    {
    … …  
    3941        $items_per_page = (int) apply_filters('koko_analytics_items_per_page', 20);
    4042        $date_format    = get_option('date_format', 'Y-m-d');
    41         $dashboard_url  = $this->get_base_url();
    4243
    4344        // parse query params
    … …  
    272273    }
    273274
    274     public function pagination(string $key, int $offset, int $limit, int $count): void
     275    /**
     276     * Hides the previous/next links in the datepicker and in every pagination component rendered from here on.
     277     */
     278    public static function hide_pagination_links(): void
     279    {
     280        self::$show_pagination_links = false;
     281    }
     282
     283    /**
     284     * Whether previous/next links should be rendered at all.
     285     */
     286    public static function shows_pagination_links(): bool
     287    {
     288        return self::$show_pagination_links;
     289    }
     290
     291    /**
     292     * Renders the pagination component for a dashboard component.
     293     *
     294     * @param string $key        The query string key holding the offset & limit for this component.
     295     * @param array  $extra_args Additional query args to preserve in the previous/next links.
     296     */
     297    public static function pagination(string $key, int $offset, int $limit, int $count, array $extra_args = []): void
    275298    {
    276299        if ($offset >= $limit || $offset + $limit < $count) {
    … …  
    283306                ?>
    284307            </span>
     308            <?php if (self::$show_pagination_links) : ?>
    285309            <span>
    286310                <?php if ($offset >= $limit) : ?>
    287                     <a  href="<?php echo esc_attr(add_query_arg(['p' => null, $key => $offset >= $limit * 2 ? ['offset' => $offset - $limit, 'limit' => $limit] : null ])); ?>" rel="nofollow">← <?php esc_html_e('Previous', 'koko-analytics'); ?></a>
     311                    <a  href="<?php echo esc_attr(dashboard_url(['p' => null, $key => $offset >= $limit * 2 ? array_merge($extra_args, ['offset' => $offset - $limit, 'limit' => $limit]) : ($extra_args ?: null) ])); ?>" rel="nofollow">← <?php esc_html_e('Previous', 'koko-analytics'); ?></a>
    288312                <?php else : ?>
    289313                    <span class="ka-pagination2-muted">← <?php esc_html_e('Previous', 'koko-analytics'); ?></span>
    … …  
    291315                <span> · </span>
    292316                <?php if ($offset + $limit < $count) : ?>
    293                 <a  href="<?php echo esc_attr(add_query_arg(['p' => null, $key => ['offset' => $offset + $limit, 'limit' => $limit]])); ?>" rel="nofollow"><?php esc_html_e('Next', 'koko-analytics'); ?> →</a>
     317                <a  href="<?php echo esc_attr(dashboard_url(['p' => null, $key => array_merge($extra_args, ['offset' => $offset + $limit, 'limit' => $limit])])); ?>" rel="nofollow"><?php esc_html_e('Next', 'koko-analytics'); ?> →</a>
    294318                <?php else : ?>
    295319                    <span class="ka-pagination2-muted"><?php esc_html_e('Next', 'koko-analytics'); ?> →</span>
    296320                <?php endif; ?>
    297321            </span>
     322            <?php endif; ?>
    298323        </div>
    299324            <?php
    … …  
    340365                        <td class="text-muted"><?= esc_html((string) ($offset + $i + 1)); ?></td>
    341366                        <td class="text-truncate">
    342                             <a href="<?= esc_attr(add_query_arg(['p' => $page_filter])); ?>"><?= esc_html($p->label); ?></a>
     367                            <a href="<?= esc_attr(dashboard_url(['p' => $page_filter])); ?>"><?= esc_html($p->label); ?></a>
    343368                            <a class="ka-visit-link" href="<?= esc_url($p->post_permalink); ?>" target="_blank" rel="noopener" title="<?php esc_attr_e('View page', 'koko-analytics'); ?>"><i class="icon icon-sm icon-external-link" aria-hidden="true"></i></a>
    344369                        </td>
    … …  
    354379        <?php } ?>
    355380
    356         <?php $this->pagination('posts', $offset, $limit, $count); ?>
     381        <?php self::pagination('posts', $offset, $limit, $count); ?>
    357382           
    358383        <?php
    … …  
    406431        <?php } ?>
    407432
    408         <?php $this->pagination('referrers', $offset, $limit, $count); ?>
     433        <?php self::pagination('referrers', $offset, $limit, $count); ?>
    409434       
    410435        <?php
  • koko-analytics/trunk/src/Dashboard_Public.php

    r3581336 r3704935  
    2020        $settings = get_settings();
    2121        $today    = (new DateTimeImmutable('now', wp_timezone()))->format('Y-m-d');
    22         if (!$settings['is_dashboard_public'] && !current_user_can('view_koko_analytics')) {
    23             return;
     22
     23        // this dashboard is a read-only view, so only ever answer GET (and HEAD) requests
     24        // any other method is uncacheable, and would be a way to force a full render past every cache
     25        $method = wp_unslash($_SERVER['REQUEST_METHOD'] ?? 'GET');
     26        if ($method !== 'GET' && $method !== 'HEAD') {
     27            status_header(405);
     28            header('Allow: GET, HEAD');
     29            exit;
    2430        }
    2531
    … …  
    3238        }
    3339
     40        // redirect to the canonical URL for this view, so that visitors arriving through a link that
     41        // was shared or crawled hit the same cache entry as everyone else
     42        $request_uri = wp_unslash($_SERVER['REQUEST_URI'] ?? '');
     43        $canonical   = sort_query_string($request_uri);
     44        if ($canonical !== $request_uri) {
     45            wp_safe_redirect($canonical, 301);
     46            exit;
     47        }
     48
     49        if (! can_view_dashboard($settings)) {
     50            do_action('koko_analytics_public_dashboard_access_denied', $settings);
     51            return;
     52        }
     53
     54        // the public dashboard is crawlable, so don't hand out paginated URLs for bots to follow
     55        self::hide_pagination_links();
     56
    3457        do_action('koko_analytics_public_dashboard_headers');
    3558
    … …  
    3760        header("X-Robots-Tag: noindex, nofollow");
    3861        if (is_user_logged_in()) {
    39             header("Cache-Control: no-store, must-revalidate, no-cache, max-age=0, private");
     62            $cache_control = 'no-store, must-revalidate, no-cache, max-age=0, private';
    4063        } elseif (isset($_GET['end_date'], $_GET['start_date']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', wp_unslash($_GET['start_date'])) && preg_match('/^\d{4}-\d{2}-\d{2}$/', wp_unslash($_GET['end_date'])) && wp_unslash($_GET['end_date']) < $today) {
    41             header("Cache-Control: public, max-age=68400");
     64            $cache_control = 'public, max-age=68400';
    4265        } else {
    43             header("Cache-Control: public, max-age=60");
     66            $cache_control = 'public, max-age=60';
    4467        }
     68        $cache_control = (string) apply_filters('koko_analytics_public_dashboard_cache_control', $cache_control, $settings);
     69        header("Cache-Control: {$cache_control}");
    4570
    4671        require KOKO_ANALYTICS_PLUGIN_DIR . '/src/Resources/views/dashboard-public.php';
  • koko-analytics/trunk/src/Pageview_Aggregator.php

    r3581336 r3704935  
    1717class Pageview_Aggregator
    1818{
     19    private const DATABASE_BATCH_SIZE = 500;
     20
    1921    protected wpdb $db;
    2022    protected DateTimeZone $timezone;
    … …  
    8486    }
    8587
    86     public function finish(): void
     88    public function finish(bool $update_realtime = true): void
    8789    {
    8890        $this->commit_site_stats();
    8991        $this->commit_post_stats();
    9092        $this->commit_referrer_stats();
    91         $this->update_realtime_pageview_count();
     93        if ($update_realtime) {
     94            $this->update_realtime_pageview_count();
     95        }
    9296    }
    9397
    … …  
    103107    private function commit_post_stats(): void
    104108    {
    105         $upserter = new Upserter('paths', 'path');
     109        $upserter = new Upserter('paths', 'path', $this->db);
    106110
    107111        // insert page-specific stats
    108112        foreach ($this->post_stats as $date => $stats) {
    109             $path_ids = $upserter->upsert(array_keys($stats));
    110             $values   = [];
    111             foreach ($stats as $path => $r) {
    112                 array_push($values, $date, $path_ids[$path], $r->post_id, $r->visitors, $r->pageviews);
     113            foreach (array_chunk($stats, self::DATABASE_BATCH_SIZE, true) as $batch) {
     114                $path_ids = $upserter->upsert(array_keys($batch));
     115                $values   = [];
     116                foreach ($batch as $path => $r) {
     117                    array_push($values, $date, $path_ids[$path], $r->post_id, $r->visitors, $r->pageviews);
     118                }
     119                $placeholders = rtrim(str_repeat('(%s,%d,%d,%d,%d),', count($batch)), ',');
     120                $this->db->query($this->db->prepare("INSERT INTO {$this->db->prefix}koko_analytics_post_stats(date, path_id, post_id, visitors, pageviews) VALUES {$placeholders} ON DUPLICATE KEY UPDATE visitors = visitors + VALUES(visitors), pageviews = pageviews + VALUES(pageviews)", $values));
    113121            }
    114             $placeholders = rtrim(str_repeat('(%s,%d,%d,%d,%d),', count($stats)), ',');
    115             $this->db->query($this->db->prepare("INSERT INTO {$this->db->prefix}koko_analytics_post_stats(date, path_id, post_id, visitors, pageviews) VALUES {$placeholders} ON DUPLICATE KEY UPDATE visitors = visitors + VALUES(visitors), pageviews = pageviews + VALUES(pageviews)", $values));
    116122        }
    117123
    … …  
    121127    private function commit_referrer_stats(): void
    122128    {
    123         $upserter = new Upserter('referrer_labels', 'value');
     129        $upserter = new Upserter('referrer_labels', 'value', $this->db);
    124130
    125131        // insert referrer stats
    126132        foreach ($this->referrer_stats as $date => $stats) {
    127             $referrer_ids = $upserter->upsert(array_keys($stats));
    128             $values       = [];
    129             foreach ($stats as $url => $r) {
    130                 array_push($values, $date, $referrer_ids[$url], $r->visitors, $r->pageviews);
     133            foreach (array_chunk($stats, self::DATABASE_BATCH_SIZE, true) as $batch) {
     134                $referrer_ids = $upserter->upsert(array_keys($batch));
     135                $values       = [];
     136                foreach ($batch as $url => $r) {
     137                    array_push($values, $date, $referrer_ids[$url], $r->visitors, $r->pageviews);
     138                }
     139                $placeholders = rtrim(str_repeat('(%s,%d,%d,%d),', count($batch)), ',');
     140                $this->db->query($this->db->prepare("INSERT INTO {$this->db->prefix}koko_analytics_referrer_stats(date, id, unique_hits, hits) VALUES {$placeholders} ON DUPLICATE KEY UPDATE unique_hits = unique_hits + VALUES(unique_hits), hits = hits + VALUES(hits)", $values));
    131141            }
    132             $placeholders = rtrim(str_repeat('(%s,%d,%d,%d),', count($stats)), ',');
    133             $this->db->query($this->db->prepare("INSERT INTO {$this->db->prefix}koko_analytics_referrer_stats(date, id, unique_hits, hits) VALUES {$placeholders} ON DUPLICATE KEY UPDATE unique_hits = unique_hits + VALUES(unique_hits), hits = hits + VALUES(hits)", $values));
    134142        }
    135143
  • koko-analytics/trunk/src/Resources/external-strings.php

    r3605984 r3704935  
    33// i18n strings for koko-analytics-pro
    44
    5 // ../koko-analytics-pro/src/UTM/views/dashboard.php#18
     5// ../koko-analytics-pro/src/UTM/views/dashboard.php#20
    66__('Top referrers', 'koko-analytics');
    77
    8 // ../koko-analytics-pro/src/UTM/views/dashboard.php#19
     8// ../koko-analytics-pro/src/UTM/views/dashboard.php#21
    99__('Where your visitors came from', 'koko-analytics');
    1010
    11 // ../koko-analytics-pro/src/UTM/views/dashboard.php#27
     11// ../koko-analytics-pro/src/UTM/views/dashboard.php#29
    1212__('Referrers report type', 'koko-analytics');
    1313
    14 // ../koko-analytics-pro/src/UTM/views/dashboard.php#34
     14// ../koko-analytics-pro/src/UTM/views/dashboard.php#36
    1515__('A visitor represents the number of sessions during which a page was viewed one or more times.', 'koko-analytics');
    1616
    17 // ../koko-analytics-pro/src/UTM/views/dashboard.php#34
     17// ../koko-analytics-pro/src/UTM/views/dashboard.php#36
    1818__('Visitors', 'koko-analytics');
    1919
    20 // ../koko-analytics-pro/src/UTM/views/dashboard.php#35
     20// ../koko-analytics-pro/src/UTM/views/dashboard.php#37
    2121__('A pageview is defined as a view of a page on your site. If a user clicks reload after reaching the page, this is counted as an additional pageview. If a visitor navigates to a different page and then returns to the original page, a second pageview is recorded as well.', 'koko-analytics');
    2222
    23 // ../koko-analytics-pro/src/UTM/views/dashboard.php#35
     23// ../koko-analytics-pro/src/UTM/views/dashboard.php#37
    2424__('Pageviews', 'koko-analytics');
    2525
    26 // ../koko-analytics-pro/src/UTM/views/dashboard.php#52
     26// ../koko-analytics-pro/src/UTM/views/dashboard.php#54
    2727__('There is nothing here. Yet!', 'koko-analytics');
    28 
    29 // ../koko-analytics-pro/src/UTM/views/dashboard.php#61
    30 __('Previous', 'koko-analytics');
    31 
    32 // ../koko-analytics-pro/src/UTM/views/dashboard.php#73
    33 __('Next', 'koko-analytics');
    3428
    3529// ../koko-analytics-pro/src/UTM/views/setting.php#8
    … …  
    165159__('Pageviews', 'koko-analytics');
    166160
    167 // ../koko-analytics-pro/src/Password/views/password-form.php#80
     161// ../koko-analytics-pro/src/Password/views/password-form.php#84
    168162__('Enter password to access the dashboard', 'koko-analytics');
    169163
    170 // ../koko-analytics-pro/src/Password/views/password-form.php#82
    171 __('Incorrect password. Please try again.', 'koko-analytics');
    172 
    173 // ../koko-analytics-pro/src/Password/views/password-form.php#84
     164// ../koko-analytics-pro/src/Password/views/password-form.php#89
    174165__('Password', 'koko-analytics');
    175166
    176 // ../koko-analytics-pro/src/Password/views/password-form.php#85
     167// ../koko-analytics-pro/src/Password/views/password-form.php#90
    177168__('Submit', 'koko-analytics');
    178169
    179 // ../koko-analytics-pro/src/Password/bootstrap.php#55
     170// ../koko-analytics-pro/src/Password/bootstrap.php#42
     171__('Unable to sign in. Check the password and try again.', 'koko-analytics');
     172
     173// ../koko-analytics-pro/src/Password/bootstrap.php#57
    180174__('Dashboard password', 'koko-analytics');
    181175
    182 // ../koko-analytics-pro/src/Password/bootstrap.php#57
     176// ../koko-analytics-pro/src/Password/bootstrap.php#59
    183177__('Set a password to protect your public dashboard. Leave empty for no password protection.', 'koko-analytics');
    184178
    … …  
    234228__('Add event', 'koko-analytics');
    235229
    236 // ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#19
     230// ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#22
    237231__('Event:', 'koko-analytics');
    238232
    239 // ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#20
     233// ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#23
    240234__('Unique', 'koko-analytics');
    241235
    242 // ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#21
     236// ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#24
    243237__('Total', 'koko-analytics');
    244238
    245 // ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#52
     239// ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#55
    246240__('There is nothing here. Yet!', 'koko-analytics');
    247 
    248 // ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#58
    249 __('Previous', 'koko-analytics');
    250 
    251 // ../koko-analytics-pro/src/Events/views/dashboard-event-component.php#61
    252 __('Next', 'koko-analytics');
    253241
    254242// ../koko-analytics-pro/src/Events/views/event-details.php#24
    … …  
    309297__('Back to event settings', 'koko-analytics');
    310298
    311 // ../koko-analytics-pro/src/Events/bootstrap.php#51
     299// ../koko-analytics-pro/src/Events/bootstrap.php#52
    312300__('Events', 'koko-analytics');
    313301
    314 // ../koko-analytics-pro/src/Events/Rest.php#59
     302// ../koko-analytics-pro/src/Events/Rest.php#71
    315303__('start_date must be before or equal to end_date.', 'koko-analytics');
    316304
    317 // ../koko-analytics-pro/src/Events/Rest.php#63
     305// ../koko-analytics-pro/src/Events/Rest.php#75
    318306__('Date range is too large.', 'koko-analytics');
    319307
    … …  
    321309__('Pageviews', 'koko-analytics');
    322310
    323 // ../koko-analytics-pro/src/Devices/views/dashboard.php#39
     311// ../koko-analytics-pro/src/Devices/views/dashboard.php#43
    324312__('There is nothing here. Yet!', 'koko-analytics');
    325 
    326 // ../koko-analytics-pro/src/Devices/views/dashboard.php#45
    327 __('Previous', 'koko-analytics');
    328 
    329 // ../koko-analytics-pro/src/Devices/views/dashboard.php#48
    330 __('Next', 'koko-analytics');
    331313
    332314// ../koko-analytics-pro/src/Devices/views/setting.php#8
    … …  
    378360__('Send periodic email reports?', 'koko-analytics');
    379361
    380 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#18
     362// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#20
    381363__('Daily', 'koko-analytics');
    382364
    383 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#19
     365// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#21
    384366__('Weekly', 'koko-analytics');
    385367
    386 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#20
     368// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#22
    387369__('Monthly', 'koko-analytics');
    388370
    389 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#22
     371// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#24
    390372__('Select the timeframes for which you want to receive a summary of your most important statistics.', 'koko-analytics');
    391373
    392 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#26
     374// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#28
    393375__('Send to these email addresses', 'koko-analytics');
    394376
    395 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#28
     377// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#30
    396378__('Enter a comma separated list of email addresses to which the reports should be sent.', 'koko-analytics');
    397379
    398 // ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#32
     380// ../koko-analytics-pro/src/Emails/views/settings-email-reports.php#34
    399381__('Save Changes', 'koko-analytics');
    400382
    … …  
    402384__('Email reports', 'koko-analytics');
    403385
    404 // ../koko-analytics-pro/src/Geolocation/views/dashboard.php#20
     386// ../koko-analytics-pro/src/Geolocation/views/dashboard.php#21
    405387__('Country', 'koko-analytics');
    406388
    407 // ../koko-analytics-pro/src/Geolocation/views/dashboard.php#21
     389// ../koko-analytics-pro/src/Geolocation/views/dashboard.php#22
    408390__('Count', 'koko-analytics');
    409391
    410 // ../koko-analytics-pro/src/Geolocation/views/dashboard.php#40
     392// ../koko-analytics-pro/src/Geolocation/views/dashboard.php#41
    411393__('There is nothing here. Yet!', 'koko-analytics');
    412 
    413 // ../koko-analytics-pro/src/Geolocation/views/dashboard.php#46
    414 __('Previous', 'koko-analytics');
    415 
    416 // ../koko-analytics-pro/src/Geolocation/views/dashboard.php#49
    417 __('Next', 'koko-analytics');
    418394
    419395// ../koko-analytics-pro/src/Geolocation/views/setting.php#8
  • koko-analytics/trunk/src/Resources/functions/functions.php

    r3581336 r3704935  
    5555}
    5656
     57/**
     58 * Returns whether the current request may view the analytics dashboard.
     59 */
     60function can_view_dashboard(?array $settings = null): bool
     61{
     62    if (current_user_can('view_koko_analytics')) {
     63        return true;
     64    }
     65
     66    $settings = $settings ?? get_settings();
     67    if (empty($settings['is_dashboard_public'])) {
     68        return false;
     69    }
     70
     71    return (bool) apply_filters('koko_analytics_can_view_public_dashboard', true, $settings);
     72}
     73
     74/**
     75 * Wrapper around add_query_arg() that returns the URL with its query string in a canonical order.
     76 *
     77 * All dashboard links should be created through this function. Since add_query_arg() keeps the query
     78 * parameters of the current request in whatever order they came in, links pointing at the exact same
     79 * view can otherwise end up with a different query string on every request. Sorting the parameters
     80 * keeps the number of distinct dashboard URLs to a minimum, which helps any cache (browser, page
     81 * cache or CDN) in front of the public dashboard.
     82 *
     83 * Takes the same arguments as add_query_arg(), so a value of null removes that parameter.
     84 *
     85 * @param array<string, mixed> $args
     86 * @param string|null $url URL to add the query args to. Defaults to the URL of the current request.
     87 */
     88function dashboard_url(array $args = [], ?string $url = null): string
     89{
     90    return sort_query_string($url === null ? add_query_arg($args) : add_query_arg($args, $url));
     91}
     92
     93/**
     94 * Sorts the query string of the given URL by parameter name, leaving the rest of the URL untouched.
     95 */
     96function sort_query_string(string $url): string
     97{
     98    $start = strpos($url, '?');
     99    if ($start === false) {
     100        return $url;
     101    }
     102
     103    $query    = substr($url, $start + 1);
     104    $fragment = '';
     105    $hash     = strpos($query, '#');
     106    if ($hash !== false) {
     107        $fragment = substr($query, $hash);
     108        $query    = substr($query, 0, $hash);
     109    }
     110
     111    if ($query === '') {
     112        return $url;
     113    }
     114
     115    $pairs = explode('&', $query);
     116    $names = array_map(function ($pair) {
     117        return explode('=', $pair, 2)[0];
     118    }, $pairs);
     119    $order = array_keys($pairs);
     120
     121    // sort by parameter name, keeping parameters that share a name in their original order
     122    array_multisort($names, SORT_ASC, SORT_STRING, $order, SORT_ASC, SORT_NUMERIC, $pairs);
     123
     124    return substr($url, 0, $start + 1) . implode('&', $pairs) . $fragment;
     125}
     126
    57127function get_most_viewed_post_ids(array $args)
    58128{
  • koko-analytics/trunk/src/Resources/views/dashboard-page.php

    r3597329 r3704935  
    33use KokoAnalytics\Chart_View;
    44use KokoAnalytics\Fmt;
     5
     6use function KokoAnalytics\dashboard_url;
    57
    68defined('ABSPATH') || exit;
    … …  
    1416 * @var int $realtime
    1517 * @var string $date_format
    16  * @var string $dashboard_url
    1718 * @var array $next_dates
    1819 * @var array $prev_dates
    … …  
    3536                    — <?php echo esc_html(wp_date($date_format, $date_end->getTimestamp())); ?>
    3637                </div>
    37 
    3838                <div id="ka-datepicker-dropdown" class="rounded bg-white shadow" style="display: none; position: absolute; width:360px; z-index: 9992;">
    39                     <div class="mb-3 bg-dark text-white p-3 rounded-top fw-bold d-flex justify-content-between">
    40                         <?php
    41                         // only output pagination for date ranges between reasonable dates... to prevent ever-crawling bots from going wild
    42                         ?>
    43                         <?php if ($date_start > $total_start_date) { ?>
    44                             <a class="js-quicknav-prev text-decoration-none text-white me-2" href="" data-href="<?= esc_attr(add_query_arg(['start_date' => $prev_dates[0]->format('Y-m-d'), 'end_date' => $prev_dates[1]->format('Y-m-d')], $dashboard_url)); ?>" rel="nofollow">◂</a>
    45                         <?php } else { ?>
    46                             <a class="text-decoration-none text-white me-2">◂</a>
     39                    <?php $show_date_navigation = KokoAnalytics\Dashboard::shows_pagination_links(); ?>
     40                    <div class="mb-3 bg-dark text-white p-3 rounded-top fw-bold d-flex <?= $show_date_navigation ? 'justify-content-between' : 'justify-content-center'; ?>">
     41                        <?php if ($show_date_navigation) { ?>
     42                            <?php
     43                            // only output pagination for date ranges between reasonable dates... to prevent ever-crawling bots from going wild
     44                            // the target URL is assembled in JS from these dates, so no crawlable URL ever appears in the document
     45                            ?>
     46                            <?php if ($date_start > $total_start_date) { ?>
     47                                <a class="js-quicknav-prev text-decoration-none text-white me-2" href="" data-start-date="<?= esc_attr($prev_dates[0]->format('Y-m-d')); ?>" data-end-date="<?= esc_attr($prev_dates[1]->format('Y-m-d')); ?>" rel="nofollow">◂</a>
     48                            <?php } else { ?>
     49                                <a class="text-decoration-none text-white me-2">◂</a>
     50                            <?php } ?>
    4751                        <?php } ?>
    4852                        <span><?php echo esc_html(wp_date($date_format, $date_start->getTimestamp())); ?> — <?= esc_html(wp_date($date_format, $date_end->getTimestamp())); ?></span>
    49                         <?php if ($date_end < $total_end_date) { ?>
    50                             <a class="js-quicknav-next text-decoration-none text-white ms-2" href="" data-href="<?= esc_attr(add_query_arg(['start_date' => $next_dates[0]->format('Y-m-d'), 'end_date' => $next_dates[1]->format('Y-m-d')], $dashboard_url)); ?>" rel="nofollow">▸</a>
    51                         <?php } else { ?>
    52                             <a class="text-decoration-none text-white ms-2">▸</a>
     53                        <?php if ($show_date_navigation) { ?>
     54                            <?php if ($date_end < $total_end_date) { ?>
     55                                <a class="js-quicknav-next text-decoration-none text-white ms-2" href="" data-start-date="<?= esc_attr($next_dates[0]->format('Y-m-d')); ?>" data-end-date="<?= esc_attr($next_dates[1]->format('Y-m-d')); ?>" rel="nofollow">▸</a>
     56                            <?php } else { ?>
     57                                <a class="text-decoration-none text-white ms-2">▸</a>
     58                            <?php } ?>
    5359                        <?php } ?>
    5460                    </div>
    … …  
    98104                <?php $filtered_label = $filtered_post_id > 0 ? get_the_title($filtered_post_id) : $page; ?>
    99105                <a class="" href="<?= esc_url($filtered_permalink); ?>"><?= esc_html($filtered_label); ?></a>
    100                 <a class="text-decoration-none text-reset ms-2" aria-label="<?php esc_attr_e('Clear page filter', 'koko-analytics'); ?>" title="<?php esc_attr_e('Clear page filter', 'koko-analytics'); ?>" href="<?= esc_attr(remove_query_arg('p')); ?>">✕</a>
     106                <a class="text-decoration-none text-reset ms-2" aria-label="<?php esc_attr_e('Clear page filter', 'koko-analytics'); ?>" title="<?php esc_attr_e('Clear page filter', 'koko-analytics'); ?>" href="<?= esc_attr(dashboard_url(['p' => null])); ?>">✕</a>
    101107            </div>
    102108
    … …  
    191197    <?php } ?>
    192198
    193     <?php $can_sort = current_user_can('manage_koko_analytics'); ?>
     199    <?php // only offer drag & drop where the action that stores the new order runs: it is registered from the admin controller, so a save from the public dashboard would silently do nothing ?>
     200    <?php $can_sort = is_admin() && current_user_can('manage_koko_analytics'); ?>
    194201    <div id="ka-components" class="ka-row ka-row-cols-1 ka-row-cols-xl-2 g-3 mb-3 <?= $page !== 0 ? 'page-filter-active' : ''; ?>" <?= $can_sort ? 'data-nonce="' . esc_attr(wp_create_nonce('koko_analytics_save_component_order')) . '"' : ''; ?>>
    195202        <?php foreach ($this->get_components() as $id => $callback) : ?>
  • koko-analytics/trunk/src/Resources/views/settings/help.php

    r3581336 r3704935  
    2424<div class="mb-5">
    2525    <ul class="ul-square">
    26         <li><a href="https://www.kokoanalytics.com/pricing/?utm_source=koko-analytics&utm_medium=link&utm_campaign=free-plugin-settings-help">Upgrade to Koko Analytics Pro</a> to get direct email support from the plugin builder.</li>
     26        <?php if (defined('KOKO_ANALYTICS_PRO_VERSION')) : ?>
     27            <?php /* translators: 1: opening anchor tag, 2: closing anchor tag. */ ?>
     28            <li><?php printf(esc_html__('As a Koko Analytics Pro user, you can email us directly at %1$ssupport@kokoanalytics.com%2$s for help.', 'koko-analytics'), '<a href="mailto:support@kokoanalytics.com">', '</a>'); ?></li>
     29        <?php else : ?>
     30            <li><a href="https://www.kokoanalytics.com/pricing/?utm_source=koko-analytics&utm_medium=link&utm_campaign=free-plugin-settings-help">Upgrade to Koko Analytics Pro</a> to get direct email support from the plugin builder.</li>
     31        <?php endif; ?>
     32
    2733        <?php /* translators: 1: opening anchor tag, 2: closing anchor tag. */ ?>
    2834        <li><?php printf(esc_html__('Have a look at our %1$sknowledge base%2$s for help with configuring and using Koko Analytics.', 'koko-analytics'), '<a href="https://www.kokoanalytics.com/docs/#utm_source=koko-analytics&amp;utm_medium=link&amp;utm_campaign=free-plugin-settings-help-docs" target="_blank">', '</a>'); ?></li>
  • koko-analytics/trunk/src/Rest.php

    r3581336 r3704935  
    113113    public function permission_callback(): bool
    114114    {
    115         $settings            = get_settings();
    116         $is_dashboard_public = $settings['is_dashboard_public'];
    117         return $is_dashboard_public || current_user_can('view_koko_analytics');
     115        return can_view_dashboard();
    118116    }
    119117
  • koko-analytics/trunk/src/Upserter.php

    r3581336 r3704935  
    77class Upserter
    88{
     9    private const DATABASE_BATCH_SIZE = 500;
     10
    911    protected wpdb $db;
    1012    protected string $table;
    … …  
    2325    /**
    2426     * @param array<string> $values
    25      * @return array<string, int> Map of value to ID in database
     27     * @return array<array-key, int> Map of value to ID in database
    2628     */
    2729    public function upsert(array $values): array
    … …  
    3537        $values = array_unique($values);
    3638
    37         // INSERT IGNORE all deduplicated values into the database table
    38         $placeholders = rtrim(str_repeat('(%s),', count($values)), ',');
    39         $this->db->query($this->db->prepare("INSERT IGNORE INTO {$this->table}({$this->column}) VALUES {$placeholders}", $values));
     39        $ids = [];
     40        foreach (array_chunk($values, self::DATABASE_BATCH_SIZE) as $batch) {
     41            // INSERT IGNORE all deduplicated values into the database table
     42            $placeholders = rtrim(str_repeat('(%s),', count($batch)), ',');
     43            $this->db->query($this->db->prepare("INSERT IGNORE INTO {$this->table}({$this->column}) VALUES {$placeholders}", $batch));
    4044
    41         // retrieve all entries from the database table to get their normalized ID's
    42         $placeholders = rtrim(str_repeat('%s,', count($values)), ',');
    43         $results      = $this->db->get_results($this->db->prepare("SELECT id, {$this->column} FROM {$this->table} WHERE {$this->column} IN({$placeholders})", $values));
     45            // retrieve all entries from the database table to get their normalized ID's
     46            $placeholders = rtrim(str_repeat('%s,', count($batch)), ',');
     47            $results      = $this->db->get_results($this->db->prepare("SELECT id, {$this->column} FROM {$this->table} WHERE {$this->column} IN({$placeholders})", $batch));
     48            $ids          = array_replace($ids, array_column($results, 'id', $this->column));
     49        }
    4450
    45         return array_column($results, 'id', $this->column);
     51        return $ids;
    4652    }
    4753}
Note: See TracChangeset for help on using the changeset viewer.