PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.8.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.8.9
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 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / desktop-files / heartbeat.php

heartbeat.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.8.9, at includes/desktop-files/heartbeat.php

385 lines 12.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Files Heartbeat sync (PHP).
4 *
5 * Piggybacks on the existing WordPress Heartbeat tick — the same
6 * channel `presence.php` uses — so connected clients see folder
7 * sharing changes and other users' placement edits inside one
8 * cross-feature poll instead of N parallel ones.
9 *
10 * Wire format. Client sends `desktop_mode_files_subscribe` keyed
11 * to two version markers:
12 *
13 * {
14 * desktop_mode_files_subscribe: {
15 * folderVersions: { '<folderId>': lastSeenUpdatedAtMs, ... },
16 * placementsVersion: lastSeenUpdatedAtMs
17 * }
18 * }
19 *
20 * Server responds with deltas + tombstones:
21 *
22 * desktop_mode_files: {
23 * placements: [ <RestPlacementShape> ], // upserts
24 * folders: [ <RestFolderShape> ], // upserts (incl. share-mode flips)
25 * removed: {
26 * placements: [ ids ],
27 * folders: [ ids ]
28 * },
29 * serverTimeMs: int,
30 * truncated: bool
31 * }
32 *
33 * Truncation kicks in when more than `desktop_mode_files_heartbeat_max_rows`
34 * (default 200) rows match — clients fall back to a full REST
35 * resync. The default cap is per-payload, not per-folder, so a
36 * massive shared folder doesn't starve other folders' deltas.
37 *
38 * @package WPDesktopMode
39 * @since 0.9.0
40 */
41
42 defined( 'ABSPATH' ) || exit;
43
44 /**
45 * @since 0.9.0
46 *
47 * @param array $response Pre-filtered response.
48 * @param array $data Client-sent payload.
49 * @return array
50 */
51 function desktop_mode_files_heartbeat_received( $response, $data ) {
52 if ( ! is_array( $response ) ) {
53 $response = array();
54 }
55 if ( empty( $data['desktop_mode_files_subscribe'] ) || ! is_array( $data['desktop_mode_files_subscribe'] ) ) {
56 return $response;
57 }
58 if ( ! function_exists( 'desktop_mode_is_enabled' ) || ! desktop_mode_is_enabled() ) {
59 return $response;
60 }
61
62 $sub = $data['desktop_mode_files_subscribe'];
63 $folder_v = isset( $sub['folderVersions'] ) && is_array( $sub['folderVersions'] )
64 ? $sub['folderVersions']
65 : array();
66 $plc_v = isset( $sub['placementsVersion'] ) ? (int) $sub['placementsVersion'] : 0;
67 $shr_v = isset( $sub['sharesVersion'] ) ? (int) $sub['sharesVersion'] : 0;
68
69 $user_id = (int) get_current_user_id();
70 if ( $user_id <= 0 ) {
71 return $response;
72 }
73
74 /**
75 * Filter the per-payload row cap. Lower this on slow links
76 * to force REST fallback sooner; raise it for fast-LAN
77 * intranets where a fatter Heartbeat is fine.
78 *
79 * @since 0.9.0
80 *
81 * @param int $cap Default 200.
82 */
83 $cap = max( 1, (int) apply_filters( 'desktop_mode_files_heartbeat_max_rows', 200 ) );
84
85 $response['desktop_mode_files'] = desktop_mode_files_compute_heartbeat_delta(
86 $user_id,
87 $folder_v,
88 $plc_v,
89 $cap,
90 $shr_v
91 );
92 return $response;
93 }
94 add_filter( 'heartbeat_received', 'desktop_mode_files_heartbeat_received', 5, 2 );
95
96 /**
97 * Compute the delta payload for a viewer.
98 *
99 * @since 0.9.0
100 *
101 * @param int $user_id Viewer.
102 * @param array $folder_versions `{ folderId => lastSeenUpdatedAtMs }`.
103 * @param int $placements_version Last-seen `updated_at_ms` for placements.
104 * @param int $cap Row cap.
105 * @param int $shares_version Last-seen `invited_at_ms` /
106 * `decided_at_ms` for shares. Used to
107 * trim the `shares.pending` payload
108 * to invites the client hasn't seen
109 * yet. Defaults to `0` (deliver all).
110 * @return array
111 */
112 function desktop_mode_files_compute_heartbeat_delta( $user_id, $folder_versions, $placements_version, $cap, $shares_version = 0 ) {
113 global $wpdb;
114
115 $tables = desktop_mode_files_table_names();
116 $truncated = false;
117
118 // 1) Visible folders the viewer should know about. We send
119 // the FULL row when its `updated_at_ms` exceeds whatever
120 // the client last saw (or the client doesn't know about
121 // it at all).
122 $visible = desktop_mode_files_get_visible_folders( $user_id );
123 $folder_upserts = array();
124 foreach ( $visible as $row ) {
125 $id = (int) $row['id'];
126 $client_ts = isset( $folder_versions[ (string) $id ] )
127 ? (int) $folder_versions[ (string) $id ]
128 : 0;
129 if ( (int) $row['updated_at_ms'] > $client_ts ) {
130 $folder_upserts[] = desktop_mode_files_shape_folder( $row );
131 if ( count( $folder_upserts ) >= $cap ) {
132 $truncated = true;
133 break;
134 }
135 }
136 }
137
138 // 2) Placement upserts the viewer can see. We pull anything
139 // written since `placements_version` whose owner is the
140 // viewer (their own desktop) OR which lives in a folder
141 // the viewer can see (shared content).
142 $visible_folder_ids = array_map( static function ( $f ) {
143 return (int) $f['id'];
144 }, $visible );
145 // Always include the desktop root (parent_id=0) for the viewer.
146 $placement_upserts = array();
147 if ( ! $truncated ) {
148 // Owner-or-visible-folder filter, expressed as a SINGLE
149 // `$wpdb->prepare()` call so every value goes through one
150 // pass of escaping. The earlier shape nested an inner
151 // `$wpdb->prepare(...)` for the WHERE inside an outer
152 // `$wpdb->prepare(...)` for the LIMIT/version — that path
153 // works for `%d` integers in practice but is latent-
154 // dangerous because a `%` in the inner output would be
155 // mis-interpreted by the outer prepare. Single-prepare
156 // keeps the contract clean.
157 //
158 // Active placements only — trashed rows leave the visible
159 // surface via the `removed.placements` channel a few lines
160 // down, NOT as upserts. Without this filter a heartbeat tick
161 // fired right after a soft-trash would resurrect the tile in
162 // the client store.
163 if ( empty( $visible_folder_ids ) ) {
164 $rows = $wpdb->get_results(
165 $wpdb->prepare(
166 "SELECT * FROM {$tables['placements']}
167 WHERE owner_id = %d
168 AND updated_at_ms > %d
169 AND trashed_at_ms IS NULL
170 ORDER BY updated_at_ms ASC
171 LIMIT %d",
172 $user_id,
173 $placements_version,
174 $cap
175 ),
176 ARRAY_A
177 );
178 } else {
179 $placeholders = implode( ',', array_fill( 0, count( $visible_folder_ids ), '%d' ) );
180 $args = array_merge(
181 array( $user_id ),
182 array_map( 'intval', $visible_folder_ids ),
183 array( $placements_version, $cap )
184 );
185 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
186 $rows = $wpdb->get_results(
187 $wpdb->prepare(
188 "SELECT * FROM {$tables['placements']}
189 WHERE ( owner_id = %d OR parent_id IN ($placeholders) )
190 AND updated_at_ms > %d
191 AND trashed_at_ms IS NULL
192 ORDER BY updated_at_ms ASC
193 LIMIT %d",
194 $args
195 ),
196 ARRAY_A
197 );
198 }
199 foreach ( (array) $rows as $row ) {
200 $row = desktop_mode_files_normalize_placement_row( $row );
201 // Per-placement read gate: shared folder shouldn't
202 // surface a row the viewer's `can_read()` rejects.
203 $file = desktop_mode_resolve_file( $row['file_type'], $row['file_ref'] );
204 if ( $file && ! $file->can_read( $user_id ) ) {
205 continue;
206 }
207 $placement_upserts[] = desktop_mode_files_shape_placement( $row );
208 }
209 if ( count( $placement_upserts ) >= $cap ) {
210 $truncated = true;
211 }
212 }
213
214 // 3) Tombstones since the last placements_version — gives the
215 // client the "this row is gone" signal.
216 $tomb_rows = $wpdb->get_results(
217 $wpdb->prepare(
218 "SELECT kind, ref_id FROM {$tables['tombstones']} WHERE removed_at_ms > %d ORDER BY removed_at_ms ASC LIMIT %d",
219 $placements_version,
220 $cap
221 ),
222 ARRAY_A
223 );
224 $removed = array( 'placements' => array(), 'folders' => array() );
225 foreach ( (array) $tomb_rows as $row ) {
226 if ( 'folder' === $row['kind'] ) {
227 $removed['folders'][] = (int) $row['ref_id'];
228 } else {
229 $removed['placements'][] = (int) $row['ref_id'];
230 }
231 }
232
233 // 4) Soft-trash events. Tombstones only fire on hard delete, so
234 // a trashed placement / folder would otherwise stay in the
235 // client store between F5s. Surface every row whose
236 // `trashed_at_ms` is fresher than the client's high-water
237 // mark as a `removed.*` entry. Restoring (clearing
238 // `trashed_at_ms`) bumps `updated_at_ms` and the row will
239 // flow back through `placements` / `folders` upserts above.
240 $trashed_placements = $wpdb->get_col(
241 $wpdb->prepare(
242 "SELECT id FROM {$tables['placements']}
243 WHERE trashed_at_ms IS NOT NULL
244 AND trashed_at_ms > %d
245 ORDER BY trashed_at_ms ASC
246 LIMIT %d",
247 $placements_version,
248 $cap
249 )
250 );
251 foreach ( (array) $trashed_placements as $id ) {
252 $removed['placements'][] = (int) $id;
253 }
254 $trashed_folders = $wpdb->get_col(
255 $wpdb->prepare(
256 "SELECT id FROM {$tables['folders']}
257 WHERE trashed_at_ms IS NOT NULL
258 AND trashed_at_ms > %d
259 ORDER BY trashed_at_ms ASC
260 LIMIT %d",
261 $placements_version,
262 $cap
263 )
264 );
265 foreach ( (array) $trashed_folders as $id ) {
266 $removed['folders'][] = (int) $id;
267 }
268
269 // 5) Pending share invites for this viewer (across every folder
270 // they're invited to). Owner-side share-status changes flow
271 // through the folder upserts above; this channel is for the
272 // recipient's "you've been invited" placeholder UI.
273 $shares = array();
274 $sharing_enabled = function_exists( 'desktop_mode_files_sharing_enabled_for' )
275 ? desktop_mode_files_sharing_enabled_for( $user_id )
276 : true;
277 if ( $sharing_enabled && function_exists( 'desktop_mode_files_get_pending_shares_for_user' ) ) {
278 $pending = desktop_mode_files_get_pending_shares_for_user( $user_id, $shares_version );
279 foreach ( $pending as $row ) {
280 $shape = desktop_mode_files_shape_share( $row );
281 $folder = desktop_mode_files_get_folder( $row['folder_id'] );
282 if ( $folder ) {
283 $shape['folderName'] = (string) $folder['name'];
284 $shape['ownerId'] = (int) $folder['owner_id'];
285 $owner_user = get_userdata( (int) $folder['owner_id'] );
286 $shape['ownerName'] = $owner_user ? $owner_user->display_name : '';
287 $shape['ownerAvatar'] = $owner_user ? get_avatar_url( $owner_user->ID, array( 'size' => 48 ) ) : '';
288 }
289 $shares[] = $shape;
290 if ( count( $shares ) >= $cap ) {
291 $truncated = true;
292 break;
293 }
294 }
295 }
296
297 // Safety net: a row that is currently being delivered as an
298 // upsert (alive) must NOT also appear in `removed.*`. Otherwise
299 // the client applies upserts first, then removals, and the
300 // alive row disappears every heartbeat tick.
301 //
302 // This can happen when stale tombstones linger after a
303 // soft-trash → restore cycle (e.g. a recipient leaves a shared
304 // folder, then re-accepts the invite — the placement row is
305 // restored but any tombstones written in error during the trash
306 // path stay in the table). Cleaning them up server-side prevents
307 // the same client-side glitch on every subsequent tick.
308 $upsert_placement_ids = array_map(
309 static function ( $p ) { return (int) $p['id']; },
310 $placement_upserts
311 );
312 $upsert_folder_ids = array_map(
313 static function ( $f ) { return (int) $f['id']; },
314 $folder_upserts
315 );
316 if ( ! empty( $upsert_placement_ids ) ) {
317 $alive_placements = array_flip( $upsert_placement_ids );
318 $removed['placements'] = array_values(
319 array_filter(
320 $removed['placements'],
321 static function ( $id ) use ( $alive_placements ) {
322 return ! isset( $alive_placements[ (int) $id ] );
323 }
324 )
325 );
326 // Cleanup: drop any tombstones referring to placement ids
327 // that are demonstrably alive in this tick. Bounded by the
328 // upsert set so the work is per-tick, not table-wide.
329 desktop_mode_files_purge_stale_tombstones( 'placement', $upsert_placement_ids );
330 }
331 if ( ! empty( $upsert_folder_ids ) ) {
332 $alive_folders = array_flip( $upsert_folder_ids );
333 $removed['folders'] = array_values(
334 array_filter(
335 $removed['folders'],
336 static function ( $id ) use ( $alive_folders ) {
337 return ! isset( $alive_folders[ (int) $id ] );
338 }
339 )
340 );
341 desktop_mode_files_purge_stale_tombstones( 'folder', $upsert_folder_ids );
342 }
343
344 return array(
345 'placements' => $placement_upserts,
346 'folders' => $folder_upserts,
347 'removed' => $removed,
348 'shares' => array(
349 'pending' => $shares,
350 ),
351 'serverTimeMs' => desktop_mode_files_now_ms(),
352 'truncated' => $truncated,
353 );
354 }
355
356 /**
357 * Delete tombstones for refs that are currently alive (still
358 * present in the placements / folders table without
359 * `trashed_at_ms`). One-shot cleanup of stale rows written by
360 * earlier buggy code paths — once removed, the heartbeat no longer
361 * surfaces them every tick.
362 *
363 * @since 0.18.0
364 *
365 * @param string $kind 'placement' | 'folder'.
366 * @param int[] $ids Ids known to be alive in the current tick.
367 */
368 function desktop_mode_files_purge_stale_tombstones( $kind, $ids ) {
369 if ( empty( $ids ) ) {
370 return;
371 }
372 global $wpdb;
373 $tables = desktop_mode_files_table_names();
374 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
375 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
376 $wpdb->query(
377 $wpdb->prepare(
378 "DELETE FROM {$tables['tombstones']}
379 WHERE kind = %s
380 AND ref_id IN ($placeholders)",
381 array_merge( array( (string) $kind ), array_map( 'intval', $ids ) )
382 )
383 );
384 }
385