PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
← All changes | includes/desktop-files/store.php +218 -200 0.9.2 → 1.1.13 View file →
@@ -1,10 +1,10 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — Files placement store.
3 + * OpenStation — Files placement store.
4 4 *
5 5 * CRUD primitives for the `_desktop_mode_file_placements` table.
6 - * Every read goes through `desktop_mode_files_query_args` so
6 + * Every read goes through `openstation_files_query_args` so
7 7 * plugins can scope what's visible (mirror of the recycle-bin's
8 8 * filter pattern). Every write fires before / after actions so
9 9 * other plugins can react and so Phase 6's Heartbeat sync has a
10 10 * single subscription point.
@@ -10,16 +10,17 @@
10 10 * single subscription point.
11 11 *
12 12 * Capability gate is per-call: callers pass the `$user_id` they
13 13 * intend to act for; the function consults
14 - * `desktop_mode_files_can_place` (filter) and the file's
15 - * `Desktop_Mode_File::can_read()` before writing.
14 + * `openstation_files_can_place` (filter) and the file's
15 + * `OpenStation_File::can_read()` before writing.
16 16 *
17 - * Tombstones are written for every successful remove / move-out
18 - * so the Phase-6 Heartbeat delta knows what to send.
17 + * Tombstones are written only for permanent removals (hard
18 + * deletes); soft-trash and moves are surfaced to clients via
19 + * `updated_at_ms` / `trashed_at_ms` in the Heartbeat delta — see
20 + * openstation_files_write_tombstone() for the invariant.
19 21 *
20 - * @package WPDesktopMode
21 - * @since 0.9.0
22 + * @package OpenStation
22 23 */
23 24
24 25 defined( 'ABSPATH' ) || exit;
25 26
@@ -25,10 +26,8 @@
25 26
26 27 /**
27 28 * Insert a placement.
28 29 *
29 - * @since 0.9.0
30 - *
31 30 * @param int $user_id Owner of the placement (the user the
32 31 * tile lives on).
33 32 * @param int $parent_id Folder id, or 0 for the desktop root.
34 33 * @param string $type File-type slug.
@@ -35,9 +34,9 @@
35 34 * @param string $ref Entity reference.
36 35 * @param array $args Optional. `x`, `y`, `sort_order`, `meta`.
37 36 * @return int|WP_Error Placement id on success, `WP_Error` otherwise.
38 37 */
39 -function desktop_mode_files_place( $user_id, $parent_id, $type, $ref, $args = array() ) {
38 +function openstation_files_place( $user_id, $parent_id, $type, $ref, $args = array() ) {
40 39 global $wpdb;
41 40
42 41 $user_id = (int) $user_id;
43 42 $parent_id = (int) $parent_id;
@@ -44,13 +43,13 @@
44 43 $type = (string) $type;
45 44 $ref = (string) $ref;
46 45
47 46 if ( $user_id <= 0 ) {
48 - return new WP_Error( 'desktop_mode_files_invalid_user', __( 'A user id is required.', 'desktop-mode' ), array( 'status' => 400 ) );
47 + return new WP_Error( 'openstation_files_invalid_user', __( 'A user id is required.', 'desktop-mode' ), array( 'status' => 400 ) );
49 48 }
50 - $entry = desktop_mode_get_file_type( $type );
49 + $entry = openstation_get_file_type( $type );
51 50 if ( ! $entry ) {
52 - return new WP_Error( 'desktop_mode_files_unknown_type', __( 'Unknown file type.', 'desktop-mode' ), array( 'status' => 400 ) );
51 + return new WP_Error( 'openstation_files_unknown_type', __( 'Unknown file type.', 'desktop-mode' ), array( 'status' => 400 ) );
53 52 }
54 53
55 54 /**
56 55 * Gate placement creation. Defaults to allowing the user to
@@ -56,20 +55,18 @@
56 55 * Gate placement creation. Defaults to allowing the user to
57 56 * place any type they can read; plugins use this to enforce
58 57 * stricter rules (e.g. only admins may place users).
59 58 *
60 - * @since 0.9.0
61 - *
62 59 * @param bool $can Default: file's `can_read( $user_id )`.
63 60 * @param int $user_id Owner.
64 61 * @param string $type File-type slug.
65 62 * @param string $ref Entity reference.
66 63 */
67 - $file = desktop_mode_resolve_file( $type, $ref );
64 + $file = openstation_resolve_file( $type, $ref );
68 65 $can = $file ? $file->can_read( $user_id ) : false;
69 - $can = (bool) apply_filters( 'desktop_mode_files_can_place', $can, $user_id, $type, $ref );
66 + $can = (bool) apply_filters( 'openstation_files_can_place', $can, $user_id, $type, $ref );
70 67 if ( ! $can ) {
71 - return new WP_Error( 'desktop_mode_files_forbidden', __( 'You are not allowed to place this file.', 'desktop-mode' ), array( 'status' => 403 ) );
68 + return new WP_Error( 'openstation_files_forbidden', __( 'You are not allowed to place this file.', 'desktop-mode' ), array( 'status' => 403 ) );
72 69 }
73 70
74 71 // Write-gate: placing INTO a non-owned folder requires the
75 72 // folder's `write` cap. Owner / desktop-root placements are
@@ -74,16 +71,16 @@
74 71 // Write-gate: placing INTO a non-owned folder requires the
75 72 // folder's `write` cap. Owner / desktop-root placements are
76 73 // always allowed.
77 74 if ( (int) $parent_id > 0 ) {
78 - $target_folder = desktop_mode_files_get_folder( (int) $parent_id );
75 + $target_folder = openstation_files_get_folder( (int) $parent_id );
79 76 if ( $target_folder && (int) $target_folder['owner_id'] !== $user_id ) {
80 - $cap = function_exists( 'desktop_mode_folder_share_user_capability' )
81 - ? desktop_mode_folder_share_user_capability( (int) $parent_id, $user_id )
77 + $cap = function_exists( 'openstation_folder_share_user_capability' )
78 + ? openstation_folder_share_user_capability( (int) $parent_id, $user_id )
82 79 : 'none';
83 80 if ( 'write' !== $cap ) {
84 81 return new WP_Error(
85 - 'desktop_mode_files_no_write_in_shared_folder',
82 + 'openstation_files_no_write_in_shared_folder',
86 83 __( 'You only have read access to that folder.', 'desktop-mode' ),
87 84 array( 'status' => 403 )
88 85 );
89 86 }
@@ -99,10 +96,10 @@
99 96 'meta' => null,
100 97 )
101 98 );
102 99
103 - $tables = desktop_mode_files_table_names();
104 - $now = desktop_mode_files_now_ms();
100 + $tables = openstation_files_table_names();
101 + $now = openstation_files_now_ms();
105 102 $row = array(
106 103 'owner_id' => $user_id,
107 104 'updated_by' => $user_id,
108 105 'parent_id' => max( 0, $parent_id ),
@@ -121,21 +118,30 @@
121 118 // `<div class="wpdberror">…</div>` to the REST response body and
122 119 // break `await response.json()` on the client. `$wpdb->last_error`
123 120 // still holds the message, so genuine DB failures surface via the
124 121 // `WP_Error` we return when no existing row is found.
125 - $prev_suppress = $wpdb->suppress_errors( true );
126 - $ok = $wpdb->insert( $tables['placements'], $row, array( '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%d', '%s' ) );
127 - $wpdb->suppress_errors( $prev_suppress );
122 + $insert = static function () use ( $tables, $row ) {
123 + global $wpdb;
124 + $prev_suppress = $wpdb->suppress_errors( true );
125 + $ok = $wpdb->insert( $tables['placements'], $row, array( '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%d', '%s' ) );
126 + $wpdb->suppress_errors( $prev_suppress );
127 + return array( $ok, (int) $wpdb->insert_id );
128 + };
129 + $result = 'upload' === $type ? openstation_stored_files_place_insert( $ref, $insert ) : $insert();
130 + if ( is_wp_error( $result ) ) {
131 + return $result;
132 + }
133 + list( $ok, $id ) = $result;
128 134 if ( false === $ok ) {
129 135 // Disambiguate the two cases hidden behind a generic `false`:
130 - // (a) The `placement_unique` index (since 0.8.0) collided
131 - // with an existing row for this (user, parent, type,
132 - // ref). The collider may be active (the orphan placer
133 - // won a race against this caller, or a stale duplicate
134 - // client request) or soft-trashed (the user removed a
135 - // link tile and is now recreating the same URL).
136 - // (b) Any other DB failure — connection, deadlock, bad
137 - // column. The error must surface to the caller as-is.
136 + // (a) The `placement_unique` index collided
137 + // with an existing row for this (user, parent, type,
138 + // ref). The collider may be active (the orphan placer
139 + // won a race against this caller, or a stale duplicate
140 + // client request) or soft-trashed (the user removed a
141 + // link tile and is now recreating the same URL).
142 + // (b) Any other DB failure — connection, deadlock, bad
143 + // column. The error must surface to the caller as-is.
138 144 // We treat (a) idempotently: restore if trashed, then apply
139 145 // the caller's coords / meta so the new placement lands
140 146 // where the user clicked. Reported as #167.
141 147 $existing = $wpdb->get_row(
@@ -153,15 +159,15 @@
153 159 ),
154 160 ARRAY_A
155 161 );
156 162 if ( ! $existing ) {
157 - return new WP_Error( 'desktop_mode_files_insert_failed', __( 'Failed to write placement.', 'desktop-mode' ), array( 'status' => 500 ) );
163 + return new WP_Error( 'openstation_files_insert_failed', __( 'Failed to write placement.', 'desktop-mode' ), array( 'status' => 500 ) );
158 164 }
159 165
160 166 $existing_id = (int) $existing['id'];
161 167
162 168 if ( ! empty( $existing['trashed_at_ms'] ) ) {
163 - $restore = desktop_mode_files_restore_placement( $user_id, $existing_id );
169 + $restore = openstation_files_restore_placement( $user_id, $existing_id );
164 170 if ( is_wp_error( $restore ) ) {
165 171 return $restore;
166 172 }
167 173 }
@@ -171,11 +177,11 @@
171 177 // stale tombstones for this id should be cleared so a fresh
172 178 // heartbeat tick can't surface "alive + removed" together.
173 179 // `restore_placement` already clears its own tombstones, so
174 180 // this is a no-op in the soft-trashed branch above.
175 - desktop_mode_files_clear_tombstones_for( 'placement', $existing_id );
181 + openstation_files_clear_tombstones_for( 'placement', $existing_id );
176 182
177 - $move = desktop_mode_files_move(
183 + $move = openstation_files_move(
178 184 $existing_id,
179 185 $user_id,
180 186 array(
181 187 'parent_id' => max( 0, $parent_id ),
@@ -190,50 +196,56 @@
190 196 }
191 197
192 198 return $existing_id;
193 199 }
194 - $id = (int) $wpdb->insert_id;
195 -
196 200 $row['id'] = $id;
197 201
198 202 /**
199 203 * Fires after a placement is created.
200 204 *
201 - * @since 0.9.0
202 - *
203 205 * @param int $id Placement id.
204 206 * @param array $row Inserted row.
205 207 */
206 - do_action( 'desktop_mode_file_placed', $id, $row );
208 + do_action( 'openstation_file_placed', $id, $row );
207 209
208 210 return $id;
209 211 }
210 212
211 213 /**
212 - * Move / mutate a placement. Pass `null` for fields that should
213 - * stay untouched.
214 + * Move / mutate a placement. Omit keys that should stay untouched.
215 + * For `parent_id`, `x`, `y`, `sort_order` a `null` value is treated
216 + * the same as omitting the key; for `meta`, an explicit
217 + * `meta => null` CLEARS the column (keyed on array_key_exists) —
218 + * omit the key to preserve it.
214 219 *
215 - * @since 0.9.0
216 - *
217 220 * @param int $placement_id Placement id.
218 221 * @param int $user_id Acting user (for capability gate).
219 222 * @param array $changes `parent_id`, `x`, `y`, `sort_order`, `meta`.
220 223 * @return true|WP_Error
221 224 */
222 -function desktop_mode_files_move( $placement_id, $user_id, $changes = array() ) {
225 +function openstation_files_move( $placement_id, $user_id, $changes = array() ) {
223 226 global $wpdb;
224 227
225 228 $placement_id = (int) $placement_id;
226 229 $user_id = (int) $user_id;
227 230 if ( $placement_id <= 0 || $user_id <= 0 ) {
228 - return new WP_Error( 'desktop_mode_files_bad_request', __( 'Invalid arguments.', 'desktop-mode' ), array( 'status' => 400 ) );
231 + return new WP_Error( 'openstation_files_bad_request', __( 'Invalid arguments.', 'desktop-mode' ), array( 'status' => 400 ) );
229 232 }
230 233
231 - $row = desktop_mode_files_get_placement( $placement_id );
234 + $row = openstation_files_get_placement( $placement_id );
232 235 if ( ! $row ) {
233 - return new WP_Error( 'desktop_mode_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
236 + return new WP_Error( 'openstation_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
234 237 }
235 238
239 + // Owner-lock for `upload` placements: only the stored file's
240 + // owner may move them — folder-share write capability does NOT
241 + // extend to uploaded files (recipients are read + download
242 + // only; see stored-files-store.php).
243 + $upload_lock = openstation_files_upload_owner_lock( $row, $user_id );
244 + if ( is_wp_error( $upload_lock ) ) {
245 + return $upload_lock;
246 + }
247 +
236 248 // Permission check. Owner of the row is always allowed. For
237 249 // rows inside a shared folder, the FOLDER's write cap is the
238 250 // gate — anyone with write on the folder can move/rearrange
239 251 // every icon in it, regardless of which user originally placed
@@ -239,17 +251,17 @@
239 251 // every icon in it, regardless of which user originally placed
240 252 // the row (shared-namespace semantics).
241 253 $is_row_owner = (int) $row['owner_id'] === $user_id;
242 254 if ( (int) $row['parent_id'] > 0 ) {
243 - $source_folder = desktop_mode_files_get_folder( (int) $row['parent_id'] );
255 + $source_folder = openstation_files_get_folder( (int) $row['parent_id'] );
244 256 if ( $source_folder ) {
245 257 $is_folder_owner = (int) $source_folder['owner_id'] === $user_id;
246 - $source_cap = function_exists( 'desktop_mode_folder_share_user_capability' )
247 - ? desktop_mode_folder_share_user_capability( (int) $row['parent_id'], $user_id )
258 + $source_cap = function_exists( 'openstation_folder_share_user_capability' )
259 + ? openstation_folder_share_user_capability( (int) $row['parent_id'], $user_id )
248 260 : 'none';
249 261 if ( ! $is_row_owner && ! $is_folder_owner && 'write' !== $source_cap ) {
250 262 return new WP_Error(
251 - 'desktop_mode_files_no_write_in_shared_folder',
263 + 'openstation_files_no_write_in_shared_folder',
252 264 __( 'You only have read access to this folder.', 'desktop-mode' ),
253 265 array( 'status' => 403 )
254 266 );
255 267 }
@@ -255,9 +267,9 @@
255 267 }
256 268 // Folder reader on their own row inside the folder — still no.
257 269 if ( $is_row_owner && ! $is_folder_owner && 'write' !== $source_cap ) {
258 270 return new WP_Error(
259 - 'desktop_mode_files_no_write_in_shared_folder',
271 + 'openstation_files_no_write_in_shared_folder',
260 272 __( 'You only have read access to this folder.', 'desktop-mode' ),
261 273 array( 'status' => 403 )
262 274 );
263 275 }
@@ -263,21 +275,21 @@
263 275 }
264 276 }
265 277 } elseif ( ! $is_row_owner ) {
266 278 // Row at root, viewer doesn't own it.
267 - return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot edit this placement.', 'desktop-mode' ), array( 'status' => 403 ) );
279 + return new WP_Error( 'openstation_files_forbidden', __( 'You cannot edit this placement.', 'desktop-mode' ), array( 'status' => 403 ) );
268 280 }
269 281 if ( isset( $changes['parent_id'] ) ) {
270 282 $target_parent = max( 0, (int) $changes['parent_id'] );
271 283 if ( $target_parent > 0 ) {
272 - $target = desktop_mode_files_get_folder( $target_parent );
284 + $target = openstation_files_get_folder( $target_parent );
273 285 if ( $target && (int) $target['owner_id'] !== $user_id ) {
274 - $cap = function_exists( 'desktop_mode_folder_share_user_capability' )
275 - ? desktop_mode_folder_share_user_capability( $target_parent, $user_id )
286 + $cap = function_exists( 'openstation_folder_share_user_capability' )
287 + ? openstation_folder_share_user_capability( $target_parent, $user_id )
276 288 : 'none';
277 289 if ( 'write' !== $cap ) {
278 290 return new WP_Error(
279 - 'desktop_mode_files_no_write_in_shared_folder',
291 + 'openstation_files_no_write_in_shared_folder',
280 292 __( 'You only have read access to that folder.', 'desktop-mode' ),
281 293 array( 'status' => 403 )
282 294 );
283 295 }
@@ -294,9 +306,9 @@
294 306 if ( 'folder' === (string) $row['file_type'] && $target_parent > 0 ) {
295 307 $moving_folder_id = (int) $row['file_ref'];
296 308 if ( $moving_folder_id > 0 ) {
297 309 if (
298 - desktop_mode_files_would_create_folder_cycle(
310 + openstation_files_would_create_folder_cycle(
299 311 $user_id,
300 312 $moving_folder_id,
301 313 $target_parent
302 314 )
@@ -301,9 +313,9 @@
301 313 $target_parent
302 314 )
303 315 ) {
304 316 return new WP_Error(
305 - 'desktop_mode_files_folder_cycle',
317 + 'openstation_files_folder_cycle',
306 318 __( 'A folder cannot be placed inside itself or one of its descendants.', 'desktop-mode' ),
307 319 array( 'status' => 409 )
308 320 );
309 321 }
@@ -310,9 +322,9 @@
310 322 }
311 323 }
312 324 }
313 325
314 - $tables = desktop_mode_files_table_names();
326 + $tables = openstation_files_table_names();
315 327 $set = array();
316 328 $fmt = array();
317 329
318 330 if ( isset( $changes['parent_id'] ) ) {
@@ -332,33 +344,31 @@
332 344 if ( empty( $set ) ) {
333 345 return true; // No-op.
334 346 }
335 347
336 - $set['updated_at_ms'] = desktop_mode_files_now_ms();
348 + $set['updated_at_ms'] = openstation_files_now_ms();
337 349 $fmt[] = '%d';
338 350 // Track who actually fired this mutation so a future
339 351 // `If-Match` 409 can name the session that won the race,
340 352 // not just whoever happens to own the row.
341 - $set['updated_by'] = $user_id;
342 - $fmt[] = '%d';
353 + $set['updated_by'] = $user_id;
354 + $fmt[] = '%d';
343 355
344 356 $ok = $wpdb->update( $tables['placements'], $set, array( 'id' => $placement_id ), $fmt, array( '%d' ) );
345 357 if ( false === $ok ) {
346 - return new WP_Error( 'desktop_mode_files_update_failed', __( 'Failed to update placement.', 'desktop-mode' ), array( 'status' => 500 ) );
358 + return new WP_Error( 'openstation_files_update_failed', __( 'Failed to update placement.', 'desktop-mode' ), array( 'status' => 500 ) );
347 359 }
348 360
349 - $next = desktop_mode_files_get_placement( $placement_id );
361 + $next = openstation_files_get_placement( $placement_id );
350 362
351 363 /**
352 364 * Fires after a placement is moved / mutated.
353 365 *
354 - * @since 0.9.0
355 - *
356 366 * @param int $id Placement id.
357 367 * @param array $next Row after the change.
358 368 * @param array $prev Row before the change.
359 369 */
360 - do_action( 'desktop_mode_file_moved', $placement_id, $next, $row );
370 + do_action( 'openstation_file_moved', $placement_id, $next, $row );
361 371
362 372 return true;
363 373 }
364 374
@@ -364,69 +374,104 @@
364 374
365 375 /**
366 376 * Remove a placement. Writes a tombstone for Phase-6 sync.
367 377 *
368 - * @since 0.9.0
369 - *
370 378 * @param int $placement_id Placement id.
371 379 * @param int $user_id Acting user.
372 380 * @return true|WP_Error
373 381 */
374 -function desktop_mode_files_remove( $placement_id, $user_id ) {
382 +function openstation_files_remove( $placement_id, $user_id ) {
375 383 global $wpdb;
376 384
377 385 $placement_id = (int) $placement_id;
378 386 $user_id = (int) $user_id;
379 - $row = desktop_mode_files_get_placement( $placement_id );
387 + $row = openstation_files_get_placement( $placement_id );
380 388 if ( ! $row ) {
381 - return new WP_Error( 'desktop_mode_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
389 + return new WP_Error( 'openstation_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
382 390 }
391 + // Owner-lock for `upload` placements — removal is destructive
392 + // for real bytes, so only the stored file's owner may do it.
393 + $upload_lock = openstation_files_upload_owner_lock( $row, $user_id );
394 + if ( is_wp_error( $upload_lock ) ) {
395 + return $upload_lock;
396 + }
383 397 // Same shared-namespace rule as the trash gate: owner of the
384 398 // row OR write cap on the parent folder.
385 399 $is_row_owner = (int) $row['owner_id'] === $user_id;
386 400 $allowed = $is_row_owner;
387 401 if ( ! $allowed && (int) $row['parent_id'] > 0 ) {
388 - $cap = function_exists( 'desktop_mode_folder_share_user_capability' )
389 - ? desktop_mode_folder_share_user_capability( (int) $row['parent_id'], $user_id )
402 + $cap = function_exists( 'openstation_folder_share_user_capability' )
403 + ? openstation_folder_share_user_capability( (int) $row['parent_id'], $user_id )
390 404 : 'none';
391 405 $allowed = 'write' === $cap;
392 406 }
393 407 if ( ! $allowed ) {
394 - return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot remove this placement.', 'desktop-mode' ), array( 'status' => 403 ) );
408 + return new WP_Error( 'openstation_files_forbidden', __( 'You cannot remove this placement.', 'desktop-mode' ), array( 'status' => 403 ) );
395 409 }
396 410
397 - $tables = desktop_mode_files_table_names();
411 + $tables = openstation_files_table_names();
398 412 $ok = $wpdb->delete( $tables['placements'], array( 'id' => $placement_id ), array( '%d' ) );
399 413 if ( false === $ok ) {
400 - return new WP_Error( 'desktop_mode_files_delete_failed', __( 'Failed to remove placement.', 'desktop-mode' ), array( 'status' => 500 ) );
414 + return new WP_Error( 'openstation_files_delete_failed', __( 'Failed to remove placement.', 'desktop-mode' ), array( 'status' => 500 ) );
401 415 }
402 416
403 - desktop_mode_files_write_tombstone( 'placement', $placement_id );
417 + openstation_files_write_tombstone( 'placement', $placement_id );
404 418
405 419 /**
406 420 * Fires after a placement is removed.
407 421 *
408 - * @since 0.9.0
409 - *
410 422 * @param int $id Placement id.
411 423 * @param array $row Removed row.
412 424 */
413 - do_action( 'desktop_mode_file_unplaced', $placement_id, $row );
425 + do_action( 'openstation_file_unplaced', $placement_id, $row );
414 426
415 427 return true;
416 428 }
417 429
418 430 /**
431 + * Owner-lock gate for `upload` placements. Returns a `WP_Error`
432 + * when `$user_id` is NOT the underlying stored file's owner —
433 + * uploaded files are immutable to everyone else, including folder
434 + * write-collaborators (the deliberate divergence from the shared-
435 + * namespace rule; recipients are read + download only). Returns
436 + * `true` for every other file type, and falls back to the normal
437 + * rules when the stored-file row is gone (dangling tiles must stay
438 + * cleanable).
439 + *
440 + * @param array $row Placement row.
441 + * @param int $user_id Acting user.
442 + * @return true|WP_Error
443 + */
444 +function openstation_files_upload_owner_lock( $row, $user_id ) {
445 + if ( ! is_array( $row ) || 'upload' !== (string) ( $row['file_type'] ?? '' ) ) {
446 + return true;
447 + }
448 + if ( ! function_exists( 'openstation_stored_files_get' ) ) {
449 + return true;
450 + }
451 + $stored = openstation_stored_files_get( (int) $row['file_ref'] );
452 + if ( ! $stored ) {
453 + return true;
454 + }
455 + if ( (int) $stored['owner_id'] === (int) $user_id ) {
456 + return true;
457 + }
458 + return new WP_Error(
459 + 'openstation_files_upload_owner_locked',
460 + __( 'Only the file’s owner can move or delete an uploaded file.', 'desktop-mode' ),
461 + array( 'status' => 403 )
462 + );
463 +}
464 +
465 +/**
419 466 * Read a single placement row by id.
420 467 *
421 - * @since 0.9.0
422 - *
423 468 * @param int $placement_id Placement id.
424 469 * @return array|null
425 470 */
426 -function desktop_mode_files_get_placement( $placement_id ) {
471 +function openstation_files_get_placement( $placement_id ) {
427 472 global $wpdb;
428 - $tables = desktop_mode_files_table_names();
473 + $tables = openstation_files_table_names();
429 474 $row = $wpdb->get_row(
430 475 $wpdb->prepare( "SELECT * FROM {$tables['placements']} WHERE id = %d", (int) $placement_id ),
431 476 ARRAY_A
432 477 );
@@ -432,23 +477,21 @@
432 477 );
433 478 if ( ! $row ) {
434 479 return null;
435 480 }
436 - return desktop_mode_files_normalize_placement_row( $row );
481 + return openstation_files_normalize_placement_row( $row );
437 482 }
438 483
439 484 /**
440 485 * List placements for a user under a given folder (0 = desktop
441 - * root). Honors the `desktop_mode_files_query_args` filter and
486 + * root). Honors the `openstation_files_query_args` filter and
442 487 * applies the file-type's `can_read()` per row.
443 488 *
444 - * @since 0.9.0
445 - *
446 489 * @param int $user_id Viewer.
447 490 * @param int $parent_id Folder id (0 for desktop root).
448 491 * @return array[]
449 492 */
450 -function desktop_mode_files_get_for_user_folder( $user_id, $parent_id = 0 ) {
493 +function openstation_files_get_for_user_folder( $user_id, $parent_id = 0 ) {
451 494 global $wpdb;
452 495 $user_id = (int) $user_id;
453 496 $parent_id = max( 0, (int) $parent_id );
454 497 if ( $user_id <= 0 ) {
@@ -454,9 +497,9 @@
454 497 if ( $user_id <= 0 ) {
455 498 return array();
456 499 }
457 500
458 - $tables = desktop_mode_files_table_names();
501 + $tables = openstation_files_table_names();
459 502
460 503 // Access gate + shared-namespace decision for non-root folders.
461 504 // Desktop root (parent_id = 0) is always per-user. For sub-
462 505 // folders, the contents of a SHARED folder are visible to every
@@ -463,15 +506,15 @@
463 506 // user who has at least 'read' on it — the icons inside belong
464 507 // to the folder, not to the user who originally placed them.
465 508 $share_view = false;
466 509 if ( $parent_id > 0 ) {
467 - $folder = desktop_mode_files_get_folder( $parent_id );
510 + $folder = openstation_files_get_folder( $parent_id );
468 511 if ( ! $folder ) {
469 512 return array();
470 513 }
471 514 if ( (int) $folder['owner_id'] !== $user_id ) {
472 - $cap = function_exists( 'desktop_mode_folder_share_user_capability' )
473 - ? desktop_mode_folder_share_user_capability( $parent_id, $user_id )
515 + $cap = function_exists( 'openstation_folder_share_user_capability' )
516 + ? openstation_folder_share_user_capability( $parent_id, $user_id )
474 517 : 'none';
475 518 if ( 'none' === $cap ) {
476 519 return array();
477 520 }
@@ -486,23 +529,22 @@
486 529 );
487 530 /**
488 531 * Filter the args used to read placements.
489 532 *
490 - * @since 0.9.0
491 - *
492 533 * @param array $args Defaults: `{ user_id, parent_id, share_view }`.
493 534 * @param int $user_id Viewer.
494 535 * @param int $parent_id Folder id.
495 536 */
496 - $args = (array) apply_filters( 'desktop_mode_files_query_args', $args, $user_id, $parent_id );
537 + $args = (array) apply_filters( 'openstation_files_query_args', $args, $user_id, $parent_id );
497 538
498 539 // Active queries always exclude trashed rows. Recycle-bin
499 540 // callers reach for the dedicated trash store.
500 - if ( ! empty( $args['share_view'] ) ) {
501 - // Shared sub-folder — return every placement in the folder
502 - // regardless of which user originally placed it. The icons
503 - // are part of the folder; the owner_id column is audit info,
504 - // not a permission gate.
541 + if ( ! empty( $args['share_view'] ) || (int) $args['parent_id'] > 0 ) {
542 + // A sub-folder, the viewer's own or shared with them: return
543 + // every placement in it regardless of which user placed it,
544 + // so the owner of a shared folder sees what a writer added.
545 + // The icons are part of the folder; the owner_id column is
546 + // audit info, not a permission gate.
505 547 $rows = $wpdb->get_results(
506 548 $wpdb->prepare(
507 549 "SELECT * FROM {$tables['placements']}
508 550 WHERE parent_id = %d
@@ -530,18 +572,18 @@
530 572 return array();
531 573 }
532 574 $out = array();
533 575 foreach ( $rows as $row ) {
534 - $normalized = desktop_mode_files_normalize_placement_row( $row );
535 - $file = desktop_mode_resolve_file( $normalized['file_type'], $normalized['file_ref'] );
576 + $normalized = openstation_files_normalize_placement_row( $row );
577 + $file = openstation_resolve_file( $normalized['file_type'], $normalized['file_ref'] );
536 578 if ( empty( $args['share_view'] ) ) {
537 - // Private folder / desktop root — keep the existing
538 - // per-row read filter so stale/inaccessible entities
539 - // don't clutter the user's own view.
579 + // The viewer's own folder or desktop root: keep the
580 + // per-row read filter so stale or inaccessible entities,
581 + // a writer's included, don't clutter the user's own view.
540 582 if ( $file && ! $file->can_read( $user_id ) ) {
541 583 continue;
542 584 }
543 - } else {
585 + } elseif ( $file && ! $file->can_read( $user_id ) ) {
544 586 // Shared folder view — every placement the OWNER chose
545 587 // to include is surfaced to the recipient. When the
546 588 // recipient lacks read on the underlying entity, we
547 589 // mark the row as `access_gated` so the tile renderer
@@ -548,11 +590,9 @@
548 590 // can paint a lock overlay + tooltip + intercept the
549 591 // open. Entity-level access enforcement still happens
550 592 // at open time in each opener — this flag is just the
551 593 // pre-emptive visual cue.
552 - if ( $file && ! $file->can_read( $user_id ) ) {
553 - $normalized['access_gated'] = true;
554 - }
594 + $normalized['access_gated'] = true;
555 595 }
556 596 $out[] = $normalized;
557 597 }
558 598 return $out;
@@ -565,9 +605,9 @@
565 605 * 1. Folders the viewer owns that have no placement anywhere.
566 606 * (Pre-fix folder-create flow could leak these; new flow
567 607 * writes the placement atomically.)
568 608 *
569 - * 2. Plugin shortcuts (`desktop_mode_register_icon()`) the
609 + * 2. Plugin shortcuts (`openstation_register_icon()`) the
570 610 * viewer hasn't placed yet. The unified-rail merge means
571 611 * every registered icon shows up as a `shortcut` placement
572 612 * on first hydrate so plugin shortcuts behave like any
573 613 * other tile (drag, sort, right-click, clean up).
@@ -572,20 +612,20 @@
572 612 * on first hydrate so plugin shortcuts behave like any
573 613 * other tile (drag, sort, right-click, clean up).
574 614 *
575 615 * Idempotent on both axes: a folder/shortcut that already has
576 - * any placement is left alone. Coordinates use the column-major
577 - * grid that `src/desktop-files/grid.ts` mirrors on the JS side.
616 + * any placement is left alone. Coordinates come from
617 + * `includes/desktop-files/grid.php`, which mirrors
618 + * `src/desktop-files/grid.ts` — pitch, reading order, and the
619 + * assumed canvas the scan wraps at.
578 620 *
579 621 * Called by the placements list endpoint when the requested
580 622 * folder is the root (`parent_id=0`).
581 623 *
582 - * @since 0.9.0
583 - *
584 624 * @param int $user_id Viewer.
585 625 * @return int Total number of orphans that were auto-placed.
586 626 */
587 -function desktop_mode_files_auto_place_orphans( $user_id ) {
627 +function openstation_files_auto_place_orphans( $user_id ) {
588 628 global $wpdb;
589 629 $user_id = (int) $user_id;
590 630 if ( $user_id <= 0 ) {
591 631 return 0;
@@ -590,9 +630,9 @@
590 630 if ( $user_id <= 0 ) {
591 631 return 0;
592 632 }
593 633
594 - $tables = desktop_mode_files_table_names();
634 + $tables = openstation_files_table_names();
595 635
596 636 // 1) Owned folders without any placement. Skip trashed folders
597 637 // and trashed placement rows so a recycled folder doesn't get
598 638 // auto-placed back on the desktop on next hydrate.
@@ -611,20 +651,20 @@
611 651 ARRAY_A
612 652 );
613 653
614 654 // 2) Registered plugin shortcuts the viewer hasn't placed yet.
615 - // Pull the registered ids first, then ask the placements
616 - // table which the viewer already has — set difference
617 - // yields the orphans without a heavy join.
618 - $shortcut_ids = array();
619 - $registry = function_exists( 'desktop_mode_desktop_icon_registry' )
620 - ? desktop_mode_desktop_icon_registry()
655 + // Pull the registered ids first, then ask the placements
656 + // table which the viewer already has — set difference
657 + // yields the orphans without a heavy join.
658 + $shortcut_ids = array();
659 + $registry = function_exists( 'openstation_desktop_icon_registry' )
660 + ? openstation_desktop_icon_registry()
621 661 : array();
622 662 if ( is_array( $registry ) ) {
623 - // Run through the same `desktop_mode_icons` filter the
663 + // Run through the same `openstation_icons` filter the
624 664 // build-payload path uses so plugins (and tests) can inject
625 665 // virtual entries.
626 - $registry = (array) apply_filters( 'desktop_mode_icons', $registry );
666 + $registry = (array) apply_filters( 'openstation_icons', $registry );
627 667 }
628 668 if ( is_array( $registry ) && ! empty( $registry ) ) {
629 669 $registered_ids = array_map( 'strval', array_keys( $registry ) );
630 670 $placeholders = implode( ',', array_fill( 0, count( $registered_ids ), '%s' ) );
@@ -638,9 +678,9 @@
638 678 AND file_ref IN ($placeholders)",
639 679 $args
640 680 )
641 681 );
642 - $placed_set = array_flip( array_map( 'strval', (array) $placed_ids ) );
682 + $placed_set = array_flip( array_map( 'strval', (array) $placed_ids ) );
643 683 foreach ( $registered_ids as $id ) {
644 684 if ( ! isset( $placed_set[ $id ] ) ) {
645 685 $shortcut_ids[] = $id;
646 686 }
@@ -652,10 +692,11 @@
652 692 }
653 693
654 694 // Build an occupied set from EXISTING root placements so
655 695 // we never drop an orphan on top of a tile the user
656 - // already has. Cell math mirrors `src/desktop-files/grid.ts`
657 - // (padding 16 + col 96 + row 110).
696 + // already has. Cell math lives in
697 + // `includes/desktop-files/grid.php`, the mirror of
698 + // `src/desktop-files/grid.ts`.
658 699 $existing = $wpdb->get_results(
659 700 $wpdb->prepare(
660 701 "SELECT x, y FROM {$tables['placements']}
661 702 WHERE owner_id = %d
@@ -664,47 +705,33 @@
664 705 $user_id
665 706 ),
666 707 ARRAY_A
667 708 );
668 - $occupied = array();
669 - foreach ( (array) $existing as $row ) {
670 - $col = max( 0, (int) round( ( (int) $row['x'] - 16 ) / 96 ) );
671 - $row_idx = max( 0, (int) round( ( (int) $row['y'] - 16 ) / 110 ) );
672 - $occupied[ "$col,$row_idx" ] = true;
673 - }
709 + $occupied = openstation_files_grid_occupied( $existing );
674 710
675 - $find_next = function () use ( &$occupied ) {
676 - for ( $col = 0; $col < 999; $col++ ) {
677 - for ( $row = 0; $row < 999; $row++ ) {
678 - $key = "$col,$row";
679 - if ( ! isset( $occupied[ $key ] ) ) {
680 - $occupied[ $key ] = true;
681 - return array( $col, $row );
682 - }
683 - }
684 - }
685 - return array( 0, 0 );
686 - };
711 + // The desktop reads in columns, and the scan wraps to the next one
712 + // at the assumed canvas height rather than running a column 999
713 + // cells deep. The server has no viewport; a slot it invents below
714 + // the fold is a tile the user cannot reach, because the layer that
715 + // renders it does not scroll.
716 + $order = openstation_files_grid_order( 0 );
687 717
688 - $placed = 0;
718 + $placed = 0;
689 719 $emit_at = function ( $type, $ref, $col, $row ) use ( $user_id, &$occupied, &$placed ) {
690 720 $occupied[ "$col,$row" ] = true;
691 - $result = desktop_mode_files_place(
721 + $result = openstation_files_place(
692 722 $user_id,
693 723 0,
694 724 $type,
695 725 (string) $ref,
696 - array(
697 - 'x' => 16 + $col * 96,
698 - 'y' => 16 + $row * 110,
699 - )
726 + openstation_files_grid_cell_to_point( $col, $row )
700 727 );
701 728 if ( ! is_wp_error( $result ) ) {
702 - $placed++;
729 + ++$placed;
703 730 }
704 731 };
705 - $emit_next = function ( $type, $ref ) use ( $find_next, $emit_at ) {
706 - list( $col, $row ) = $find_next();
732 + $emit_next = function ( $type, $ref ) use ( &$occupied, $order, $emit_at ) {
733 + list( $col, $row ) = openstation_files_grid_next_free( $occupied, $order );
707 734 $emit_at( $type, $ref, $col, $row );
708 735 };
709 736
710 737 // Pinned shortcuts get reserved top-left slots. Anchored to
@@ -729,9 +756,9 @@
729 756 // client-side override anyway, but a future cleanup pass
730 757 // can compact the column.
731 758 $occupied[ "0,$pinned_idx" ] = true;
732 759 $emit_at( 'shortcut', $id, 0, $pinned_idx );
733 - $pinned_idx++;
760 + ++$pinned_idx;
734 761 }
735 762
736 763 foreach ( $folder_rows as $row ) {
737 764 $emit_next( 'folder', $row['id'] );
@@ -747,15 +774,15 @@
747 774
748 775 /**
749 776 * Backwards-compat alias for the older folder-only name.
750 777 *
751 - * @deprecated 0.9.0 Use {@see desktop_mode_files_auto_place_orphans}.
778 + * @deprecated Use {@see openstation_files_auto_place_orphans}.
752 779 *
753 780 * @param int $user_id Viewer.
754 781 * @return int
755 782 */
756 -function desktop_mode_files_auto_place_orphan_folders( $user_id ) {
757 - return desktop_mode_files_auto_place_orphans( $user_id );
783 +function openstation_files_auto_place_orphan_folders( $user_id ) {
784 + return openstation_files_auto_place_orphans( $user_id );
758 785 }
759 786
760 787 /**
761 788 * Coerce wpdb's stringly-typed row into typed values + decoded
@@ -760,15 +787,14 @@
760 787 /**
761 788 * Coerce wpdb's stringly-typed row into typed values + decoded
762 789 * meta. Internal helper.
763 790 *
764 - * @since 0.9.0
765 791 * @internal
766 792 *
767 793 * @param array $row Raw wpdb row.
768 794 * @return array
769 795 */
770 -function desktop_mode_files_normalize_placement_row( $row ) {
796 +function openstation_files_normalize_placement_row( $row ) {
771 797 $meta_raw = isset( $row['meta'] ) ? (string) $row['meta'] : '';
772 798 $meta = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null;
773 799 return array(
774 800 'id' => (int) $row['id'],
@@ -773,9 +799,9 @@
773 799 return array(
774 800 'id' => (int) $row['id'],
775 801 'owner_id' => (int) $row['owner_id'],
776 802 // `updated_by` is v10. Null on legacy rows — callers that
777 - // need the actor (e.g. `desktop_mode_files_check_if_match`)
803 + // need the actor (e.g. `openstation_files_check_if_match`)
778 804 // fall back to `owner_id` when this is null/missing.
779 805 'updated_by' => isset( $row['updated_by'] ) ? (int) $row['updated_by'] : null,
780 806 'parent_id' => (int) $row['parent_id'],
781 807 'file_type' => (string) $row['file_type'],
@@ -794,34 +820,32 @@
794 820 * Invariant (enforced by callers): tombstones may exist only for
795 821 * ids of PERMANENTLY-DELETED rows. Never write one for a soft-
796 822 * trashed row — soft-trash is reversible and the heartbeat already
797 823 * surfaces it via the `trashed_at_ms IS NOT NULL` query in
798 - * `desktop_mode_files_compute_heartbeat_delta`. A tombstone on a
824 + * `openstation_files_compute_heartbeat_delta`. A tombstone on a
799 825 * soft-trashed row lingers past restore and tells clients the row
800 826 * is gone while it is in fact alive — see the "shared folder
801 - * disappears on refresh" bug fixed in 0.18.x.
827 + * disappears on refresh" bug.
802 828 *
803 - * Pair every revival path (`desktop_mode_files_restore_placement`,
804 - * `desktop_mode_files_restore_folder`, and the duplicate-key
805 - * revival branch in `desktop_mode_files_place`) with
806 - * {@see desktop_mode_files_clear_tombstones_for} so a row coming
829 + * Pair every revival path (`openstation_files_restore_placement`,
830 + * `openstation_files_restore_folder`, and the duplicate-key
831 + * revival branch in `openstation_files_place`) with
832 + * {@see openstation_files_clear_tombstones_for} so a row coming
807 833 * back to life never carries lingering tombstones from a previous
808 834 * removal that turned out to be reversible.
809 835 *
810 - * @since 0.9.0
811 - *
812 836 * @param string $kind 'placement' | 'folder'.
813 837 * @param int $ref Removed id.
814 838 */
815 -function desktop_mode_files_write_tombstone( $kind, $ref ) {
839 +function openstation_files_write_tombstone( $kind, $ref ) {
816 840 global $wpdb;
817 - $tables = desktop_mode_files_table_names();
841 + $tables = openstation_files_table_names();
818 842 $wpdb->insert(
819 843 $tables['tombstones'],
820 844 array(
821 845 'kind' => (string) $kind,
822 846 'ref_id' => (int) $ref,
823 - 'removed_at_ms' => desktop_mode_files_now_ms(),
847 + 'removed_at_ms' => openstation_files_now_ms(),
824 848 ),
825 849 array( '%s', '%d', '%d' )
826 850 );
827 851 }
@@ -833,20 +857,18 @@
833 857 * previous removal that turned out to be reversible.
834 858 *
835 859 * Idempotent — running it on a ref with no tombstones is a no-op.
836 860 *
837 - * @since 0.18.0
838 - *
839 861 * @param string $kind 'placement' | 'folder'.
840 862 * @param int $ref_id Row id whose tombstones should be dropped.
841 863 */
842 -function desktop_mode_files_clear_tombstones_for( $kind, $ref_id ) {
864 +function openstation_files_clear_tombstones_for( $kind, $ref_id ) {
843 865 global $wpdb;
844 866 $ref_id = (int) $ref_id;
845 867 if ( $ref_id <= 0 ) {
846 868 return;
847 869 }
848 - $tables = desktop_mode_files_table_names();
870 + $tables = openstation_files_table_names();
849 871 $wpdb->delete(
850 872 $tables['tombstones'],
851 873 array(
852 874 'kind' => (string) $kind,
@@ -860,30 +882,28 @@
860 882 * Daily prune of tombstones older than 7 days. Phase 6 may tune
861 883 * the retention window when the Heartbeat sync lands; for now 7d
862 884 * is plenty since a client that's been offline that long will
863 885 * always need a full REST resync anyway.
864 - *
865 - * @since 0.9.0
866 886 */
867 -function desktop_mode_files_prune_tombstones() {
887 +function openstation_files_prune_tombstones() {
868 888 global $wpdb;
869 - $tables = desktop_mode_files_table_names();
870 - $cutoff = desktop_mode_files_now_ms() - ( 7 * DAY_IN_SECONDS * 1000 );
889 + $tables = openstation_files_table_names();
890 + $cutoff = openstation_files_now_ms() - ( 7 * DAY_IN_SECONDS * 1000 );
871 891 $wpdb->query( $wpdb->prepare( "DELETE FROM {$tables['tombstones']} WHERE removed_at_ms < %d", $cutoff ) );
872 892 }
873 -add_action( 'desktop_mode_files_daily_prune', 'desktop_mode_files_prune_tombstones' );
893 +add_action( 'desktop_mode_files_daily_prune', 'openstation_files_prune_tombstones' );
874 894
875 895 /**
876 - * Schedule the daily prune on activation.
877 - *
878 - * @since 0.9.0
896 + * Schedule the daily prune. Hooked on `init` and idempotent via
897 + * wp_next_scheduled(), so a manual file-copy install (no activation
898 + * hook) still gets the cron event.
879 899 */
880 -function desktop_mode_files_schedule_prune() {
900 +function openstation_files_schedule_prune() {
881 901 if ( ! wp_next_scheduled( 'desktop_mode_files_daily_prune' ) ) {
882 902 wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'desktop_mode_files_daily_prune' );
883 903 }
884 904 }
885 -add_action( 'init', 'desktop_mode_files_schedule_prune' );
905 +add_action( 'init', 'openstation_files_schedule_prune' );
886 906
887 907 /**
888 908 * Walk the folder-parentage chain upward from `$target_parent_id` and
889 909 * return `true` when `$moving_folder_id` appears anywhere in it —
@@ -901,16 +921,14 @@
901 921 * Defends against pre-existing cycles in the data: if we re-visit a
902 922 * cursor we've already seen, we treat it as a cycle and reject, so a
903 923 * corrupted history can't drive this function into an infinite loop.
904 924 *
905 - * @since 0.20.0
906 - *
907 925 * @param int $user_id Acting user.
908 926 * @param int $moving_folder_id Folder being moved (its `folders.id`).
909 927 * @param int $target_parent_id New container folder id (0 = desktop root).
910 928 * @return bool True when the move would create a cycle.
911 929 */
912 -function desktop_mode_files_would_create_folder_cycle( $user_id, $moving_folder_id, $target_parent_id ) {
930 +function openstation_files_would_create_folder_cycle( $user_id, $moving_folder_id, $target_parent_id ) {
913 931 $moving_folder_id = (int) $moving_folder_id;
914 932 $target_parent_id = (int) $target_parent_id;
915 933 $user_id = (int) $user_id;
916 934 if ( $moving_folder_id <= 0 || $target_parent_id <= 0 || $user_id <= 0 ) {
@@ -919,9 +937,9 @@
919 937 if ( $moving_folder_id === $target_parent_id ) {
920 938 return true;
921 939 }
922 940 global $wpdb;
923 - $tables = desktop_mode_files_table_names();
941 + $tables = openstation_files_table_names();
924 942 $visited = array();
925 943 $cursor = $target_parent_id;
926 944 // Hard cap to defend against catastrophically deep trees too —
927 945 // real installs won't approach 256.