PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
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.9.6, at includes/desktop-files/heartbeat.php

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