@memberjunction/connector-wordpress 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/WordPressConnector.js +7 -0
- package/dist/WordPressConnector.js.map +1 -1
- package/package.json +3 -2
- package/wp-plugin/mj-wsal-bridge/README.md +157 -0
- package/wp-plugin/mj-wsal-bridge/mj-wsal-bridge.php +1043 -0
- package/wp-plugin/mj-wsal-bridge/test/inspect-tables.mjs +147 -0
- package/wp-plugin/mj-wsal-bridge/test/probe.mjs +171 -0
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* Plugin Name: MJ WP Activity Log Bridge
|
|
4
|
+
* Description: Exposes the WP Activity Log (WSAL) tables over the WordPress REST API, read-only, so the MemberJunction WordPress connector can discover and sync them like any other collection. Registers no writes and stores no data of its own.
|
|
5
|
+
* Version: 1.1.0
|
|
6
|
+
* Requires PHP: 7.4
|
|
7
|
+
* Author: MemberJunction
|
|
8
|
+
* License: GPL-2.0-or-later
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS PLUGIN EXISTS
|
|
11
|
+
* ----------------------
|
|
12
|
+
* WP Activity Log keeps its events in two custom tables (`wsal_occurrences`, `wsal_metadata`) and
|
|
13
|
+
* registers ZERO REST routes of its own — verified against WSAL 5.6.6: neither `register_rest_route`
|
|
14
|
+
* nor `rest_api_init` appears anywhere in the plugin. So the data is invisible to `wp/v2` and
|
|
15
|
+
* therefore invisible to the MJ WordPress connector, which builds its object universe from the
|
|
16
|
+
* site's own route index.
|
|
17
|
+
*
|
|
18
|
+
* This bridge supplies the missing routes. The MJ connector then needs NO code change at all:
|
|
19
|
+
* - It derives candidate objects from the route index, and a route qualifies when it is a GET
|
|
20
|
+
* collection route that registers `per_page` — both routes below do.
|
|
21
|
+
* - Third-party namespaces are explicitly NOT filtered out by the connector.
|
|
22
|
+
* - A WordPress Application Password already authenticates every namespace, including this one.
|
|
23
|
+
* - The connector paginates on `X-WP-Total` / `X-WP-TotalPages`, which both routes emit.
|
|
24
|
+
*
|
|
25
|
+
* @package mj-wsal-bridge
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Exit if accessed directly.
|
|
29
|
+
if ( ! defined( 'ABSPATH' ) ) {
|
|
30
|
+
exit;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if ( ! class_exists( 'MJ_WSAL_Bridge' ) ) {
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read-only REST surface over the WP Activity Log tables.
|
|
37
|
+
*/
|
|
38
|
+
final class MJ_WSAL_Bridge {
|
|
39
|
+
|
|
40
|
+
/** REST namespace. Deliberately vendor-prefixed so it can never collide with WSAL's own future routes. */
|
|
41
|
+
const REST_NAMESPACE = 'mj-wsal/v1';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Page-size ceiling. Matches the WordPress core convention (and the MJ connector's documented
|
|
45
|
+
* `per_page` cap) so the connector's clamp and ours agree; a larger request is REJECTED by the
|
|
46
|
+
* arg validator rather than silently clamped, exactly as core does.
|
|
47
|
+
*/
|
|
48
|
+
const MAX_PER_PAGE = 100;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Boot.
|
|
52
|
+
*/
|
|
53
|
+
public static function init() {
|
|
54
|
+
add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register both collection routes.
|
|
59
|
+
*
|
|
60
|
+
* Each registers `page` + `per_page`, which is precisely the discriminator the MJ connector uses
|
|
61
|
+
* to tell a listable record collection from an RPC endpoint.
|
|
62
|
+
*/
|
|
63
|
+
public static function register_routes() {
|
|
64
|
+
register_rest_route(
|
|
65
|
+
self::REST_NAMESPACE,
|
|
66
|
+
'/events',
|
|
67
|
+
array(
|
|
68
|
+
array(
|
|
69
|
+
'methods' => WP_REST_Server::READABLE,
|
|
70
|
+
'callback' => array( __CLASS__, 'get_events' ),
|
|
71
|
+
'permission_callback' => array( __CLASS__, 'permission_check' ),
|
|
72
|
+
'args' => self::get_events_collection_params(),
|
|
73
|
+
),
|
|
74
|
+
'schema' => array( __CLASS__, 'get_event_schema' ),
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Introspection. Reports which WP Activity Log tables this site actually HAS, with their
|
|
79
|
+
// columns and row counts. It exists because table presence is not a constant: the free
|
|
80
|
+
// plugin creates only wsal_occurrences and wsal_metadata, while sessions, notifications and
|
|
81
|
+
// the two report tables arrive with premium extensions — and their columns vary by version.
|
|
82
|
+
// Deciding what to support by GUESSING which tables exist would be a guess about someone
|
|
83
|
+
// else's install; this asks the site.
|
|
84
|
+
register_rest_route(
|
|
85
|
+
self::REST_NAMESPACE,
|
|
86
|
+
'/tables',
|
|
87
|
+
array(
|
|
88
|
+
array(
|
|
89
|
+
'methods' => WP_REST_Server::READABLE,
|
|
90
|
+
'callback' => array( __CLASS__, 'get_tables' ),
|
|
91
|
+
'permission_callback' => array( __CLASS__, 'permission_check' ),
|
|
92
|
+
'args' => array(),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// The remaining WP Activity Log tables, each behind the same generic handler. Registered
|
|
98
|
+
// unconditionally so the route index is stable across sites; a site missing the table gets
|
|
99
|
+
// a 503 naming it when the route is called, which is a far clearer signal than the route
|
|
100
|
+
// silently not existing.
|
|
101
|
+
foreach ( array_keys( self::generic_tables() ) as $mj_wsal_key ) {
|
|
102
|
+
register_rest_route(
|
|
103
|
+
self::REST_NAMESPACE,
|
|
104
|
+
'/' . $mj_wsal_key,
|
|
105
|
+
array(
|
|
106
|
+
array(
|
|
107
|
+
'methods' => WP_REST_Server::READABLE,
|
|
108
|
+
'callback' => static function ( WP_REST_Request $request ) use ( $mj_wsal_key ) {
|
|
109
|
+
return self::get_generic( $mj_wsal_key, $request );
|
|
110
|
+
},
|
|
111
|
+
'permission_callback' => array( __CLASS__, 'permission_check' ),
|
|
112
|
+
'args' => self::get_basic_collection_params(),
|
|
113
|
+
),
|
|
114
|
+
'schema' => static function () use ( $mj_wsal_key ) {
|
|
115
|
+
return self::generic_schema( $mj_wsal_key );
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
register_rest_route(
|
|
122
|
+
self::REST_NAMESPACE,
|
|
123
|
+
'/event-types',
|
|
124
|
+
array(
|
|
125
|
+
array(
|
|
126
|
+
'methods' => WP_REST_Server::READABLE,
|
|
127
|
+
'callback' => array( __CLASS__, 'get_event_types' ),
|
|
128
|
+
'permission_callback' => array( __CLASS__, 'permission_check' ),
|
|
129
|
+
'args' => self::get_basic_collection_params(),
|
|
130
|
+
),
|
|
131
|
+
'schema' => array( __CLASS__, 'get_event_type_schema' ),
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Auth ────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Who may read the activity log.
|
|
140
|
+
*
|
|
141
|
+
* The activity log is sensitive — it records usernames, IPs and content changes — so this
|
|
142
|
+
* deliberately requires a full administrator rather than a lesser role. On multisite the tables
|
|
143
|
+
* are network-wide (WSAL keys them off `base_prefix`), so a network capability is required there.
|
|
144
|
+
*
|
|
145
|
+
* Filterable via `mj_wsal_bridge_capability` for sites that maintain a dedicated integration role.
|
|
146
|
+
*
|
|
147
|
+
* @return true|WP_Error
|
|
148
|
+
*/
|
|
149
|
+
public static function permission_check() {
|
|
150
|
+
$capability = is_multisite() ? 'manage_network_options' : 'manage_options';
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Filters the capability required to read the bridge routes.
|
|
154
|
+
*
|
|
155
|
+
* @param string $capability Capability name.
|
|
156
|
+
*/
|
|
157
|
+
$capability = apply_filters( 'mj_wsal_bridge_capability', $capability );
|
|
158
|
+
|
|
159
|
+
if ( ! current_user_can( $capability ) ) {
|
|
160
|
+
return new WP_Error(
|
|
161
|
+
'mj_wsal_forbidden',
|
|
162
|
+
__( 'You are not allowed to read the activity log.', 'mj-wsal-bridge' ),
|
|
163
|
+
array( 'status' => rest_authorization_required_code() )
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Tables ──────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* WSAL stores ONE network-wide table set keyed off `base_prefix`, not the per-site `prefix`
|
|
174
|
+
* (see WSAL's Abstract_Entity::get_table_name). Using `prefix` here would silently read the
|
|
175
|
+
* wrong table — or nothing at all — on a multisite subsite.
|
|
176
|
+
*
|
|
177
|
+
* @param string $suffix Table suffix, e.g. 'wsal_occurrences'.
|
|
178
|
+
* @return string Fully-qualified table name.
|
|
179
|
+
*/
|
|
180
|
+
private static function table( $suffix ) {
|
|
181
|
+
global $wpdb;
|
|
182
|
+
return $wpdb->base_prefix . $suffix;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether the WSAL tables are actually present.
|
|
187
|
+
*
|
|
188
|
+
* @return bool
|
|
189
|
+
*/
|
|
190
|
+
private static function tables_exist() {
|
|
191
|
+
global $wpdb;
|
|
192
|
+
|
|
193
|
+
foreach ( array( 'wsal_occurrences', 'wsal_metadata' ) as $suffix ) {
|
|
194
|
+
$table = self::table( $suffix );
|
|
195
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is derived from $wpdb->base_prefix, not user input.
|
|
196
|
+
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
|
197
|
+
if ( $found !== $table ) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Uniform "WSAL isn't here" response.
|
|
207
|
+
*
|
|
208
|
+
* @return WP_Error
|
|
209
|
+
*/
|
|
210
|
+
private static function missing_tables_error() {
|
|
211
|
+
return new WP_Error(
|
|
212
|
+
'mj_wsal_tables_missing',
|
|
213
|
+
__( 'The WP Activity Log tables were not found on this site. Install and activate WP Activity Log before using this bridge.', 'mj-wsal-bridge' ),
|
|
214
|
+
array( 'status' => 503 )
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── /events ─────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Collection params for /events.
|
|
222
|
+
*
|
|
223
|
+
* @return array
|
|
224
|
+
*/
|
|
225
|
+
private static function get_events_collection_params() {
|
|
226
|
+
return array_merge(
|
|
227
|
+
self::get_basic_collection_params(),
|
|
228
|
+
array(
|
|
229
|
+
'after' => array(
|
|
230
|
+
'description' => __( 'Return events at or after this point. Accepts an ISO-8601 UTC datetime or a Unix timestamp in seconds. INCLUSIVE — see the note on watermark semantics.', 'mj-wsal-bridge' ),
|
|
231
|
+
'type' => 'string',
|
|
232
|
+
'required' => false,
|
|
233
|
+
'validate_callback' => array( __CLASS__, 'validate_timestamp_arg' ),
|
|
234
|
+
),
|
|
235
|
+
'before' => array(
|
|
236
|
+
'description' => __( 'Return events strictly before this point. Accepts an ISO-8601 UTC datetime or a Unix timestamp in seconds. EXCLUSIVE.', 'mj-wsal-bridge' ),
|
|
237
|
+
'type' => 'string',
|
|
238
|
+
'required' => false,
|
|
239
|
+
'validate_callback' => array( __CLASS__, 'validate_timestamp_arg' ),
|
|
240
|
+
),
|
|
241
|
+
'site_id' => array(
|
|
242
|
+
'description' => __( 'Restrict to one multisite site ID.', 'mj-wsal-bridge' ),
|
|
243
|
+
'type' => 'integer',
|
|
244
|
+
'required' => false,
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The `page` / `per_page` pair every listable collection must register.
|
|
252
|
+
*
|
|
253
|
+
* @return array
|
|
254
|
+
*/
|
|
255
|
+
private static function get_basic_collection_params() {
|
|
256
|
+
// `validate_callback` is REQUIRED, not decorative: WordPress only enforces minimum/maximum when
|
|
257
|
+
// an arg declares one. Without it `maximum` is silently ignored, per_page=5000 sanitizes
|
|
258
|
+
// straight through absint into the LIMIT, and a route advertised as bounded turns into an
|
|
259
|
+
// unbounded read on a large tenant. Core's own get_collection_params() sets it for this reason.
|
|
260
|
+
return array(
|
|
261
|
+
'page' => array(
|
|
262
|
+
'description' => __( 'Current page of the collection.', 'mj-wsal-bridge' ),
|
|
263
|
+
'type' => 'integer',
|
|
264
|
+
'default' => 1,
|
|
265
|
+
'minimum' => 1,
|
|
266
|
+
'sanitize_callback' => 'absint',
|
|
267
|
+
'validate_callback' => 'rest_validate_request_arg',
|
|
268
|
+
),
|
|
269
|
+
'per_page' => array(
|
|
270
|
+
'description' => __( 'Maximum number of items to return per page.', 'mj-wsal-bridge' ),
|
|
271
|
+
'type' => 'integer',
|
|
272
|
+
'default' => self::MAX_PER_PAGE,
|
|
273
|
+
'minimum' => 1,
|
|
274
|
+
'maximum' => self::MAX_PER_PAGE,
|
|
275
|
+
'sanitize_callback' => 'absint',
|
|
276
|
+
'validate_callback' => 'rest_validate_request_arg',
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Accept either an ISO-8601 datetime or a numeric epoch.
|
|
283
|
+
*
|
|
284
|
+
* @param mixed $value Raw arg value.
|
|
285
|
+
* @return true|WP_Error
|
|
286
|
+
*/
|
|
287
|
+
public static function validate_timestamp_arg( $value ) {
|
|
288
|
+
if ( null === self::to_epoch( $value ) ) {
|
|
289
|
+
return new WP_Error(
|
|
290
|
+
'mj_wsal_bad_timestamp',
|
|
291
|
+
__( 'Expected an ISO-8601 datetime or a Unix timestamp in seconds.', 'mj-wsal-bridge' ),
|
|
292
|
+
array( 'status' => 400 )
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Coerce an ISO-8601 string or numeric epoch into a float epoch (seconds).
|
|
301
|
+
*
|
|
302
|
+
* Sub-second precision is FLOORED, never rounded. `created_on` is a double with microsecond
|
|
303
|
+
* precision but the ISO form we emit carries only milliseconds, so rounding up could advance the
|
|
304
|
+
* watermark past an event that was never delivered. Flooring can only ever re-deliver a boundary
|
|
305
|
+
* event, which the consumer dedupes on `id`. Losing an event is unrecoverable; repeating one is free.
|
|
306
|
+
*
|
|
307
|
+
* @param mixed $value ISO-8601 string, numeric string, or number.
|
|
308
|
+
* @return float|null Epoch seconds, or null when unparseable.
|
|
309
|
+
*/
|
|
310
|
+
private static function to_epoch( $value ) {
|
|
311
|
+
if ( is_int( $value ) || is_float( $value ) ) {
|
|
312
|
+
return (float) $value;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if ( ! is_string( $value ) || '' === trim( $value ) ) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
$value = trim( $value );
|
|
320
|
+
|
|
321
|
+
if ( is_numeric( $value ) ) {
|
|
322
|
+
return (float) $value;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// A zoneless ISO string is interpreted as UTC, matching how the MJ side parses it.
|
|
326
|
+
$normalized = $value;
|
|
327
|
+
if ( ! preg_match( '/(Z|[+-]\d{2}:?\d{2})$/i', $normalized ) ) {
|
|
328
|
+
$normalized .= 'Z';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
$dt = new DateTimeImmutable( $normalized );
|
|
333
|
+
} catch ( Exception $e ) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Floor to the millisecond the ISO form can actually express.
|
|
338
|
+
return (float) $dt->format( 'U' ) + ( (int) $dt->format( 'v' ) ) / 1000;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* GET /mj-wsal/v1/events
|
|
343
|
+
*
|
|
344
|
+
* @param WP_REST_Request $request Request.
|
|
345
|
+
* @return WP_REST_Response|WP_Error
|
|
346
|
+
*/
|
|
347
|
+
public static function get_events( WP_REST_Request $request ) {
|
|
348
|
+
global $wpdb;
|
|
349
|
+
|
|
350
|
+
if ( ! self::tables_exist() ) {
|
|
351
|
+
return self::missing_tables_error();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
$occurrences = self::table( 'wsal_occurrences' );
|
|
355
|
+
$per_page = (int) $request->get_param( 'per_page' );
|
|
356
|
+
$page = (int) $request->get_param( 'page' );
|
|
357
|
+
$offset = ( $page - 1 ) * $per_page;
|
|
358
|
+
|
|
359
|
+
// ── WHERE ──
|
|
360
|
+
$where = array( '1=1' );
|
|
361
|
+
$params = array();
|
|
362
|
+
|
|
363
|
+
$after = $request->get_param( 'after' );
|
|
364
|
+
if ( null !== $after && '' !== $after ) {
|
|
365
|
+
// INCLUSIVE (>=). `created_on` is not unique — several events can share a timestamp —
|
|
366
|
+
// so an exclusive bound would drop every co-timestamped sibling of the last row synced.
|
|
367
|
+
$where[] = 'created_on >= %f';
|
|
368
|
+
$params[] = self::to_epoch( $after );
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
$before = $request->get_param( 'before' );
|
|
372
|
+
if ( null !== $before && '' !== $before ) {
|
|
373
|
+
$where[] = 'created_on < %f';
|
|
374
|
+
$params[] = self::to_epoch( $before );
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
$site_id = $request->get_param( 'site_id' );
|
|
378
|
+
if ( null !== $site_id && '' !== $site_id ) {
|
|
379
|
+
$where[] = 'site_id = %d';
|
|
380
|
+
$params[] = (int) $site_id;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
$where_sql = implode( ' AND ', $where );
|
|
384
|
+
|
|
385
|
+
// ── Total (a separate COUNT; SQL_CALC_FOUND_ROWS is deprecated as of MySQL 8.0.17) ──
|
|
386
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from base_prefix; all values are placeholders.
|
|
387
|
+
$count_sql = "SELECT COUNT(*) FROM `{$occurrences}` WHERE {$where_sql}";
|
|
388
|
+
$total = (int) ( $params
|
|
389
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
|
390
|
+
? $wpdb->get_var( $wpdb->prepare( $count_sql, $params ) )
|
|
391
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
|
392
|
+
: $wpdb->get_var( $count_sql ) );
|
|
393
|
+
|
|
394
|
+
// ── Page ──
|
|
395
|
+
// ORDER BY (created_on, id) is a TOTAL order. Ordering on created_on alone is not stable —
|
|
396
|
+
// co-timestamped rows could shuffle between pages and be skipped or duplicated across an
|
|
397
|
+
// offset boundary. The trailing `id` breaks every tie deterministically.
|
|
398
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from base_prefix; all values are placeholders.
|
|
399
|
+
$rows_sql = "SELECT * FROM `{$occurrences}` WHERE {$where_sql} ORDER BY created_on ASC, id ASC LIMIT %d OFFSET %d";
|
|
400
|
+
$rows = $wpdb->get_results(
|
|
401
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
|
402
|
+
$wpdb->prepare( $rows_sql, array_merge( $params, array( $per_page, $offset ) ) ),
|
|
403
|
+
ARRAY_A
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
if ( ! is_array( $rows ) ) {
|
|
407
|
+
$rows = array();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
$meta_by_occurrence = self::fetch_metadata( wp_list_pluck( $rows, 'id' ) );
|
|
411
|
+
$catalog = self::get_alert_catalog();
|
|
412
|
+
|
|
413
|
+
$data = array();
|
|
414
|
+
foreach ( $rows as $row ) {
|
|
415
|
+
$data[] = self::shape_event( $row, $meta_by_occurrence, $catalog );
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
$response = new WP_REST_Response( $data );
|
|
419
|
+
|
|
420
|
+
// The MJ connector terminates paging on these headers, exactly as it does for wp/v2.
|
|
421
|
+
$response->header( 'X-WP-Total', (string) $total );
|
|
422
|
+
$response->header( 'X-WP-TotalPages', (string) ( $per_page > 0 ? (int) ceil( $total / $per_page ) : 0 ) );
|
|
423
|
+
|
|
424
|
+
return $response;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Pivot `wsal_metadata` for a page of occurrences.
|
|
429
|
+
*
|
|
430
|
+
* ONE query for the whole page, never one per row — the metadata table carries several rows per
|
|
431
|
+
* event and an N+1 here would multiply a 100-row page into 100 round trips.
|
|
432
|
+
*
|
|
433
|
+
* @param int[] $occurrence_ids Occurrence IDs on this page.
|
|
434
|
+
* @return array<int,array<string,mixed>> occurrence_id => [ name => value ].
|
|
435
|
+
*/
|
|
436
|
+
private static function fetch_metadata( array $occurrence_ids ) {
|
|
437
|
+
global $wpdb;
|
|
438
|
+
|
|
439
|
+
$occurrence_ids = array_values( array_filter( array_map( 'intval', $occurrence_ids ) ) );
|
|
440
|
+
if ( empty( $occurrence_ids ) ) {
|
|
441
|
+
return array();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
$metadata = self::table( 'wsal_metadata' );
|
|
445
|
+
$placeholders = implode( ',', array_fill( 0, count( $occurrence_ids ), '%d' ) );
|
|
446
|
+
|
|
447
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from base_prefix; IDs are placeholders.
|
|
448
|
+
$sql = "SELECT occurrence_id, name, value FROM `{$metadata}` WHERE occurrence_id IN ({$placeholders})";
|
|
449
|
+
|
|
450
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
|
451
|
+
$rows = $wpdb->get_results( $wpdb->prepare( $sql, $occurrence_ids ), ARRAY_A );
|
|
452
|
+
|
|
453
|
+
$out = array();
|
|
454
|
+
foreach ( (array) $rows as $row ) {
|
|
455
|
+
$out[ (int) $row['occurrence_id'] ][ $row['name'] ] = self::decode_meta_value( $row['value'] );
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return $out;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* WSAL serialises non-scalar metadata values with PHP `serialize()`, and some of them are OBJECTS
|
|
463
|
+
* (`PluginData` is a serialised stdClass). Unserialising blindly is a known object-injection
|
|
464
|
+
* vector, so this permits NO classes: a serialised object comes back as __PHP_Incomplete_Class,
|
|
465
|
+
* which is inert — no constructor, no __wakeup, no autoload.
|
|
466
|
+
*
|
|
467
|
+
* Rather than give up there and emit an opaque `O:8:"stdClass":6:{…}` string, the incomplete
|
|
468
|
+
* object is flattened to its public properties. That yields real structured JSON with no class
|
|
469
|
+
* ever instantiated. The private marker key PHP injects is dropped on the way out.
|
|
470
|
+
*
|
|
471
|
+
* @param string $value Raw stored value.
|
|
472
|
+
* @return mixed
|
|
473
|
+
*/
|
|
474
|
+
private static function decode_meta_value( $value ) {
|
|
475
|
+
if ( ! is_string( $value ) || ! is_serialized( $value ) ) {
|
|
476
|
+
return $value;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
$decoded = @unserialize( $value, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
|
480
|
+
|
|
481
|
+
if ( false === $decoded && 'b:0;' !== $value ) {
|
|
482
|
+
return $value; // Genuinely undecodable — hand back what was stored.
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return self::flatten_incomplete( $decoded );
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Recursively convert __PHP_Incomplete_Class placeholders into plain arrays.
|
|
490
|
+
*
|
|
491
|
+
* @param mixed $value Decoded value.
|
|
492
|
+
* @return mixed
|
|
493
|
+
*/
|
|
494
|
+
private static function flatten_incomplete( $value ) {
|
|
495
|
+
if ( is_object( $value ) ) {
|
|
496
|
+
$value = (array) $value;
|
|
497
|
+
// PHP records the original class name under a mangled key; it is not data.
|
|
498
|
+
unset( $value['__PHP_Incomplete_Class_Name'] );
|
|
499
|
+
foreach ( array_keys( $value ) as $k ) {
|
|
500
|
+
if ( is_string( $k ) && "\0" === substr( $k, 0, 1 ) ) {
|
|
501
|
+
unset( $value[ $k ] ); // Private/protected property mangling — not public data.
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if ( is_array( $value ) ) {
|
|
507
|
+
foreach ( $value as $k => $v ) {
|
|
508
|
+
$value[ $k ] = self::flatten_incomplete( $v );
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return $value;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Shape one occurrence row into the flattened event payload.
|
|
517
|
+
*
|
|
518
|
+
* @param array $row One `wsal_occurrences` row.
|
|
519
|
+
* @param array $meta_by_occurrence Pivoted metadata for the page.
|
|
520
|
+
* @param array $catalog alert_id => definition.
|
|
521
|
+
* @return array
|
|
522
|
+
*/
|
|
523
|
+
private static function shape_event( array $row, array $meta_by_occurrence, array $catalog ) {
|
|
524
|
+
$id = (int) $row['id'];
|
|
525
|
+
$alert_id = (int) $row['alert_id'];
|
|
526
|
+
$created_on = (float) $row['created_on'];
|
|
527
|
+
$definition = isset( $catalog[ $alert_id ] ) ? $catalog[ $alert_id ] : null;
|
|
528
|
+
|
|
529
|
+
return array(
|
|
530
|
+
'id' => $id,
|
|
531
|
+
'site_id' => (int) $row['site_id'],
|
|
532
|
+
'alert_id' => $alert_id,
|
|
533
|
+
'alert_label' => $definition ? $definition['label'] : '',
|
|
534
|
+
// The raw double, preserved exactly as stored, for anyone reconciling against the table.
|
|
535
|
+
'created_on' => $created_on,
|
|
536
|
+
// The SAME instant as ISO-8601 UTC. This is the field the MJ connector watermarks on:
|
|
537
|
+
// a bare epoch NUMBER is ambiguous to date parsers (seconds vs milliseconds), and reading
|
|
538
|
+
// these seconds as milliseconds would place every event in January 1970. An explicit ISO
|
|
539
|
+
// string removes the ambiguity at the source instead of relying on the consumer to guess.
|
|
540
|
+
'created_at' => self::to_iso8601( $created_on ),
|
|
541
|
+
// The raw code as stored — the occurrences table holds a NUMERIC level (500/400/300/250/200),
|
|
542
|
+
// not the WSAL_* constant name.
|
|
543
|
+
'severity' => (string) $row['severity'],
|
|
544
|
+
'severity_label' => self::severity_label( $row['severity'] ),
|
|
545
|
+
'object' => (string) $row['object'],
|
|
546
|
+
'event_type' => (string) $row['event_type'],
|
|
547
|
+
'username' => null === $row['username'] ? '' : (string) $row['username'],
|
|
548
|
+
'user_id' => null === $row['user_id'] ? null : (int) $row['user_id'],
|
|
549
|
+
'user_roles' => (string) $row['user_roles'],
|
|
550
|
+
'client_ip' => (string) $row['client_ip'],
|
|
551
|
+
'user_agent' => (string) $row['user_agent'],
|
|
552
|
+
'session_id' => (string) $row['session_id'],
|
|
553
|
+
'post_id' => (int) $row['post_id'],
|
|
554
|
+
'post_type' => (string) $row['post_type'],
|
|
555
|
+
'post_status' => (string) $row['post_status'],
|
|
556
|
+
'meta' => isset( $meta_by_occurrence[ $id ] ) ? $meta_by_occurrence[ $id ] : new stdClass(),
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Resolve the stored numeric severity level into a readable label.
|
|
562
|
+
*
|
|
563
|
+
* The occurrences table stores a numeric level, not a name: 500/400/300/250/200. Left raw, every
|
|
564
|
+
* consumer would have to hard-code that five-way mapping. Resolved through the plugin's own
|
|
565
|
+
* Constants::WSAL_SEVERITIES so the mapping tracks the installed version rather than a copy of it
|
|
566
|
+
* that silently rots; falls back to the documented levels when the class is unavailable.
|
|
567
|
+
*
|
|
568
|
+
* @param mixed $code Stored severity value.
|
|
569
|
+
* @return string 'Critical' | 'High' | 'Medium' | 'Low' | 'Informational' | 'Unknown' | ''.
|
|
570
|
+
*/
|
|
571
|
+
private static function severity_label( $code ) {
|
|
572
|
+
if ( null === $code || '' === $code ) {
|
|
573
|
+
return '';
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
$map = array( 500 => 'WSAL_CRITICAL', 400 => 'WSAL_HIGH', 300 => 'WSAL_MEDIUM', 250 => 'WSAL_LOW', 200 => 'WSAL_INFORMATIONAL', 0 => 'E_UNKNOWN' );
|
|
577
|
+
if ( class_exists( '\WSAL\Controllers\Constants' ) && defined( '\WSAL\Controllers\Constants::WSAL_SEVERITIES' ) ) {
|
|
578
|
+
$map = \WSAL\Controllers\Constants::WSAL_SEVERITIES;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
$key = (int) $code;
|
|
582
|
+
if ( ! isset( $map[ $key ] ) ) {
|
|
583
|
+
return 'Unknown';
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// 'WSAL_CRITICAL' → 'Critical'; 'E_UNKNOWN' → 'Unknown'.
|
|
587
|
+
$name = preg_replace( '/^(WSAL|E)_/', '', $map[ $key ] );
|
|
588
|
+
|
|
589
|
+
return ucfirst( strtolower( $name ) );
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Epoch seconds → ISO-8601 UTC with milliseconds.
|
|
594
|
+
*
|
|
595
|
+
* @param float $epoch Epoch seconds.
|
|
596
|
+
* @return string
|
|
597
|
+
*/
|
|
598
|
+
private static function to_iso8601( $epoch ) {
|
|
599
|
+
$seconds = (int) floor( $epoch );
|
|
600
|
+
$milliseconds = (int) floor( ( $epoch - $seconds ) * 1000 );
|
|
601
|
+
|
|
602
|
+
return gmdate( 'Y-m-d\TH:i:s', $seconds ) . sprintf( '.%03dZ', $milliseconds );
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ─── /event-types ────────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The alert catalog: WSAL's own event definitions, keyed by alert ID.
|
|
609
|
+
*
|
|
610
|
+
* Read through WSAL's public Alert_Manager rather than by re-parsing `defaults.php`, so
|
|
611
|
+
* third-party sensors (WooCommerce, Gravity Forms, Yoast, …) that register their own events are
|
|
612
|
+
* included automatically and the labels track the installed version.
|
|
613
|
+
*
|
|
614
|
+
* @return array<int,array<string,string>>
|
|
615
|
+
*/
|
|
616
|
+
private static function get_alert_catalog() {
|
|
617
|
+
static $cache = null;
|
|
618
|
+
|
|
619
|
+
if ( null !== $cache ) {
|
|
620
|
+
return $cache;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
$cache = array();
|
|
624
|
+
|
|
625
|
+
if ( ! class_exists( '\WSAL\Controllers\Alert_Manager' ) ) {
|
|
626
|
+
return $cache;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
$alerts = \WSAL\Controllers\Alert_Manager::get_alerts();
|
|
630
|
+
|
|
631
|
+
foreach ( (array) $alerts as $code => $alert ) {
|
|
632
|
+
if ( ! is_array( $alert ) ) {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
$cache[ (int) $code ] = array(
|
|
637
|
+
'alert_id' => (int) $code,
|
|
638
|
+
'label' => isset( $alert['desc'] ) ? (string) $alert['desc'] : '',
|
|
639
|
+
'message' => isset( $alert['message'] ) ? (string) $alert['message'] : '',
|
|
640
|
+
'severity' => isset( $alert['severity'] ) ? (string) $alert['severity'] : '',
|
|
641
|
+
'category' => isset( $alert['category'] ) ? (string) $alert['category'] : '',
|
|
642
|
+
'subcategory' => isset( $alert['subcategory'] ) ? (string) $alert['subcategory'] : '',
|
|
643
|
+
'object' => isset( $alert['object'] ) ? (string) $alert['object'] : '',
|
|
644
|
+
'event_type' => isset( $alert['event_type'] ) ? (string) $alert['event_type'] : '',
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
ksort( $cache );
|
|
649
|
+
|
|
650
|
+
return $cache;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* GET /mj-wsal/v1/event-types
|
|
655
|
+
*
|
|
656
|
+
* A small, slow-moving dimension table — every event ID the installed plugin set can emit, with
|
|
657
|
+
* its human label, severity and category. Lets `alert_id` be interpreted without hard-coding a
|
|
658
|
+
* lookup on the consuming side.
|
|
659
|
+
*
|
|
660
|
+
* @param WP_REST_Request $request Request.
|
|
661
|
+
* @return WP_REST_Response
|
|
662
|
+
*/
|
|
663
|
+
public static function get_event_types( WP_REST_Request $request ) {
|
|
664
|
+
$catalog = array_values( self::get_alert_catalog() );
|
|
665
|
+
$per_page = (int) $request->get_param( 'per_page' );
|
|
666
|
+
$page = (int) $request->get_param( 'page' );
|
|
667
|
+
$total = count( $catalog );
|
|
668
|
+
|
|
669
|
+
$response = new WP_REST_Response( array_slice( $catalog, ( $page - 1 ) * $per_page, $per_page ) );
|
|
670
|
+
$response->header( 'X-WP-Total', (string) $total );
|
|
671
|
+
$response->header( 'X-WP-TotalPages', (string) ( $per_page > 0 ? (int) ceil( $total / $per_page ) : 0 ) );
|
|
672
|
+
|
|
673
|
+
return $response;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
// ─── /tables (introspection) ──────────────────────────────────────────────
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* GET /mj-wsal/v1/tables
|
|
681
|
+
*
|
|
682
|
+
* Every `<base_prefix>wsal_*` table on this site, with its columns, types and row count.
|
|
683
|
+
*
|
|
684
|
+
* Discovered by PREFIX rather than from a fixed list, so a table this build has never heard of
|
|
685
|
+
* — a newer premium extension, a future version — still shows up instead of being invisible.
|
|
686
|
+
* Reports structure and counts only: no row content is read, so nothing in the activity log
|
|
687
|
+
* itself can leak through this route.
|
|
688
|
+
*
|
|
689
|
+
* @param WP_REST_Request $request Request.
|
|
690
|
+
* @return WP_REST_Response|WP_Error
|
|
691
|
+
*/
|
|
692
|
+
public static function get_tables( WP_REST_Request $request ) {
|
|
693
|
+
global $wpdb;
|
|
694
|
+
|
|
695
|
+
$prefix = $wpdb->base_prefix . 'wsal_';
|
|
696
|
+
// LIKE pattern: esc_like then the wildcard, so an underscore in the prefix stays literal.
|
|
697
|
+
$like = $wpdb->esc_like( $prefix ) . '%';
|
|
698
|
+
|
|
699
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- schema name and pattern are placeholders.
|
|
700
|
+
$names = $wpdb->get_col(
|
|
701
|
+
$wpdb->prepare(
|
|
702
|
+
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME LIKE %s ORDER BY TABLE_NAME',
|
|
703
|
+
DB_NAME,
|
|
704
|
+
$like
|
|
705
|
+
)
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
$known = array(
|
|
709
|
+
'wsal_occurrences' => 'Activity events. Supported today as ActivityLogEvent.',
|
|
710
|
+
'wsal_metadata' => 'Per-event name/value detail. Supported today, pivoted into ActivityLogEvent.meta.',
|
|
711
|
+
'wsal_sessions' => 'Live logged-in sessions. Rows are DELETED on logout, so this is a snapshot, not a history.',
|
|
712
|
+
'wsal_custom_notifications' => 'Notification rules. Plugin configuration, not user activity.',
|
|
713
|
+
'wsal_generated_reports' => 'History of report runs. Plugin configuration, not user activity.',
|
|
714
|
+
'wsal_periodic_reports' => 'Scheduled report definitions. Plugin configuration, not user activity.',
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
$out = array();
|
|
718
|
+
foreach ( (array) $names as $full ) {
|
|
719
|
+
$suffix = substr( $full, strlen( $wpdb->base_prefix ) );
|
|
720
|
+
|
|
721
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- values are placeholders.
|
|
722
|
+
$cols = $wpdb->get_results(
|
|
723
|
+
$wpdb->prepare(
|
|
724
|
+
'SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s ORDER BY ORDINAL_POSITION',
|
|
725
|
+
DB_NAME,
|
|
726
|
+
$full
|
|
727
|
+
),
|
|
728
|
+
ARRAY_A
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
// Table identifiers cannot be parameterised; this one came from information_schema for
|
|
732
|
+
// this exact schema and prefix, never from input, and is backtick-quoted.
|
|
733
|
+
$safe = str_replace( '`', '', $full );
|
|
734
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
|
735
|
+
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$safe}`" );
|
|
736
|
+
|
|
737
|
+
$out[] = array(
|
|
738
|
+
'table' => $full,
|
|
739
|
+
'suffix' => $suffix,
|
|
740
|
+
'rows' => $rows,
|
|
741
|
+
'supported' => in_array( $suffix, array( 'wsal_occurrences', 'wsal_metadata' ), true ),
|
|
742
|
+
'note' => isset( $known[ $suffix ] ) ? $known[ $suffix ] : 'Not documented by this build — discovered by prefix.',
|
|
743
|
+
'columns' => array_map(
|
|
744
|
+
static function ( $c ) {
|
|
745
|
+
return array(
|
|
746
|
+
'name' => $c['COLUMN_NAME'],
|
|
747
|
+
'type' => $c['COLUMN_TYPE'],
|
|
748
|
+
'nullable' => 'YES' === $c['IS_NULLABLE'],
|
|
749
|
+
'key' => $c['COLUMN_KEY'],
|
|
750
|
+
);
|
|
751
|
+
},
|
|
752
|
+
(array) $cols
|
|
753
|
+
),
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Name the documented tables that are ABSENT. Silence about a missing table reads as "we
|
|
758
|
+
// looked and it was empty", which is a different fact from "this site never had it".
|
|
759
|
+
$present = wp_list_pluck( $out, 'suffix' );
|
|
760
|
+
$missing = array();
|
|
761
|
+
foreach ( $known as $suffix => $note ) {
|
|
762
|
+
if ( ! in_array( $suffix, $present, true ) ) {
|
|
763
|
+
$missing[] = array( 'suffix' => $suffix, 'note' => $note );
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return new WP_REST_Response(
|
|
768
|
+
array(
|
|
769
|
+
'base_prefix' => $wpdb->base_prefix,
|
|
770
|
+
'present' => $out,
|
|
771
|
+
'missing' => $missing,
|
|
772
|
+
)
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* The remaining WP Activity Log tables, served by ONE generic handler.
|
|
779
|
+
*
|
|
780
|
+
* These four are not created by the free plugin — they arrive with premium extensions — so every
|
|
781
|
+
* route below is PRESENCE-GATED: a site without the table gets a clear 503 naming it, never a
|
|
782
|
+
* 500 and never an empty-but-successful page that reads as "there is no activity".
|
|
783
|
+
*
|
|
784
|
+
* `epoch` names columns holding a Unix timestamp; each gains an `<name>_at` ISO-8601 sibling,
|
|
785
|
+
* for the same reason ActivityLogEvent has `created_at`: a bare epoch is ambiguous to date
|
|
786
|
+
* parsers, and seconds read as milliseconds land every row in 1970.
|
|
787
|
+
*
|
|
788
|
+
* `json` names columns holding a JSON document in a text column. They are decoded so a consumer
|
|
789
|
+
* receives a real object rather than a string it has to parse a second time.
|
|
790
|
+
*/
|
|
791
|
+
private static function generic_tables() {
|
|
792
|
+
return array(
|
|
793
|
+
'sessions' => array(
|
|
794
|
+
'suffix' => 'wsal_sessions',
|
|
795
|
+
'pk' => 'id',
|
|
796
|
+
'epoch' => array( 'created_on', 'expires_on' ),
|
|
797
|
+
'json' => array(),
|
|
798
|
+
'title' => 'mj_wsal_session',
|
|
799
|
+
'note' => 'Live logged-in sessions. Rows are DELETED on logout, so this is a snapshot of who is signed in now, never a history.',
|
|
800
|
+
),
|
|
801
|
+
'notifications' => array(
|
|
802
|
+
'suffix' => 'wsal_custom_notifications',
|
|
803
|
+
'pk' => 'id',
|
|
804
|
+
'epoch' => array( 'created_on' ),
|
|
805
|
+
'json' => array( 'notification_settings', 'notification_template', 'notification_sms_template', 'notification_slack_template', 'notification_query' ),
|
|
806
|
+
'title' => 'mj_wsal_notification',
|
|
807
|
+
'note' => 'Notification rules configured in the plugin.',
|
|
808
|
+
),
|
|
809
|
+
'generated-reports' => array(
|
|
810
|
+
'suffix' => 'wsal_generated_reports',
|
|
811
|
+
'pk' => 'id',
|
|
812
|
+
'epoch' => array( 'created_on' ),
|
|
813
|
+
'json' => array( 'generated_report_filters', 'generated_report_filters_normalized', 'generated_report_header_columns' ),
|
|
814
|
+
'title' => 'mj_wsal_generated_report',
|
|
815
|
+
'note' => 'History of report runs.',
|
|
816
|
+
),
|
|
817
|
+
'periodic-reports' => array(
|
|
818
|
+
'suffix' => 'wsal_periodic_reports',
|
|
819
|
+
'pk' => 'id',
|
|
820
|
+
'epoch' => array( 'created_on', 'last_sent' ),
|
|
821
|
+
'json' => array( 'report_data' ),
|
|
822
|
+
'title' => 'mj_wsal_periodic_report',
|
|
823
|
+
'note' => 'Scheduled report definitions.',
|
|
824
|
+
),
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Columns of one table, from information_schema. Returns an empty array when the table is absent.
|
|
830
|
+
*
|
|
831
|
+
* @param string $full Fully-qualified table name.
|
|
832
|
+
* @return array<int,array<string,string>>
|
|
833
|
+
*/
|
|
834
|
+
private static function columns_of( $full ) {
|
|
835
|
+
global $wpdb;
|
|
836
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- values are placeholders.
|
|
837
|
+
return (array) $wpdb->get_results(
|
|
838
|
+
$wpdb->prepare(
|
|
839
|
+
'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_KEY FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s ORDER BY ORDINAL_POSITION',
|
|
840
|
+
DB_NAME,
|
|
841
|
+
$full
|
|
842
|
+
),
|
|
843
|
+
ARRAY_A
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* One page of a generic table.
|
|
849
|
+
*
|
|
850
|
+
* @param string $key Key into {@see generic_tables()}.
|
|
851
|
+
* @param WP_REST_Request $request Request.
|
|
852
|
+
* @return WP_REST_Response|WP_Error
|
|
853
|
+
*/
|
|
854
|
+
private static function get_generic( $key, WP_REST_Request $request ) {
|
|
855
|
+
global $wpdb;
|
|
856
|
+
|
|
857
|
+
$spec = self::generic_tables()[ $key ];
|
|
858
|
+
$full = self::table( $spec['suffix'] );
|
|
859
|
+
$cols = self::columns_of( $full );
|
|
860
|
+
|
|
861
|
+
if ( empty( $cols ) ) {
|
|
862
|
+
return new WP_Error(
|
|
863
|
+
'mj_wsal_table_missing',
|
|
864
|
+
sprintf(
|
|
865
|
+
/* translators: %s: database table name */
|
|
866
|
+
__( 'The table "%s" does not exist on this site. It is created by a WP Activity Log premium extension; without that extension there is nothing to read here.', 'mj-wsal-bridge' ),
|
|
867
|
+
$full
|
|
868
|
+
),
|
|
869
|
+
array( 'status' => 503 )
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
$per_page = (int) $request->get_param( 'per_page' );
|
|
874
|
+
$page = (int) $request->get_param( 'page' );
|
|
875
|
+
$pk = $spec['pk'];
|
|
876
|
+
|
|
877
|
+
// The PK comes from this file, never from input, and is validated against the real column
|
|
878
|
+
// list before it reaches SQL — so an ordering clause can never be attacker-controlled.
|
|
879
|
+
$names = wp_list_pluck( $cols, 'COLUMN_NAME' );
|
|
880
|
+
$order = in_array( $pk, $names, true ) ? $pk : $names[0];
|
|
881
|
+
|
|
882
|
+
$safe = str_replace( '`', '', $full );
|
|
883
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- identifier from information_schema, values are placeholders.
|
|
884
|
+
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$safe}`" );
|
|
885
|
+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
|
886
|
+
$rows = $wpdb->get_results(
|
|
887
|
+
$wpdb->prepare( "SELECT * FROM `{$safe}` ORDER BY `{$order}` ASC LIMIT %d OFFSET %d", $per_page, ( $page - 1 ) * $per_page ),
|
|
888
|
+
ARRAY_A
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
$data = array();
|
|
892
|
+
foreach ( (array) $rows as $row ) {
|
|
893
|
+
foreach ( $spec['json'] as $col ) {
|
|
894
|
+
if ( isset( $row[ $col ] ) && is_string( $row[ $col ] ) && '' !== $row[ $col ] ) {
|
|
895
|
+
$decoded = json_decode( $row[ $col ], true );
|
|
896
|
+
if ( JSON_ERROR_NONE === json_last_error() ) {
|
|
897
|
+
$row[ $col ] = $decoded;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
foreach ( $spec['epoch'] as $col ) {
|
|
902
|
+
if ( isset( $row[ $col ] ) && is_numeric( $row[ $col ] ) && (float) $row[ $col ] > 0 ) {
|
|
903
|
+
$row[ $col . '_at' ] = self::to_iso8601( (float) $row[ $col ] );
|
|
904
|
+
} elseif ( array_key_exists( $col, $row ) ) {
|
|
905
|
+
$row[ $col . '_at' ] = null; // 0 means "never", which is not 1970.
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
$data[] = $row;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
$response = new WP_REST_Response( $data );
|
|
912
|
+
$response->header( 'X-WP-Total', (string) $total );
|
|
913
|
+
$response->header( 'X-WP-TotalPages', (string) ( $per_page > 0 ? (int) ceil( $total / $per_page ) : 0 ) );
|
|
914
|
+
|
|
915
|
+
return $response;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* JSON Schema for a generic table, derived from the LIVE columns rather than a frozen list —
|
|
920
|
+
* these tables' shapes vary by premium version, so a hardcoded schema would misdescribe some
|
|
921
|
+
* sites. Absent table: an empty property set, and the route reports 503 when actually called.
|
|
922
|
+
*
|
|
923
|
+
* @param string $key Key into {@see generic_tables()}.
|
|
924
|
+
* @return array
|
|
925
|
+
*/
|
|
926
|
+
private static function generic_schema( $key ) {
|
|
927
|
+
$spec = self::generic_tables()[ $key ];
|
|
928
|
+
$cols = self::columns_of( self::table( $spec['suffix'] ) );
|
|
929
|
+
$props = array();
|
|
930
|
+
|
|
931
|
+
foreach ( $cols as $c ) {
|
|
932
|
+
$props[ $c['COLUMN_NAME'] ] = array(
|
|
933
|
+
'description' => $c['COLUMN_NAME'] . ' (' . $c['DATA_TYPE'] . ')',
|
|
934
|
+
'type' => self::json_type_for( $c['DATA_TYPE'], in_array( $c['COLUMN_NAME'], $spec['json'], true ) ),
|
|
935
|
+
'readonly' => true,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
foreach ( $spec['epoch'] as $col ) {
|
|
939
|
+
if ( isset( $props[ $col ] ) ) {
|
|
940
|
+
$props[ $col . '_at' ] = array(
|
|
941
|
+
'description' => $col . ' as an ISO-8601 UTC datetime; null when unset.',
|
|
942
|
+
'type' => array( 'string', 'null' ),
|
|
943
|
+
'format' => 'date-time',
|
|
944
|
+
'readonly' => true,
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return array(
|
|
950
|
+
'$schema' => 'http://json-schema.org/draft-04/schema#',
|
|
951
|
+
'title' => $spec['title'],
|
|
952
|
+
'type' => 'object',
|
|
953
|
+
'properties' => $props,
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* MySQL data type to JSON Schema type.
|
|
959
|
+
*
|
|
960
|
+
* @param string $data_type MySQL DATA_TYPE.
|
|
961
|
+
* @param bool $is_json Whether this build decodes the column as JSON.
|
|
962
|
+
* @return string|array
|
|
963
|
+
*/
|
|
964
|
+
private static function json_type_for( $data_type, $is_json ) {
|
|
965
|
+
if ( $is_json ) {
|
|
966
|
+
return array( 'object', 'array', 'string', 'null' );
|
|
967
|
+
}
|
|
968
|
+
switch ( strtolower( $data_type ) ) {
|
|
969
|
+
case 'tinyint':
|
|
970
|
+
case 'smallint':
|
|
971
|
+
case 'mediumint':
|
|
972
|
+
case 'int':
|
|
973
|
+
case 'integer':
|
|
974
|
+
case 'bigint':
|
|
975
|
+
return array( 'integer', 'null' );
|
|
976
|
+
case 'decimal':
|
|
977
|
+
case 'float':
|
|
978
|
+
case 'double':
|
|
979
|
+
return array( 'number', 'null' );
|
|
980
|
+
default:
|
|
981
|
+
return array( 'string', 'null' );
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ─── Schemas (what OPTIONS returns, and what the connector reads fields from) ──
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* @return array
|
|
989
|
+
*/
|
|
990
|
+
public static function get_event_schema() {
|
|
991
|
+
return array(
|
|
992
|
+
'$schema' => 'http://json-schema.org/draft-04/schema#',
|
|
993
|
+
'title' => 'mj_wsal_event',
|
|
994
|
+
'type' => 'object',
|
|
995
|
+
'properties' => array(
|
|
996
|
+
'id' => array( 'description' => __( 'Unique event identifier.', 'mj-wsal-bridge' ), 'type' => 'integer', 'readonly' => true ),
|
|
997
|
+
'site_id' => array( 'description' => __( 'Multisite network site ID (1 on a single site).', 'mj-wsal-bridge' ), 'type' => 'integer', 'readonly' => true ),
|
|
998
|
+
'alert_id' => array( 'description' => __( 'WP Activity Log event type ID.', 'mj-wsal-bridge' ), 'type' => 'integer', 'readonly' => true ),
|
|
999
|
+
'alert_label' => array( 'description' => __( 'Human-readable name of the event type.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1000
|
+
'created_on' => array( 'description' => __( 'Raw Unix timestamp in seconds, as stored.', 'mj-wsal-bridge' ), 'type' => 'number', 'readonly' => true ),
|
|
1001
|
+
'created_at' => array( 'description' => __( 'The same instant as an ISO-8601 UTC datetime.', 'mj-wsal-bridge' ), 'type' => 'string', 'format' => 'date-time', 'readonly' => true ),
|
|
1002
|
+
'severity' => array( 'description' => __( 'Raw numeric severity level as stored: 500, 400, 300, 250 or 200.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1003
|
+
'severity_label' => array( 'description' => __( 'Severity resolved to a name: Critical, High, Medium, Low, Informational or Unknown.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1004
|
+
'object' => array( 'description' => __( 'Subject of the activity, e.g. user or post.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1005
|
+
'event_type' => array( 'description' => __( 'Classification of the activity, e.g. login or modified.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1006
|
+
'username' => array( 'description' => __( 'WordPress username responsible for the event.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1007
|
+
'user_id' => array( 'description' => __( 'WordPress user ID responsible for the event.', 'mj-wsal-bridge' ), 'type' => array( 'integer', 'null' ), 'readonly' => true ),
|
|
1008
|
+
'user_roles' => array( 'description' => __( 'Roles held by the user at the time of the event.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1009
|
+
'client_ip' => array( 'description' => __( 'Source IP address.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1010
|
+
'user_agent' => array( 'description' => __( 'Browser user agent string.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1011
|
+
'session_id' => array( 'description' => __( 'Session the event belongs to.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1012
|
+
'post_id' => array( 'description' => __( 'Associated post ID, 0 when not post-related.', 'mj-wsal-bridge' ), 'type' => 'integer', 'readonly' => true ),
|
|
1013
|
+
'post_type' => array( 'description' => __( 'Associated post type.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1014
|
+
'post_status' => array( 'description' => __( 'Associated post status.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1015
|
+
'meta' => array( 'description' => __( 'Event metadata, pivoted from name/value pairs into an object.', 'mj-wsal-bridge' ), 'type' => 'object', 'readonly' => true ),
|
|
1016
|
+
),
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* @return array
|
|
1022
|
+
*/
|
|
1023
|
+
public static function get_event_type_schema() {
|
|
1024
|
+
return array(
|
|
1025
|
+
'$schema' => 'http://json-schema.org/draft-04/schema#',
|
|
1026
|
+
'title' => 'mj_wsal_event_type',
|
|
1027
|
+
'type' => 'object',
|
|
1028
|
+
'properties' => array(
|
|
1029
|
+
'alert_id' => array( 'description' => __( 'WP Activity Log event type ID.', 'mj-wsal-bridge' ), 'type' => 'integer', 'readonly' => true ),
|
|
1030
|
+
'label' => array( 'description' => __( 'Short human-readable name.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1031
|
+
'message' => array( 'description' => __( 'Message template for the event.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1032
|
+
'severity' => array( 'description' => __( 'Declared severity level.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1033
|
+
'category' => array( 'description' => __( 'Top-level grouping.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1034
|
+
'subcategory' => array( 'description' => __( 'Secondary grouping.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1035
|
+
'object' => array( 'description' => __( 'Subject this event type concerns.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1036
|
+
'event_type' => array( 'description' => __( 'Action this event type represents.', 'mj-wsal-bridge' ), 'type' => 'string', 'readonly' => true ),
|
|
1037
|
+
),
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
MJ_WSAL_Bridge::init();
|
|
1043
|
+
}
|