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