PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / desktop-files / schema.php

schema.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.8, at includes/desktop-files/schema.php

667 lines 25.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Files-on-the-Desktop schema.
4 *
5 * Five custom tables back the system:
6 *
7 * - `_desktop_mode_file_placements` — every (user, parent_folder,
8 * type, ref, x, y, sort) tuple. Indexed on `(owner_id, parent_id)`
9 * and `(file_type, file_ref)` for two queries we run constantly:
10 * "show me what's on user X's folder Y" and "where else does
11 * this entity appear" (used when an entity is deleted to clean
12 * up dangling placements).
13 *
14 * - `_desktop_mode_folders` — folder rows. Owned by one user, with
15 * a share mode (`private` | `users` | `roles` | `all`) and a
16 * JSON `share_meta` column carrying user/role lists. Folders
17 * live independently of where they're placed (a folder placed
18 * on user A's desktop root can also appear inside user B's
19 * "Projects" folder via a placement).
20 *
21 * - `_desktop_mode_file_tombstones` — id ledger of removals so the
22 * Heartbeat delta sync (Phase 6) can tell connected clients
23 * "this placement / folder is gone." Pruned daily.
24 *
25 * - `_desktop_mode_folder_shares` — one row per (folder, principal)
26 * grant: user- or role-principal, `read` | `write` capability,
27 * `pending` | `accepted` | `denied` state.
28 *
29 * - `_desktop_mode_share_user_decisions` — per-user opt-ins for
30 * role-principal shares (each member of the role accepts or
31 * denies individually; the shares row itself stays `pending`).
32 *
33 * dbDelta is the only safe path for schema migrations against the
34 * Core tables environment — Phase 6 re-uses this file by bumping
35 * `DESKTOP_MODE_FILES_SCHEMA_VERSION` and adding columns.
36 *
37 * @package WPDesktopMode
38 */
39
40 defined( 'ABSPATH' ) || exit;
41
42 define( 'DESKTOP_MODE_FILES_SCHEMA_VERSION', '13' );
43 define( 'DESKTOP_MODE_FILES_SCHEMA_OPTION', 'desktop_mode_files_schema_version' );
44
45 /**
46 * Returns the per-table names with the active prefix applied.
47 *
48 * @return array{ placements: string, folders: string, tombstones: string, shares: string, decisions: string }
49 */
50 function desktop_mode_files_table_names() {
51 global $wpdb;
52 return array(
53 'placements' => $wpdb->prefix . 'desktop_mode_file_placements',
54 'folders' => $wpdb->prefix . 'desktop_mode_folders',
55 'tombstones' => $wpdb->prefix . 'desktop_mode_file_tombstones',
56 'shares' => $wpdb->prefix . 'desktop_mode_folder_shares',
57 'decisions' => $wpdb->prefix . 'desktop_mode_share_user_decisions',
58 'stored_files' => $wpdb->prefix . 'desktop_mode_stored_files',
59 );
60 }
61
62 /**
63 * Idempotent `dbDelta` call. Hooked on plugin activation and on
64 * `admin_init` (gated by a version-option mismatch) so a manual
65 * file copy install still ends up with the tables.
66 */
67 function desktop_mode_files_install_schema() {
68 global $wpdb;
69
70 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
71
72 $tables = desktop_mode_files_table_names();
73 $charset_collate = $wpdb->get_charset_collate();
74
75 // Schema v2: adds trash columns to both placements
76 // and folders so deleted shortcuts and folders land in the
77 // recycle bin instead of vanishing. `trashed_at_ms` is the
78 // epoch-ms timestamp of the trash event (NULL = active).
79 // `trashed_by` records the user that fired it (for permission
80 // checks on restore). `trashed_via_folder` on placements is the
81 // id of the folder whose trash cascaded the placement, so a
82 // folder restore knows exactly which children to bring back —
83 // precise round-trip with no time-window heuristics.
84 $placements_sql = "CREATE TABLE {$tables['placements']} (
85 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
86 owner_id BIGINT UNSIGNED NOT NULL,
87 parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
88 file_type VARCHAR(64) NOT NULL,
89 file_ref VARCHAR(255) NOT NULL DEFAULT '',
90 x INT NOT NULL DEFAULT 0,
91 y INT NOT NULL DEFAULT 0,
92 sort_order INT NOT NULL DEFAULT 0,
93 updated_at_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
94 meta LONGTEXT NULL,
95 trashed_at_ms BIGINT UNSIGNED NULL,
96 trashed_by BIGINT UNSIGNED NULL,
97 trashed_via_folder BIGINT UNSIGNED NULL,
98 trashed_meta LONGTEXT NULL,
99 PRIMARY KEY (id),
100 KEY owner_parent (owner_id, parent_id),
101 KEY type_ref (file_type, file_ref),
102 KEY updated_at_ms (updated_at_ms),
103 KEY trashed_at_ms (trashed_at_ms)
104 ) $charset_collate;";
105
106 $folders_sql = "CREATE TABLE {$tables['folders']} (
107 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
108 owner_id BIGINT UNSIGNED NOT NULL,
109 name VARCHAR(255) NOT NULL DEFAULT '',
110 share_mode VARCHAR(16) NOT NULL DEFAULT 'private',
111 share_meta LONGTEXT NULL,
112 updated_at_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
113 trashed_at_ms BIGINT UNSIGNED NULL,
114 trashed_by BIGINT UNSIGNED NULL,
115 trashed_meta LONGTEXT NULL,
116 PRIMARY KEY (id),
117 KEY owner_id (owner_id),
118 KEY share_mode (share_mode),
119 KEY updated_at_ms (updated_at_ms),
120 KEY trashed_at_ms (trashed_at_ms)
121 ) $charset_collate;";
122
123 $tombstones_sql = "CREATE TABLE {$tables['tombstones']} (
124 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
125 kind VARCHAR(16) NOT NULL,
126 ref_id BIGINT UNSIGNED NOT NULL,
127 removed_at_ms BIGINT UNSIGNED NOT NULL,
128 PRIMARY KEY (id),
129 KEY kind_removed (kind, removed_at_ms)
130 ) $charset_collate;";
131
132 // Schema v13: real per-user file storage. One row
133 // per uploaded file; the bytes live flat on disk under
134 // `uploads/desktop-mode-files/<owner_id>/<disk_name>` with a
135 // server-generated extensionless `disk_name` (UUID) — hierarchy,
136 // naming, and sharing are entirely DB concerns (folders +
137 // placements + shares tables), the disk is a dumb blob store.
138 // No UNIQUE keys beyond the PK on purpose: dbDelta's UNIQUE-KEY
139 // quirks (see the shares-table comment below) don't apply, and
140 // disk_name uniqueness is guaranteed by the UUID generator plus
141 // a collision check at write time.
142 $stored_files_sql = "CREATE TABLE {$tables['stored_files']} (
143 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
144 owner_id BIGINT UNSIGNED NOT NULL,
145 display_name VARCHAR(255) NOT NULL DEFAULT '',
146 disk_name VARCHAR(64) NOT NULL DEFAULT '',
147 size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
148 mime VARCHAR(100) NOT NULL DEFAULT '',
149 created_at_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
150 updated_at_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
151 PRIMARY KEY (id),
152 KEY owner_id (owner_id),
153 KEY disk_name (disk_name)
154 ) $charset_collate;";
155
156 // Schema v9 — per-principal grants (`folder_shares` table) +
157 // per-user opt-in decisions (`share_user_decisions` table).
158 // `share_meta` on the folders row stays as a diagnostic-only
159 // column; visibility is computed entirely from the shares
160 // table.
161 //
162 // Shares + decisions are intentionally NOT routed through
163 // dbDelta. Their `ensure_*_table()` helpers below are the sole
164 // creators. dbDelta uses `DESCRIBE` to detect existing tables
165 // and falls back to a bare `CREATE TABLE` (no IF NOT EXISTS)
166 // when DESCRIBE returns empty — under certain MySQL / MariaDB
167 // configurations (case-folding mismatches, transient connection
168 // states, the `lower_case_table_names` quirk on case-sensitive
169 // filesystems) DESCRIBE can fail on a table that physically
170 // exists, and dbDelta then issues a CREATE that blows up with
171 // "Table … already exists" (MySQL error 1050). The `ensure_*`
172 // helpers use INFORMATION_SCHEMA + explicit `CREATE TABLE IF NOT
173 // EXISTS`, which is bullet-proof; v9 → vN column additions are
174 // handled by `ALTER TABLE … ADD COLUMN` inside the same helper.
175
176 // v11: rename `placements.user_id` to `placements.owner_id` so
177 // the placements + folders tables use the same column name for
178 // the same concept. Must run BEFORE dbDelta — once the
179 // `$placements_sql` definition switches from `user_id` to
180 // `owner_id`, dbDelta on an existing v≤10 install would see
181 // `owner_id` as a missing column and ADD it (leaving the old
182 // `user_id` in place + the new `owner_id` NULL). Running the
183 // CHANGE COLUMN first means dbDelta sees the table already
184 // matches the desired shape.
185 desktop_mode_files_rename_user_id_to_owner_id();
186
187 dbDelta( $placements_sql );
188 dbDelta( $folders_sql );
189 dbDelta( $tombstones_sql );
190 dbDelta( $stored_files_sql );
191
192 // dbDelta has well-documented quirks with `NULL`-only columns
193 // (no DEFAULT) — under some MySQL/MariaDB combos it silently
194 // skips the ADD COLUMN. Verify the v2 trash columns are
195 // physically present and ALTER them in directly when not.
196 desktop_mode_files_ensure_trash_columns();
197
198 // v4: clean up duplicate placements created by sessions that
199 // hit the auto-orphan-placer while the v2 trash columns were
200 // missing — every `WHERE trashed_at_ms IS NULL` precheck
201 // returned empty, so each pageload re-inserted every
202 // registered shortcut. Collapse runs of identical
203 // `(owner_id, parent_id, file_type, file_ref)` rows down to
204 // the lowest id.
205 desktop_mode_files_dedupe_placements();
206
207 // v5: enforce uniqueness at the DB level so a future bug
208 // (or a racing pair of REST requests) can never re-create
209 // the duplicate shortcuts again. Must run AFTER dedupe —
210 // adding a unique key against duplicate rows would fail.
211 desktop_mode_files_ensure_unique_placement_index();
212
213 // v9: belt-and-suspenders existence check for the shares +
214 // decisions tables. The folder-sharing feature is the
215 // canonical source of truth for "who can see this folder" —
216 // the `share_meta` JSON column on the folders table remains
217 // for diagnostic purposes only and is not consulted by the
218 // visibility resolver.
219 desktop_mode_files_ensure_shares_table();
220 desktop_mode_files_ensure_decisions_table();
221
222 // v10: `updated_by` column on placements so the If-Match 409
223 // conflict toast names the SESSION that actually won the race,
224 // not just whoever currently owns the row. Critical for the
225 // shared-write scenario where User B (writer recipient) moves a
226 // placement and User C gets the conflict — without this column,
227 // the toast would blame User A (owner of the row).
228 desktop_mode_files_ensure_updated_by_column();
229
230 update_option( DESKTOP_MODE_FILES_SCHEMA_OPTION, DESKTOP_MODE_FILES_SCHEMA_VERSION );
231
232 /**
233 * Fires after the files schema is installed / migrated.
234 *
235 * @param string $version The version that was installed.
236 */
237 do_action( 'desktop_mode_files_schema_installed', DESKTOP_MODE_FILES_SCHEMA_VERSION );
238 }
239
240 /**
241 * Belt-and-suspenders verifier for the v2 trash columns. Reads
242 * `INFORMATION_SCHEMA.COLUMNS` for the placements + folders tables
243 * and `ALTER`s in any column dbDelta missed. Idempotent: each
244 * `ALTER` only fires when the column is not already there.
245 *
246 * @internal
247 */
248 function desktop_mode_files_ensure_trash_columns() {
249 global $wpdb;
250 $tables = desktop_mode_files_table_names();
251
252 // Two-worker race protection: between the INFORMATION_SCHEMA
253 // check and the ALTER, a concurrent worker (cron + admin-init,
254 // REST + heartbeat) can run the same check, see the column
255 // missing, and both fire ALTER. The second hits MySQL error
256 // 1060 ("Duplicate column"). Suppressing wpdb errors around
257 // the ALTER swallows that benign log line. The column ends up
258 // present either way — we re-verify with a second
259 // INFORMATION_SCHEMA query and only surface an error when the
260 // column is genuinely missing after the attempt.
261 $ensure = static function ( $table, $column, $definition ) use ( $wpdb ) {
262 $col_exists = static function () use ( $wpdb, $table, $column ) {
263 return (int) $wpdb->get_var(
264 $wpdb->prepare(
265 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
266 WHERE TABLE_SCHEMA = DATABASE()
267 AND TABLE_NAME = %s
268 AND COLUMN_NAME = %s",
269 $table,
270 $column
271 )
272 );
273 };
274 if ( $col_exists() > 0 ) {
275 return;
276 }
277 $prev_suppress = $wpdb->suppress_errors( true );
278 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
279 $wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}" );
280 $wpdb->suppress_errors( $prev_suppress );
281 // Belt-and-suspenders: if the column STILL isn't there
282 // after the ALTER (real schema error, not a race), retry
283 // once unsuppressed so WP_DEBUG users see the cause.
284 if ( $col_exists() === 0 ) {
285 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
286 $wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}" );
287 }
288 };
289
290 $ensure( $tables['placements'], 'trashed_at_ms', 'BIGINT UNSIGNED NULL' );
291 $ensure( $tables['placements'], 'trashed_by', 'BIGINT UNSIGNED NULL' );
292 $ensure( $tables['placements'], 'trashed_via_folder', 'BIGINT UNSIGNED NULL' );
293 // v6: ancestry snapshot — JSON capturing every folder in the
294 // parent chain at trash time so a restore can resurrect the
295 // chain even when a folder was hard-deleted in the meantime.
296 $ensure( $tables['placements'], 'trashed_meta', 'LONGTEXT NULL' );
297 $ensure( $tables['folders'], 'trashed_at_ms', 'BIGINT UNSIGNED NULL' );
298 $ensure( $tables['folders'], 'trashed_by', 'BIGINT UNSIGNED NULL' );
299 $ensure( $tables['folders'], 'trashed_meta', 'LONGTEXT NULL' );
300 }
301
302 /**
303 * Collapse duplicate `(owner_id, parent_id, file_type, file_ref)`
304 * placement rows down to the lowest id, deleting the rest. The
305 * DELETE is restricted to `file_type IN ('shortcut','folder')` —
306 * the types where legacy duplicates were actually observed. Note
307 * that the v5 `placement_unique` index added right after this
308 * covers EVERY file type, so duplicates of any type within the
309 * same (owner, parent) are disallowed at the DB level; if legacy
310 * duplicates of another type exist, the (error-suppressed)
311 * `ADD UNIQUE` in
312 * `desktop_mode_files_ensure_unique_placement_index()` will fail
313 * and leave the index absent until those rows are cleaned up.
314 *
315 * @internal
316 */
317 function desktop_mode_files_dedupe_placements() {
318 global $wpdb;
319 $tables = desktop_mode_files_table_names();
320 $tbl = $tables['placements'];
321
322 // Once the unique index exists, MySQL prevents duplicate
323 // inserts at the DB level — dedupe is a no-op and the
324 // table-scanning DELETE is pure waste on every install_schema
325 // call. Skip in that case so the cost is paid exactly once,
326 // during the v4 → v5 migration.
327 $has_unique = (int) $wpdb->get_var(
328 $wpdb->prepare(
329 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
330 WHERE TABLE_SCHEMA = DATABASE()
331 AND TABLE_NAME = %s
332 AND INDEX_NAME = %s",
333 $tbl,
334 'placement_unique'
335 )
336 );
337 if ( $has_unique > 0 ) {
338 return;
339 }
340
341 // Self-join keeps the minimum id per (user_id, parent_id,
342 // file_type, file_ref) and deletes everything else. Restricted
343 // to shortcut + folder placements, where duplicates are never
344 // intentional.
345 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
346 $wpdb->query(
347 "DELETE p1 FROM `{$tbl}` p1
348 INNER JOIN `{$tbl}` p2
349 ON p1.owner_id = p2.owner_id
350 AND p1.parent_id = p2.parent_id
351 AND p1.file_type = p2.file_type
352 AND p1.file_ref = p2.file_ref
353 AND p1.id > p2.id
354 WHERE p1.file_type IN ( 'shortcut', 'folder' )"
355 );
356 }
357
358 /**
359 * Add a UNIQUE index on
360 * `(owner_id, parent_id, file_type, file_ref)` to make duplicate
361 * placements physically impossible. Skipped when the index is
362 * already present.
363 *
364 * Note: `file_ref` is `VARCHAR(255)` — combined with the three
365 * other columns this fits comfortably under MySQL's 3072-byte
366 * InnoDB index-key limit on `utf8mb4`.
367 *
368 * @internal
369 */
370 function desktop_mode_files_ensure_unique_placement_index() {
371 global $wpdb;
372 $tables = desktop_mode_files_table_names();
373 $tbl = $tables['placements'];
374
375 $exists = (int) $wpdb->get_var(
376 $wpdb->prepare(
377 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
378 WHERE TABLE_SCHEMA = DATABASE()
379 AND TABLE_NAME = %s
380 AND INDEX_NAME = %s",
381 $tbl,
382 'placement_unique'
383 )
384 );
385 if ( 0 === $exists ) {
386 // Suppress errors on the ADD KEY in case a concurrent
387 // worker won the same race (MySQL 1061: "Duplicate key
388 // name"). The index ends up present either way; the
389 // check-then-add pattern is benign under contention.
390 $prev_suppress = $wpdb->suppress_errors( true );
391 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
392 $wpdb->query(
393 "ALTER TABLE `{$tbl}`
394 ADD UNIQUE KEY `placement_unique`
395 (owner_id, parent_id, file_type, file_ref)"
396 );
397 $wpdb->suppress_errors( $prev_suppress );
398 }
399 }
400
401 /**
402 * Add the v10 `updated_by` column to the placements table.
403 *
404 * Tracks which user last mutated the row (created, moved, restored).
405 * Used by `desktop_mode_files_check_if_match()` so the If-Match 409
406 * conflict toast attributes the change to the SESSION that won the
407 * race rather than to the row's static owner — critical when a
408 * writer recipient of a shared folder rearranges placements and
409 * another viewer hits a stale `If-Match`.
410 *
411 * NULL on legacy rows (pre-v10). The conflict resolver falls back
412 * to `owner_id` when this column is NULL, matching the old behavior.
413 *
414 * @internal
415 */
416 function desktop_mode_files_ensure_updated_by_column() {
417 global $wpdb;
418 $tables = desktop_mode_files_table_names();
419 $tbl = $tables['placements'];
420 $exists = (int) $wpdb->get_var(
421 $wpdb->prepare(
422 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
423 WHERE TABLE_SCHEMA = DATABASE()
424 AND TABLE_NAME = %s
425 AND COLUMN_NAME = %s",
426 $tbl,
427 'updated_by'
428 )
429 );
430 if ( 0 === $exists ) {
431 // Suppress errors so a concurrent worker that already won
432 // the same race doesn't fire a benign MySQL 1060
433 // ("Duplicate column"). The column ends up present either
434 // way.
435 $prev_suppress = $wpdb->suppress_errors( true );
436 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
437 $wpdb->query( "ALTER TABLE `{$tbl}` ADD COLUMN `updated_by` BIGINT UNSIGNED NULL AFTER `owner_id`" );
438 $wpdb->suppress_errors( $prev_suppress );
439 }
440 }
441
442 /**
443 * Rename `placements.user_id` to `placements.owner_id` and the
444 * matching `user_parent` index to `owner_parent`. The two
445 * desktop-mode-owned tables historically used different names for
446 * the same "row's owner" concept — folders carried `owner_id` from
447 * day one, placements carried `user_id`. v11 unifies them so SQL
448 * and PHP read identically across the two tables.
449 *
450 * Idempotent: skips when the table doesn't exist (fresh install —
451 * dbDelta runs after this and creates the table with the new
452 * column name directly) and when the column is already renamed.
453 *
454 * Must run BEFORE `dbDelta( $placements_sql )` in
455 * `desktop_mode_files_install_schema()` — dbDelta does NOT rename
456 * columns, so against a v≤10 table whose definition says
457 * `owner_id` it would ADD a new `owner_id` column and leave the
458 * stale `user_id` in place. Running the CHANGE COLUMN first puts
459 * the table in the desired shape so dbDelta sees no diff.
460 *
461 * Schema version was bumped from 11 to 12 so any install that
462 * stamped 11 but never actually renamed the column (a silent
463 * partial run during dev iteration) gets a clean retry. The
464 * function is idempotent — early-returns when `user_id` is absent,
465 * so healthy v11 installs see a cheap no-op on the retry.
466 *
467 * @internal
468 */
469 function desktop_mode_files_rename_user_id_to_owner_id() {
470 global $wpdb;
471 $tables = desktop_mode_files_table_names();
472 $tbl = $tables['placements'];
473
474 // Fresh install — the table doesn't exist yet; dbDelta creates
475 // it with `owner_id` directly. Nothing to migrate.
476 $table_exists = (int) $wpdb->get_var(
477 $wpdb->prepare(
478 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
479 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s",
480 $tbl
481 )
482 );
483 if ( 0 === $table_exists ) {
484 return;
485 }
486
487 $has_user_id = (int) $wpdb->get_var(
488 $wpdb->prepare(
489 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
490 WHERE TABLE_SCHEMA = DATABASE()
491 AND TABLE_NAME = %s
492 AND COLUMN_NAME = 'user_id'",
493 $tbl
494 )
495 );
496 if ( 0 === $has_user_id ) {
497 return; // Already renamed (or column was never there — fresh install via test factory).
498 }
499
500 // DELIBERATELY NOT suppressing errors here. An earlier draft
501 // wrapped the ALTER in `suppress_errors( true )` "in case of
502 // concurrent migration race" — there's no realistic race for
503 // this ALTER (schema migrations run inside one request) and the
504 // suppression hid a real failure mode on at least one local
505 // install: the option got stamped at v11 but the CHANGE COLUMN
506 // never landed, so every later placements query 500'd silently.
507 // Let any wpdb error surface to `debug.log` so the failure is
508 // visible the next time this code runs.
509 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
510 $wpdb->query(
511 "ALTER TABLE `{$tbl}` CHANGE COLUMN `user_id` `owner_id` BIGINT UNSIGNED NOT NULL"
512 );
513
514 // MySQL/MariaDB auto-update index column references on CHANGE
515 // COLUMN, but the index NAMES are baked in. Rename them too so
516 // `EXPLAIN`/`SHOW INDEX` output reads consistently with the
517 // column.
518 $has_user_parent = (int) $wpdb->get_var(
519 $wpdb->prepare(
520 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
521 WHERE TABLE_SCHEMA = DATABASE()
522 AND TABLE_NAME = %s
523 AND INDEX_NAME = 'user_parent'",
524 $tbl
525 )
526 );
527 if ( 0 < $has_user_parent ) {
528 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
529 $wpdb->query( "ALTER TABLE `{$tbl}` RENAME INDEX `user_parent` TO `owner_parent`" );
530 }
531 }
532
533 /**
534 * Belt-and-suspenders verifier for the v8 `shares` table. `dbDelta`
535 * has known edge cases where a brand-new table with `UNIQUE KEY`
536 * declarations on a non-`utf8mb4` collation gets silently skipped
537 * on some MySQL/MariaDB combos; we mirror the trash-columns
538 * pattern and `CREATE TABLE IF NOT EXISTS` the row explicitly.
539 *
540 * @internal
541 */
542 function desktop_mode_files_ensure_shares_table() {
543 global $wpdb;
544 $tables = desktop_mode_files_table_names();
545 $charset_collate = $wpdb->get_charset_collate();
546 $tbl = $tables['shares'];
547
548 $exists = (int) $wpdb->get_var(
549 $wpdb->prepare(
550 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
551 WHERE TABLE_SCHEMA = DATABASE()
552 AND TABLE_NAME = %s",
553 $tbl
554 )
555 );
556 if ( 0 === $exists ) {
557 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
558 $wpdb->query(
559 "CREATE TABLE IF NOT EXISTS `{$tbl}` (
560 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
561 target_type VARCHAR(32) NOT NULL DEFAULT 'folder',
562 folder_id BIGINT UNSIGNED NOT NULL,
563 principal_type VARCHAR(16) NOT NULL,
564 principal_ref VARCHAR(191) NOT NULL,
565 capability VARCHAR(8) NOT NULL DEFAULT 'read',
566 state VARCHAR(16) NOT NULL DEFAULT 'pending',
567 invited_by BIGINT UNSIGNED NOT NULL,
568 invited_at_ms BIGINT UNSIGNED NOT NULL,
569 decided_at_ms BIGINT UNSIGNED NULL,
570 PRIMARY KEY (id),
571 UNIQUE KEY uniq_principal (target_type, folder_id, principal_type, principal_ref),
572 KEY by_principal (principal_type, principal_ref, state),
573 KEY target (target_type, folder_id)
574 ) $charset_collate"
575 );
576 } else {
577 // Existing table — make sure `target_type` is there for
578 // installs that ran a pre-target_type build of v8.
579 $has_col = (int) $wpdb->get_var(
580 $wpdb->prepare(
581 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
582 WHERE TABLE_SCHEMA = DATABASE()
583 AND TABLE_NAME = %s
584 AND COLUMN_NAME = %s",
585 $tbl,
586 'target_type'
587 )
588 );
589 if ( 0 === $has_col ) {
590 // Same TOCTOU rationale as the other ensure_* helpers
591 // — concurrent worker that already added the column
592 // surfaces a benign MySQL 1060 we should swallow.
593 $prev_suppress = $wpdb->suppress_errors( true );
594 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
595 $wpdb->query( "ALTER TABLE `{$tbl}` ADD COLUMN `target_type` VARCHAR(32) NOT NULL DEFAULT 'folder' AFTER `id`" );
596 $wpdb->suppress_errors( $prev_suppress );
597 }
598 }
599 }
600
601 /**
602 * Belt-and-suspenders verifier for the decisions table.
603 *
604 * @internal
605 */
606 function desktop_mode_files_ensure_decisions_table() {
607 global $wpdb;
608 $tables = desktop_mode_files_table_names();
609 $charset_collate = $wpdb->get_charset_collate();
610 $tbl = $tables['decisions'];
611
612 $exists = (int) $wpdb->get_var(
613 $wpdb->prepare(
614 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
615 WHERE TABLE_SCHEMA = DATABASE()
616 AND TABLE_NAME = %s",
617 $tbl
618 )
619 );
620 if ( 0 === $exists ) {
621 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
622 $wpdb->query(
623 "CREATE TABLE IF NOT EXISTS `{$tbl}` (
624 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
625 share_id BIGINT UNSIGNED NOT NULL,
626 user_id BIGINT UNSIGNED NOT NULL,
627 state VARCHAR(16) NOT NULL DEFAULT 'pending',
628 decided_at_ms BIGINT UNSIGNED NOT NULL,
629 PRIMARY KEY (id),
630 UNIQUE KEY uniq_share_user (share_id, user_id),
631 KEY by_user (user_id, state)
632 ) $charset_collate"
633 );
634 }
635 }
636
637 /**
638 * Lazy migrator — runs on `admin_init` when the stored schema
639 * version doesn't match the constant. Idempotent: `dbDelta`
640 * itself is a no-op when the table already matches.
641 */
642 function desktop_mode_files_maybe_install_schema() {
643 $installed = get_option( DESKTOP_MODE_FILES_SCHEMA_OPTION, '' );
644 if ( $installed === DESKTOP_MODE_FILES_SCHEMA_VERSION ) {
645 return;
646 }
647 desktop_mode_files_install_schema();
648 }
649 add_action( 'admin_init', 'desktop_mode_files_maybe_install_schema' );
650 // REST + front-end requests never fire `admin_init` — without these
651 // hooks a session that hits a REST endpoint before any admin page
652 // load would query the placements / folders tables before the v2
653 // trash columns exist, throwing wpdb errors and blanking the desktop.
654 add_action( 'rest_api_init', 'desktop_mode_files_maybe_install_schema' );
655 add_action( 'init', 'desktop_mode_files_maybe_install_schema', 1 );
656 register_activation_hook( DESKTOP_MODE_FILE, 'desktop_mode_files_install_schema' );
657
658 /**
659 * Current epoch-ms timestamp. Centralized so the store and the
660 * tombstone writer stay in lock-step.
661 *
662 * @return int
663 */
664 function desktop_mode_files_now_ms() {
665 return (int) round( microtime( true ) * 1000 );
666 }
667