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

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