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

402 lines 13.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — 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 `openstation_files_subscribe` keyed
11 * to three version markers:
12 *
13 * {
14 * openstation_files_subscribe: {
15 * folderVersions: { '<folderId>': lastSeenUpdatedAtMs, ... },
16 * placementsVersion: lastSeenUpdatedAtMs,
17 * sharesVersion: lastSeenInvitedAtMs
18 * }
19 * }
20 *
21 * Server responds with deltas + tombstones:
22 *
23 * openstation_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 `openstation_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 OpenStation
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 openstation_files_heartbeat_received( $response, $data ) {
53 if ( ! is_array( $response ) ) {
54 $response = array();
55 }
56 if ( empty( $data['openstation_files_subscribe'] ) || ! is_array( $data['openstation_files_subscribe'] ) ) {
57 return $response;
58 }
59 if ( ! function_exists( 'openstation_is_enabled' ) || ! openstation_is_enabled() ) {
60 return $response;
61 }
62
63 $sub = $data['openstation_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( 'openstation_files_heartbeat_max_rows', 200 ) );
83
84 $response['openstation_files'] = openstation_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', 'openstation_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 openstation_files_compute_heartbeat_delta( $user_id, $folder_versions, $placements_version, $cap, $shares_version = 0 ) {
110 global $wpdb;
111
112 $tables = openstation_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 = openstation_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[] = openstation_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(
140 static function ( $f ) {
141 return (int) $f['id'];
142 },
143 $visible
144 );
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 = openstation_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 = openstation_resolve_file( $row['file_type'], $row['file_ref'] );
204 if ( $file && ! $file->can_read( $user_id ) ) {
205 continue;
206 }
207 $placement_upserts[] = openstation_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(
225 'placements' => array(),
226 'folders' => array(),
227 );
228 foreach ( (array) $tomb_rows as $row ) {
229 if ( 'folder' === $row['kind'] ) {
230 $removed['folders'][] = (int) $row['ref_id'];
231 } else {
232 $removed['placements'][] = (int) $row['ref_id'];
233 }
234 }
235
236 // 4) Soft-trash events. Tombstones only fire on hard delete, so
237 // a trashed placement / folder would otherwise stay in the
238 // client store between F5s. Surface every row whose
239 // `trashed_at_ms` is fresher than the client's high-water
240 // mark as a `removed.*` entry. Restoring (clearing
241 // `trashed_at_ms`) bumps `updated_at_ms` and the row will
242 // flow back through `placements` / `folders` upserts above.
243 $trashed_placements = $wpdb->get_col(
244 $wpdb->prepare(
245 "SELECT id FROM {$tables['placements']}
246 WHERE trashed_at_ms IS NOT NULL
247 AND trashed_at_ms > %d
248 ORDER BY trashed_at_ms ASC
249 LIMIT %d",
250 $placements_version,
251 $cap
252 )
253 );
254 foreach ( (array) $trashed_placements as $id ) {
255 $removed['placements'][] = (int) $id;
256 }
257 $trashed_folders = $wpdb->get_col(
258 $wpdb->prepare(
259 "SELECT id FROM {$tables['folders']}
260 WHERE trashed_at_ms IS NOT NULL
261 AND trashed_at_ms > %d
262 ORDER BY trashed_at_ms ASC
263 LIMIT %d",
264 $placements_version,
265 $cap
266 )
267 );
268 foreach ( (array) $trashed_folders as $id ) {
269 $removed['folders'][] = (int) $id;
270 }
271
272 // 5) Pending share invites for this viewer (across every folder
273 // they're invited to). Owner-side share-status changes flow
274 // through the folder upserts above; this channel is for the
275 // recipient's "you've been invited" placeholder UI.
276 $shares = array();
277 $sharing_enabled = function_exists( 'openstation_files_sharing_enabled_for' )
278 ? openstation_files_sharing_enabled_for( $user_id )
279 : true;
280 if ( $sharing_enabled && function_exists( 'openstation_files_get_pending_shares_for_user' ) ) {
281 $pending = openstation_files_get_pending_shares_for_user( $user_id, $shares_version );
282 foreach ( $pending as $row ) {
283 $shape = openstation_files_shape_share( $row );
284 $folder = openstation_files_get_folder( $row['folder_id'] );
285 if ( $folder ) {
286 $shape['folderName'] = (string) $folder['name'];
287 $shape['ownerId'] = (int) $folder['owner_id'];
288 $owner_user = get_userdata( (int) $folder['owner_id'] );
289 $shape['ownerName'] = $owner_user ? $owner_user->display_name : '';
290 $shape['ownerAvatar'] = $owner_user ? get_avatar_url( $owner_user->ID, array( 'size' => 48 ) ) : '';
291 }
292 $shares[] = $shape;
293 if ( count( $shares ) >= $cap ) {
294 $truncated = true;
295 break;
296 }
297 }
298 }
299 // Pending FILE-share invites ride the same channel. Shapes carry
300 // `targetType: 'file'` + `fileId` / `fileName` so the client
301 // invite banner can branch (folder shapes have no targetType and
302 // default to folder handling).
303 if ( $sharing_enabled && ! $truncated && function_exists( 'openstation_files_get_pending_file_shares_for_user' ) ) {
304 $pending_files = openstation_files_get_pending_file_shares_for_user( $user_id, $shares_version );
305 foreach ( $pending_files as $row ) {
306 $shares[] = openstation_files_shape_file_share( $row );
307 if ( count( $shares ) >= $cap ) {
308 $truncated = true;
309 break;
310 }
311 }
312 }
313
314 // Safety net: a row that is currently being delivered as an
315 // upsert (alive) must NOT also appear in `removed.*`. Otherwise
316 // the client applies upserts first, then removals, and the
317 // alive row disappears every heartbeat tick.
318 //
319 // This can happen when stale tombstones linger after a
320 // soft-trash → restore cycle (e.g. a recipient leaves a shared
321 // folder, then re-accepts the invite — the placement row is
322 // restored but any tombstones written in error during the trash
323 // path stay in the table). Cleaning them up server-side prevents
324 // the same client-side glitch on every subsequent tick.
325 $upsert_placement_ids = array_map(
326 static function ( $p ) {
327 return (int) $p['id']; },
328 $placement_upserts
329 );
330 $upsert_folder_ids = array_map(
331 static function ( $f ) {
332 return (int) $f['id']; },
333 $folder_upserts
334 );
335 if ( ! empty( $upsert_placement_ids ) ) {
336 $alive_placements = array_flip( $upsert_placement_ids );
337 $removed['placements'] = array_values(
338 array_filter(
339 $removed['placements'],
340 static function ( $id ) use ( $alive_placements ) {
341 return ! isset( $alive_placements[ (int) $id ] );
342 }
343 )
344 );
345 // Cleanup: drop any tombstones referring to placement ids
346 // that are demonstrably alive in this tick. Bounded by the
347 // upsert set so the work is per-tick, not table-wide.
348 openstation_files_purge_stale_tombstones( 'placement', $upsert_placement_ids );
349 }
350 if ( ! empty( $upsert_folder_ids ) ) {
351 $alive_folders = array_flip( $upsert_folder_ids );
352 $removed['folders'] = array_values(
353 array_filter(
354 $removed['folders'],
355 static function ( $id ) use ( $alive_folders ) {
356 return ! isset( $alive_folders[ (int) $id ] );
357 }
358 )
359 );
360 openstation_files_purge_stale_tombstones( 'folder', $upsert_folder_ids );
361 }
362
363 return array(
364 'placements' => $placement_upserts,
365 'folders' => $folder_upserts,
366 'removed' => $removed,
367 'shares' => array(
368 'pending' => $shares,
369 ),
370 'serverTimeMs' => openstation_files_now_ms(),
371 'truncated' => $truncated,
372 );
373 }
374
375 /**
376 * Delete tombstones for refs that are currently alive (still
377 * present in the placements / folders table without
378 * `trashed_at_ms`). One-shot cleanup of stale rows written by
379 * earlier buggy code paths — once removed, the heartbeat no longer
380 * surfaces them every tick.
381 *
382 * @param string $kind 'placement' | 'folder'.
383 * @param int[] $ids Ids known to be alive in the current tick.
384 */
385 function openstation_files_purge_stale_tombstones( $kind, $ids ) {
386 if ( empty( $ids ) ) {
387 return;
388 }
389 global $wpdb;
390 $tables = openstation_files_table_names();
391 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
392 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
393 $wpdb->query(
394 $wpdb->prepare(
395 "DELETE FROM {$tables['tombstones']}
396 WHERE kind = %s
397 AND ref_id IN ($placeholders)",
398 array_merge( array( (string) $kind ), array_map( 'intval', $ids ) )
399 )
400 );
401 }
402