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 / notes / rest.php

rest.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.8, at includes/notes/rest.php

592 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Pinned notes REST routes.
4 *
5 * Routes under `/desktop-mode/v1/notes`:
6 *
7 * GET /notes List the viewer's own notes
8 * (private + publish) plus every
9 * other user's public notes.
10 * POST /notes Create a note (author = viewer).
11 * PATCH /notes/(?P<id>\d+) Partial update — owner only.
12 * DELETE /notes/(?P<id>\d+) Soft-trash — owner only.
13 * POST /notes/(?P<id>\d+)/restore Untrash (Undo toast) — owner only.
14 * POST /notes/(?P<id>\d+)/convert Spawn a draft post from the note,
15 * then trash the note — owner only,
16 * requires the `edit_posts` cap.
17 *
18 * Ownership model: a note belongs to its `post_author`, and ONLY the
19 * owner may mutate it — deliberately including administrators. Public
20 * notes are a read-only broadcast surface; "manage other users'
21 * notes" is not a supported operation through this controller.
22 *
23 * Optimistic concurrency: PATCH accepts an `updatedAtMs` field
24 * carrying the client's last-seen modified timestamp; a mismatch
25 * returns 409 `desktop_mode_notes_conflict` with the server copy in
26 * `data.current` so the client can re-render instead of clobbering.
27 *
28 * @package WPDesktopMode
29 */
30
31 defined( 'ABSPATH' ) || exit;
32
33 /**
34 * Base permission: logged-in + desktop mode enabled.
35 *
36 * @return true|WP_Error
37 */
38 function desktop_mode_notes_rest_permission() {
39 if ( ! is_user_logged_in() ) {
40 return new WP_Error( 'desktop_mode_notes_unauthenticated', __( 'You must be logged in.', 'desktop-mode' ), array( 'status' => 401 ) );
41 }
42 if ( function_exists( 'desktop_mode_is_enabled' ) && ! desktop_mode_is_enabled( get_current_user_id() ) ) {
43 return new WP_Error( 'desktop_mode_notes_disabled', __( 'Desktop mode is not enabled for this user.', 'desktop-mode' ), array( 'status' => 403 ) );
44 }
45 return true;
46 }
47
48 /**
49 * Register the routes.
50 */
51 function desktop_mode_notes_register_rest_routes() {
52 $ns = 'desktop-mode/v1';
53
54 register_rest_route(
55 $ns,
56 '/notes',
57 array(
58 array(
59 'methods' => WP_REST_Server::READABLE,
60 'permission_callback' => 'desktop_mode_notes_rest_permission',
61 'callback' => 'desktop_mode_notes_rest_list',
62 ),
63 array(
64 'methods' => WP_REST_Server::CREATABLE,
65 'permission_callback' => 'desktop_mode_notes_rest_permission',
66 'callback' => 'desktop_mode_notes_rest_create',
67 'args' => array(
68 'text' => array(
69 'type' => 'string',
70 'default' => '',
71 'sanitize_callback' => 'sanitize_textarea_field',
72 ),
73 'color' => array(
74 'type' => 'string',
75 'default' => 'butter',
76 'sanitize_callback' => 'desktop_mode_notes_sanitize_color',
77 ),
78 'x' => array( 'type' => 'number', 'default' => 0.1 ),
79 'y' => array( 'type' => 'number', 'default' => 0.1 ),
80 'public' => array( 'type' => 'boolean', 'default' => false ),
81 'seed' => array(
82 'type' => 'integer',
83 'default' => 0,
84 'sanitize_callback' => 'absint',
85 ),
86 ),
87 ),
88 )
89 );
90
91 register_rest_route(
92 $ns,
93 '/notes/(?P<id>\d+)',
94 array(
95 array(
96 'methods' => WP_REST_Server::EDITABLE,
97 'permission_callback' => 'desktop_mode_notes_rest_permission',
98 'callback' => 'desktop_mode_notes_rest_update',
99 ),
100 array(
101 'methods' => WP_REST_Server::DELETABLE,
102 'permission_callback' => 'desktop_mode_notes_rest_permission',
103 'callback' => 'desktop_mode_notes_rest_delete',
104 ),
105 )
106 );
107
108 register_rest_route(
109 $ns,
110 '/notes/(?P<id>\d+)/restore',
111 array(
112 'methods' => WP_REST_Server::CREATABLE,
113 'permission_callback' => 'desktop_mode_notes_rest_permission',
114 'callback' => 'desktop_mode_notes_rest_restore',
115 )
116 );
117
118 register_rest_route(
119 $ns,
120 '/notes/(?P<id>\d+)/convert',
121 array(
122 'methods' => WP_REST_Server::CREATABLE,
123 'permission_callback' => 'desktop_mode_notes_rest_permission',
124 'callback' => 'desktop_mode_notes_rest_convert',
125 )
126 );
127 }
128 add_action( 'rest_api_init', 'desktop_mode_notes_register_rest_routes' );
129
130 /**
131 * Fetch a note post, or a WP_Error when it doesn't exist / isn't a note.
132 *
133 * @param int $id Post ID.
134 * @param bool $allow_trash Whether a trashed note is acceptable (restore path).
135 * @return WP_Post|WP_Error
136 */
137 function desktop_mode_notes_get_note( $id, $allow_trash = false ) {
138 $post = get_post( (int) $id );
139 if ( ! $post instanceof WP_Post || DESKTOP_MODE_NOTES_POST_TYPE !== $post->post_type ) {
140 return new WP_Error( 'desktop_mode_notes_not_found', __( 'Note not found.', 'desktop-mode' ), array( 'status' => 404 ) );
141 }
142 $allowed = $allow_trash ? array( 'private', 'publish', 'trash' ) : array( 'private', 'publish' );
143 if ( ! in_array( $post->post_status, $allowed, true ) ) {
144 return new WP_Error( 'desktop_mode_notes_not_found', __( 'Note not found.', 'desktop-mode' ), array( 'status' => 404 ) );
145 }
146 return $post;
147 }
148
149 /**
150 * Owner gate. Only the note's author may mutate it — including admins.
151 *
152 * Returning 404 (not 403) for other users' PRIVATE notes would leak
153 * less, but the id namespace is shared with public notes anyway and
154 * a mutation attempt on a visible public note deserves an honest 403.
155 *
156 * @param WP_Post $post Note post.
157 * @return true|WP_Error
158 */
159 function desktop_mode_notes_require_owner( $post ) {
160 if ( (int) $post->post_author !== get_current_user_id() ) {
161 return new WP_Error( 'desktop_mode_notes_forbidden', __( 'Only the note owner can change it.', 'desktop-mode' ), array( 'status' => 403 ) );
162 }
163 return true;
164 }
165
166 /**
167 * Modified timestamp in milliseconds (GMT).
168 *
169 * Second precision (WordPress stores no sub-second post dates) — the
170 * client treats the value as an opaque token and echoes it back.
171 *
172 * @param WP_Post $post Post.
173 * @return int
174 */
175 function desktop_mode_notes_modified_ms( $post ) {
176 return (int) get_post_modified_time( 'U', true, $post ) * 1000;
177 }
178
179 /**
180 * Serialize a note for the wire.
181 *
182 * @param WP_Post $post Note post.
183 * @return array
184 */
185 function desktop_mode_notes_prepare( $post ) {
186 $owner_id = (int) $post->post_author;
187 $owner = get_userdata( $owner_id );
188
189 return array(
190 'id' => (int) $post->ID,
191 'text' => (string) get_post_field( 'post_content', $post, 'raw' ),
192 'color' => desktop_mode_notes_sanitize_color( get_post_meta( $post->ID, '_wpd_note_color', true ) ),
193 'x' => desktop_mode_notes_sanitize_fraction( get_post_meta( $post->ID, '_wpd_note_x', true ) ),
194 'y' => desktop_mode_notes_sanitize_fraction( get_post_meta( $post->ID, '_wpd_note_y', true ) ),
195 'z' => (int) get_post_meta( $post->ID, '_wpd_note_z', true ),
196 'public' => 'publish' === $post->post_status,
197 'seed' => (int) get_post_meta( $post->ID, '_wpd_note_seed', true ),
198 'ownerId' => $owner_id,
199 'ownerName' => $owner instanceof WP_User ? (string) $owner->display_name : '',
200 'ownerAvatar' => (string) get_avatar_url( $owner_id, array( 'size' => 48 ) ),
201 'canEdit' => get_current_user_id() === $owner_id,
202 'updatedAtMs' => desktop_mode_notes_modified_ms( $post ),
203 );
204 }
205
206 /**
207 * Derive the post title from the note text (first non-empty line).
208 *
209 * Only used for admin-side lists / exports — the shell never shows it.
210 *
211 * @param string $text Note text.
212 * @return string
213 */
214 function desktop_mode_notes_derive_title( $text ) {
215 foreach ( preg_split( '/\r\n|\r|\n/', (string) $text ) as $line ) {
216 $line = trim( $line );
217 if ( '' !== $line ) {
218 return mb_substr( sanitize_text_field( $line ), 0, 80 );
219 }
220 }
221 return __( 'Note', 'desktop-mode' );
222 }
223
224 /**
225 * GET /notes — own notes (private + publish) ∪ others' publish.
226 *
227 * @return WP_REST_Response
228 */
229 function desktop_mode_notes_rest_list() {
230 $user_id = get_current_user_id();
231
232 // Newest first: the per-half cap exists as a runaway guard, and
233 // when it ever bites it must drop the OLDEST notes — capping an
234 // ascending list would silently hide every recently pinned note
235 // (and the boot high-water would stop the Heartbeat delta from
236 // ever backfilling them).
237 $own = new WP_Query(
238 array(
239 'post_type' => DESKTOP_MODE_NOTES_POST_TYPE,
240 'post_status' => array( 'private', 'publish' ),
241 'author' => $user_id,
242 'posts_per_page' => 200,
243 'orderby' => 'date',
244 'order' => 'DESC',
245 'no_found_rows' => true,
246 )
247 );
248
249 $public = new WP_Query(
250 array(
251 'post_type' => DESKTOP_MODE_NOTES_POST_TYPE,
252 'post_status' => 'publish',
253 'author__not_in' => array( $user_id ),
254 'posts_per_page' => 200,
255 'orderby' => 'date',
256 'order' => 'DESC',
257 'no_found_rows' => true,
258 )
259 );
260
261 $notes = array();
262 foreach ( array_merge( (array) $own->posts, (array) $public->posts ) as $post ) {
263 $notes[] = desktop_mode_notes_prepare( $post );
264 }
265 wp_reset_postdata();
266
267 return rest_ensure_response( array( 'notes' => $notes ) );
268 }
269
270 /**
271 * POST /notes.
272 *
273 * @param WP_REST_Request $request Request.
274 * @return WP_REST_Response|WP_Error
275 */
276 function desktop_mode_notes_rest_create( $request ) {
277 /**
278 * Filters whether the current user may create a note.
279 *
280 * Notes default to any logged-in desktop-mode user — including
281 * publishing PUBLIC notes onto every other user's wallpaper.
282 * Sites that want to restrict that (by role, capability, or the
283 * request's `public` flag) hook here.
284 *
285 * @param bool $can_create Whether creation is allowed. Default true.
286 * @param int $user_id Current user id.
287 * @param WP_REST_Request $request The create request (inspect `public`, `text`, ...).
288 */
289 $can_create = apply_filters( 'desktop_mode_notes_user_can_create', true, get_current_user_id(), $request );
290 if ( ! $can_create ) {
291 return new WP_Error( 'desktop_mode_notes_forbidden', __( 'You are not allowed to create notes.', 'desktop-mode' ), array( 'status' => 403 ) );
292 }
293
294 $text = sanitize_textarea_field( (string) $request['text'] );
295
296 $post_id = wp_insert_post(
297 array(
298 'post_type' => DESKTOP_MODE_NOTES_POST_TYPE,
299 'post_status' => $request['public'] ? 'publish' : 'private',
300 'post_author' => get_current_user_id(),
301 'post_title' => desktop_mode_notes_derive_title( $text ),
302 'post_content' => $text,
303 ),
304 true
305 );
306 if ( is_wp_error( $post_id ) ) {
307 $post_id->add_data( array( 'status' => 500 ) );
308 return $post_id;
309 }
310
311 update_post_meta( $post_id, '_wpd_note_color', desktop_mode_notes_sanitize_color( $request['color'] ) );
312 update_post_meta( $post_id, '_wpd_note_x', desktop_mode_notes_sanitize_fraction( $request['x'] ) );
313 update_post_meta( $post_id, '_wpd_note_y', desktop_mode_notes_sanitize_fraction( $request['y'] ) );
314 update_post_meta( $post_id, '_wpd_note_z', desktop_mode_notes_next_z() );
315 // The jitter seed is written ONCE, here — PATCH never touches it,
316 // so editing a note's text never re-tilts its paper. The client
317 // sends its own text hash (keeps the optimistic render identical);
318 // fall back to a server-side hash when absent.
319 $seed = absint( $request['seed'] );
320 if ( 0 === $seed ) {
321 $seed = absint( crc32( $text ) ) % 2147483647;
322 $seed = $seed > 0 ? $seed : 1;
323 }
324 update_post_meta( $post_id, '_wpd_note_seed', $seed );
325
326 return rest_ensure_response( desktop_mode_notes_prepare( get_post( $post_id ) ) );
327 }
328
329 /**
330 * Next z-order value across all live (non-trashed) notes.
331 *
332 * Deliberately site-wide, not per-owner: public notes from different
333 * owners stack on the same wall, so a fresh note must land above
334 * everyone's papers. Cheap max-of-meta walk — note counts are tiny
335 * (a wall of paper, not a database of record).
336 *
337 * @return int
338 */
339 function desktop_mode_notes_next_z() {
340 global $wpdb;
341 $max = $wpdb->get_var(
342 $wpdb->prepare(
343 "SELECT MAX( CAST( pm.meta_value AS UNSIGNED ) )
344 FROM {$wpdb->postmeta} pm
345 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
346 WHERE pm.meta_key = %s AND p.post_type = %s AND p.post_status IN ( 'private', 'publish' )",
347 '_wpd_note_z',
348 DESKTOP_MODE_NOTES_POST_TYPE
349 )
350 );
351 return (int) $max + 1;
352 }
353
354 /**
355 * PATCH /notes/:id — partial update, owner only.
356 *
357 * @param WP_REST_Request $request Request.
358 * @return WP_REST_Response|WP_Error
359 */
360 function desktop_mode_notes_rest_update( $request ) {
361 $post = desktop_mode_notes_get_note( $request['id'] );
362 if ( is_wp_error( $post ) ) {
363 return $post;
364 }
365 $owner = desktop_mode_notes_require_owner( $post );
366 if ( is_wp_error( $owner ) ) {
367 return $owner;
368 }
369
370 // Optimistic concurrency — a stale token means another session
371 // (or device) changed the note since this client last saw it.
372 $client_ms = $request['updatedAtMs'];
373 if ( null !== $client_ms && (int) $client_ms !== desktop_mode_notes_modified_ms( $post ) ) {
374 return new WP_Error(
375 'desktop_mode_notes_conflict',
376 __( 'The note was changed by another session.', 'desktop-mode' ),
377 array(
378 'status' => 409,
379 'current' => desktop_mode_notes_prepare( $post ),
380 )
381 );
382 }
383
384 $update = array( 'ID' => $post->ID );
385
386 if ( null !== $request['text'] ) {
387 $text = sanitize_textarea_field( (string) $request['text'] );
388 $update['post_content'] = $text;
389 $update['post_title'] = desktop_mode_notes_derive_title( $text );
390 }
391 if ( null !== $request['public'] ) {
392 $update['post_status'] = rest_sanitize_boolean( $request['public'] ) ? 'publish' : 'private';
393 }
394
395 if ( null !== $request['color'] ) {
396 update_post_meta( $post->ID, '_wpd_note_color', desktop_mode_notes_sanitize_color( $request['color'] ) );
397 }
398 if ( null !== $request['x'] ) {
399 update_post_meta( $post->ID, '_wpd_note_x', desktop_mode_notes_sanitize_fraction( $request['x'] ) );
400 }
401 if ( null !== $request['y'] ) {
402 update_post_meta( $post->ID, '_wpd_note_y', desktop_mode_notes_sanitize_fraction( $request['y'] ) );
403 }
404 if ( null !== $request['z'] ) {
405 update_post_meta( $post->ID, '_wpd_note_z', absint( $request['z'] ) );
406 }
407
408 // Always run the post update — even a meta-only PATCH must bump
409 // `post_modified` so the concurrency token advances and the
410 // Heartbeat delta query sees the move.
411 $result = wp_update_post( $update, true );
412 if ( is_wp_error( $result ) ) {
413 $result->add_data( array( 'status' => 500 ) );
414 return $result;
415 }
416
417 return rest_ensure_response( desktop_mode_notes_prepare( get_post( $post->ID ) ) );
418 }
419
420 /**
421 * DELETE /notes/:id — soft-trash, owner only.
422 *
423 * @param WP_REST_Request $request Request.
424 * @return WP_REST_Response|WP_Error
425 */
426 function desktop_mode_notes_rest_delete( $request ) {
427 $post = desktop_mode_notes_get_note( $request['id'] );
428 if ( is_wp_error( $post ) ) {
429 return $post;
430 }
431 $owner = desktop_mode_notes_require_owner( $post );
432 if ( is_wp_error( $owner ) ) {
433 return $owner;
434 }
435
436 if ( ! wp_trash_post( $post->ID ) ) {
437 return new WP_Error( 'desktop_mode_notes_trash_failed', __( 'Could not move the note to the trash.', 'desktop-mode' ), array( 'status' => 500 ) );
438 }
439
440 return rest_ensure_response( array( 'trashed' => true, 'id' => (int) $post->ID ) );
441 }
442
443 /**
444 * POST /notes/:id/restore — untrash (Undo), owner only.
445 *
446 * @param WP_REST_Request $request Request.
447 * @return WP_REST_Response|WP_Error
448 */
449 function desktop_mode_notes_rest_restore( $request ) {
450 $post = desktop_mode_notes_get_note( $request['id'], true );
451 if ( is_wp_error( $post ) ) {
452 return $post;
453 }
454 $owner = desktop_mode_notes_require_owner( $post );
455 if ( is_wp_error( $owner ) ) {
456 return $owner;
457 }
458 if ( 'trash' !== $post->post_status ) {
459 return rest_ensure_response( desktop_mode_notes_prepare( $post ) );
460 }
461
462 if ( ! wp_untrash_post( $post->ID ) ) {
463 return new WP_Error( 'desktop_mode_notes_restore_failed', __( 'Could not restore the note.', 'desktop-mode' ), array( 'status' => 500 ) );
464 }
465
466 // If this note was trashed by a "convert to post" action, undoing
467 // the conversion must also discard the draft it spawned — otherwise
468 // Undo would leave the note back on the wall AND a stray draft. The
469 // link is written by the convert route (`_wpd_note_converted_post`)
470 // and consumed once here. Only a still-present draft is trashed; a
471 // draft the user already published or trashed themselves is left be.
472 $converted_post_id = (int) get_post_meta( $post->ID, '_wpd_note_converted_post', true );
473 if ( $converted_post_id > 0 ) {
474 delete_post_meta( $post->ID, '_wpd_note_converted_post' );
475 $draft = get_post( $converted_post_id );
476 if ( $draft instanceof WP_Post && 'draft' === $draft->post_status ) {
477 wp_trash_post( $converted_post_id );
478 }
479 }
480
481 return rest_ensure_response( desktop_mode_notes_prepare( get_post( $post->ID ) ) );
482 }
483
484 /**
485 * Convert a note's plain text into Gutenberg paragraph-block markup.
486 *
487 * Blank lines split paragraphs; single newlines within a paragraph
488 * become `<br>`. The result lands clean in the block editor rather
489 * than as one classic-HTML blob.
490 *
491 * @param string $text Note text.
492 * @return string Serialized block markup (empty string for empty text).
493 */
494 function desktop_mode_notes_text_to_blocks( $text ) {
495 $text = str_replace( array( "\r\n", "\r" ), "\n", (string) $text );
496 $paragraphs = preg_split( '/\n{2,}/', trim( $text ) );
497 $blocks = array();
498 foreach ( $paragraphs as $paragraph ) {
499 $paragraph = trim( $paragraph, "\n" );
500 if ( '' === $paragraph ) {
501 continue;
502 }
503 $html = nl2br( esc_html( $paragraph ), false );
504 $blocks[] = "<!-- wp:paragraph -->\n<p>{$html}</p>\n<!-- /wp:paragraph -->";
505 }
506 return implode( "\n\n", $blocks );
507 }
508
509 /**
510 * POST /notes/:id/convert — spawn a draft post from a note, then trash
511 * the note. Owner only, and the owner must be able to author posts.
512 *
513 * The note is trashed (not hard-deleted) and linked to its new draft
514 * via `_wpd_note_converted_post` so the standard restore route can undo
515 * both sides of the conversion (see `desktop_mode_notes_rest_restore`).
516 *
517 * @param WP_REST_Request $request Request.
518 * @return WP_REST_Response|WP_Error
519 */
520 function desktop_mode_notes_rest_convert( $request ) {
521 $post = desktop_mode_notes_get_note( $request['id'] );
522 if ( is_wp_error( $post ) ) {
523 return $post;
524 }
525 $owner = desktop_mode_notes_require_owner( $post );
526 if ( is_wp_error( $owner ) ) {
527 return $owner;
528 }
529 if ( ! current_user_can( 'edit_posts' ) ) {
530 return new WP_Error( 'desktop_mode_notes_cannot_create_posts', __( 'You are not allowed to create posts.', 'desktop-mode' ), array( 'status' => 403 ) );
531 }
532
533 $text = (string) get_post_field( 'post_content', $post, 'raw' );
534 $title = desktop_mode_notes_derive_title( $text );
535
536 /**
537 * Filters the arguments used to create the draft post from a note.
538 *
539 * Hook here to change the post type/status, assign a category or
540 * author, or wrap the body in different block markup.
541 *
542 * @param array $post_args Args passed to `wp_insert_post()`.
543 * @param WP_Post $post The source note.
544 * @param WP_REST_Request $request The convert request.
545 */
546 $post_args = apply_filters(
547 'desktop_mode_notes_convert_post_args',
548 array(
549 'post_type' => 'post',
550 'post_status' => 'draft',
551 'post_author' => (int) $post->post_author,
552 'post_title' => $title,
553 'post_content' => desktop_mode_notes_text_to_blocks( $text ),
554 ),
555 $post,
556 $request
557 );
558
559 $new_post_id = wp_insert_post( $post_args, true );
560 if ( is_wp_error( $new_post_id ) ) {
561 $new_post_id->add_data( array( 'status' => 500 ) );
562 return $new_post_id;
563 }
564
565 // Link the note to its draft BEFORE trashing so restore can reverse
566 // both sides. If the trash fails, roll the draft back so a failed
567 // conversion never leaves an orphan draft behind.
568 update_post_meta( $post->ID, '_wpd_note_converted_post', (int) $new_post_id );
569 if ( ! wp_trash_post( $post->ID ) ) {
570 wp_delete_post( $new_post_id, true );
571 delete_post_meta( $post->ID, '_wpd_note_converted_post' );
572 return new WP_Error( 'desktop_mode_notes_convert_failed', __( 'Could not convert the note to a post.', 'desktop-mode' ), array( 'status' => 500 ) );
573 }
574
575 /**
576 * Fires after a note has been converted to a draft post.
577 *
578 * @param int $new_post_id The draft post id.
579 * @param WP_Post $post The source note (now trashed).
580 * @param WP_REST_Request $request The convert request.
581 */
582 do_action( 'desktop_mode_notes_converted', (int) $new_post_id, $post, $request );
583
584 return rest_ensure_response(
585 array(
586 'noteId' => (int) $post->ID,
587 'postId' => (int) $new_post_id,
588 'editUrl' => (string) get_edit_post_link( $new_post_id, 'raw' ),
589 )
590 );
591 }
592