PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 All 37 releases
← All changes | includes/widgets/widget-site-views.php +171 -5 1.1.11 → 1.1.13 View file →
@@ -3,9 +3,11 @@
3 3 * OpenStation — Site Views Widget.
4 4 *
5 5 * Sparkline graph of page views over the last 7 days with
6 6 * week-over-week delta. Tries Jetpack Stats first, falls back
7 - * to a _post_views_YYYY-MM-DD meta-key aggregator.
7 + * to a _post_views_YYYY-MM-DD meta-key aggregator. Both sources are
8 + * read server side, one route each, and both answer their rows as
9 + * `days`: `{ date, views }`, oldest first.
8 10 *
9 11 * Refresh: every 10 minutes.
10 12 * Requires: OpenStation 0.18.0+ (openstation_register_widget).
11 13 *
@@ -14,17 +16,31 @@
14 16
15 17 defined( 'ABSPATH' ) || exit;
16 18
17 19 /**
18 - * Register the REST endpoint that aggregates per-post view counts
19 - * from the _post_views_YYYY-MM-DD meta key (plain-WP fallback).
20 + * Register the widget's two REST endpoints, one per source.
20 21 *
21 - * Route: GET /desktop-mode/v1/site-views-meta
22 - * Permission: edit_posts.
22 + * Route: GET /desktop-mode/v1/site-views-jetpack (Jetpack Stats)
23 + * Route: GET /desktop-mode/v1/site-views-meta (plain-WP fallback,
24 + * per-post `_post_views_YYYY-MM-DD` meta)
25 + * Permission: edit_posts on both. The Jetpack route then applies
26 + * Jetpack's own stats gate per caller, see
27 + * {@see openstation_site_views_jetpack_callback()}.
23 28 */
24 29 function openstation_register_site_views_rest_route() {
25 30 register_rest_route(
26 31 'desktop-mode/v1',
32 + '/site-views-jetpack',
33 + array(
34 + 'methods' => WP_REST_Server::READABLE,
35 + 'callback' => 'openstation_site_views_jetpack_callback',
36 + 'permission_callback' => static function () {
37 + return current_user_can( 'edit_posts' );
38 + },
39 + )
40 + );
41 + register_rest_route(
42 + 'desktop-mode/v1',
27 43 '/site-views-meta',
28 44 array(
29 45 'methods' => WP_REST_Server::READABLE,
30 46 'callback' => 'openstation_site_views_meta_callback',
@@ -34,8 +50,158 @@
34 50 )
35 51 );
36 52 }
37 53 add_action( 'rest_api_init', 'openstation_register_site_views_rest_route' );
54 +
55 +/**
56 + * Last 14 days of site visits from Jetpack Stats.
57 + *
58 + * Jetpack registers no `jetpack/v4/stats/visits` route: its own
59 + * dashboard reads `jetpack/v4/stats-app/sites/<blog id>/stats/visits`,
60 + * which proxies `WPCOM_Stats::get_visits()`. Calling that method here
61 + * keeps the blog id on the server, and `WPCOM_Stats` already caches
62 + * the WordPress.com answer for five minutes per site.
63 + *
64 + * Answers `available: false` instead of an error when Jetpack cannot
65 + * serve this caller, so the client falls through to the meta source
66 + * without a failed request on every site that has no Jetpack:
67 + *
68 + * - The caller does not pass Jetpack's stats gate, see
69 + * {@see openstation_site_views_user_can_read_jetpack()}. That answer
70 + * also carries `restricted: true`, so the widget can say the numbers
71 + * exist and are withheld instead of asking for a plugin that is
72 + * already active.
73 + * - Jetpack is absent, the Stats module is off, or WordPress.com
74 + * answered with an error (for example a site that is not connected).
75 + *
76 + * A successful answer is trusted even when every day is zero: Jetpack
77 + * is collecting, and a quiet week is a real answer.
78 + *
79 + * @return array{available:bool,restricted:bool,days:array}
80 + */
81 +function openstation_site_views_jetpack_callback() {
82 + $restricted = openstation_site_views_jetpack_stats_active() && ! openstation_site_views_user_can_read_jetpack();
83 + $days = $restricted ? null : openstation_site_views_jetpack_days();
84 +
85 + return array(
86 + 'available' => null !== $days,
87 + 'restricted' => $restricted,
88 + 'days' => null === $days ? array() : $days,
89 + );
90 +}
91 +
92 +/**
93 + * Whether the current user passes Jetpack's own stats gate.
94 + *
95 + * Mirrors the `permission_callback` of Jetpack's stats-app REST
96 + * controller: `manage_options` or `view_stats` (the roles picked in
97 + * Jetpack's Stats settings). Every surface that serves a number read
98 + * from Jetpack Stats asks this, the Living Tree's traffic total
99 + * included, so OpenStation never hands out traffic Jetpack itself
100 + * withholds from the caller.
101 + *
102 + * @return bool
103 + */
104 +function openstation_site_views_user_can_read_jetpack() {
105 + return current_user_can( 'manage_options' ) || current_user_can( 'view_stats' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown -- Jetpack's stats capability, mapped by its Stats package.
106 +}
107 +
108 +/**
109 + * Whether Jetpack Stats is there to ask: its reader is loaded and the
110 + * Stats module is on.
111 + *
112 + * `WPCOM_Stats` is autoloaded whenever Jetpack is active, module on or
113 + * off, so its presence alone says nothing about whether the site is
114 + * counting. With the module off the recent days read as zero, which
115 + * would hide a post-views plugin that is counting. A site where the
116 + * module state cannot be read is treated as off for the same reason.
117 + *
118 + * @return bool
119 + */
120 +function openstation_site_views_jetpack_stats_active() {
121 + return class_exists( '\Automattic\Jetpack\Stats\WPCOM_Stats' )
122 + && class_exists( '\Automattic\Jetpack\Modules' )
123 + && ( new \Automattic\Jetpack\Modules() )->is_active( 'stats' );
124 +}
125 +
126 +/**
127 + * Read and normalise 14 daily rows from `WPCOM_Stats::get_visits()`.
128 + *
129 + * WordPress.com answers `{ fields: [ 'period', 'views', ... ], data:
130 + * [ [ 'YYYY-MM-DD', 12, ... ], ... ] }`. Rows are positional per
131 + * `fields`, so both columns are located by name. An answer that names
132 + * neither is not guessed at by position: another column read as views
133 + * is a wrong number, and no answer lets the meta source speak instead.
134 + *
135 + * Not gated on the caller: every caller that serves these rows, or a
136 + * total of them, asks {@see openstation_site_views_user_can_read_jetpack()}.
137 + *
138 + * @return array<int,array{date:string,views:int}>|null Rows oldest
139 + * first, or null when Jetpack Stats cannot answer.
140 + */
141 +function openstation_site_views_jetpack_days() {
142 + if ( ! openstation_site_views_jetpack_stats_active() ) {
143 + return null;
144 + }
145 + $wpcom_stats = new \Automattic\Jetpack\Stats\WPCOM_Stats();
146 + if ( ! method_exists( $wpcom_stats, 'get_visits' ) ) {
147 + return null;
148 + }
149 +
150 + try {
151 + $stats = $wpcom_stats->get_visits(
152 + array(
153 + 'unit' => 'day',
154 + 'quantity' => 14,
155 + )
156 + );
157 + } catch ( \Throwable $e ) {
158 + return null;
159 + }
160 + if ( is_wp_error( $stats ) ) {
161 + return null;
162 + }
163 +
164 + // Jetpack versions differ on object-vs-assoc-array decoding.
165 + $stats = json_decode( wp_json_encode( $stats ), true );
166 + if ( ! is_array( $stats ) || empty( $stats['data'] ) || ! is_array( $stats['data'] ) ) {
167 + return null;
168 + }
169 +
170 + $fields = isset( $stats['fields'] ) && is_array( $stats['fields'] ) ? $stats['fields'] : array();
171 + $date_index = array_search( 'period', $fields, true );
172 + $views_index = array_search( 'views', $fields, true );
173 + if ( false === $date_index || false === $views_index ) {
174 + return null;
175 + }
176 +
177 + $days = array();
178 + foreach ( $stats['data'] as $row ) {
179 + if ( ! is_array( $row ) || ! isset( $row[ $date_index ] ) ) {
180 + continue;
181 + }
182 + $date = (string) $row[ $date_index ];
183 + if ( 1 !== preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
184 + continue;
185 + }
186 + $views = isset( $row[ $views_index ] ) && is_numeric( $row[ $views_index ] ) ? (int) $row[ $views_index ] : 0;
187 + $days[] = array(
188 + 'date' => $date,
189 + 'views' => max( 0, $views ),
190 + );
191 + }
192 + if ( empty( $days ) ) {
193 + return null;
194 + }
195 +
196 + usort(
197 + $days,
198 + static function ( $a, $b ) {
199 + return strcmp( $a['date'], $b['date'] );
200 + }
201 + );
202 + return $days;
203 +}
38 204
39 205 /**
40 206 * Aggregate _post_views_YYYY-MM-DD meta across all posts for 14 days.
41 207 *