get_json_params(); $client = preg_replace( '/[^a-z0-9]/i', '', (string) ( $p['client'] ?? '' ) ); if ( ! $client || strlen( $client ) > 32 ) { return new \WP_Error( 'akram_sync', __( 'Missing client id.', 'akram' ), array( 'status' => 400 ) ); } $since = max( 0, (int) ( $p['since'] ?? 0 ) ); $ops = array(); foreach ( array_slice( (array) ( $p['ops'] ?? array() ), 0, 500 ) as $op ) { $op = self::clean_op( $op ); if ( $op ) { $ops[] = $op; } } $sel = array_values( array_filter( array_slice( (array) ( $p['sel'] ?? array() ), 0, 20 ), static function ( $x ) { return is_string( $x ) && preg_match( '/^[a-z0-9_-]{1,24}$/i', $x ); } ) ); if ( ! self::lock( $id ) ) { return new \WP_Error( 'akram_sync_busy', __( 'Busy, retrying.', 'akram' ), array( 'status' => 503 ) ); } $key = 'akram_sync_' . $id; wp_cache_delete( $key, 'options' ); $log = get_option( $key ); $log = is_array( $log ) ? $log : array( 'seq' => 0, 'ops' => array(), 'clients' => array(), ); $me = get_current_user_id(); $u = wp_get_current_user(); // Clients that went quiet leave the session. foreach ( $log['clients'] as $cid => $c ) { if ( time() - (int) $c['seen'] > self::IDLE ) { unset( $log['clients'][ $cid ] ); } } if ( ! empty( $p['leave'] ) ) { unset( $log['clients'][ $client ] ); } else { $known = $log['clients'][ $client ] ?? null; $log['clients'][ $client ] = array( 'user' => $me, 'name' => $u->display_name, 'color' => $known['color'] ?? self::color( $log['clients'] ), 'sel' => $sel, 'seen' => time(), ); } // A client that fell behind the kept window must reload the shared document. $oldest = $log['ops'] ? (int) $log['ops'][0]['seq'] : $log['seq'] + 1; $reset = $since > 0 && $since < $oldest - 1; $remote = array(); if ( ! $reset ) { foreach ( $log['ops'] as $op ) { if ( $op['seq'] > $since && $op['client'] !== $client ) { $remote[] = $op; } } } foreach ( $ops as $op ) { $op['seq'] = ++$log['seq']; $op['client'] = $client; $op['user'] = $me; $log['ops'][] = $op; } if ( count( $log['ops'] ) > self::KEEP ) { $log['ops'] = array_slice( $log['ops'], -self::KEEP ); } if ( ! $log['clients'] ) { delete_option( $key ); // nobody left: the log is no longer needed } else { update_option( $key, $log, false ); } self::unlock( $id ); $peers = array(); foreach ( $log['clients'] as $cid => $c ) { if ( $cid !== $client ) { $peers[] = array( 'client' => $cid, 'user' => (int) $c['user'], 'name' => $c['name'], 'color' => $c['color'], 'sel' => $c['sel'], ); } } $out = array( 'seq' => $log['seq'], 'ops' => $remote, 'peers' => $peers, 'hash' => Document::hash( $id ), ); if ( $reset ) { $out['reset'] = true; } return $out; } /** Shared-session snapshot for a client that joins (or fell behind): latest saved/draft doc + log position. */ public static function join( $id ) { $log = get_option( 'akram_sync_' . $id ); return is_array( $log ) ? array( 'seq' => (int) $log['seq'], 'ops' => $log['ops'], ) : array( 'seq' => 0, 'ops' => array(), ); } /** Number of other people in the shared session (used to decide whether to keep syncing). */ public static function active( $id ) { $log = get_option( 'akram_sync_' . $id ); if ( ! is_array( $log ) ) { return 0; } $n = 0; foreach ( $log['clients'] as $c ) { if ( time() - (int) $c['seen'] <= self::IDLE ) { $n++; } } return $n; } private static function clean_op( $op ) { if ( ! is_array( $op ) ) { return null; } if ( array_key_exists( 'root', $op ) ) { return is_array( $op['root'] ) ? array( 'root' => array_values( array_filter( $op['root'], array( __CLASS__, 'valid_id' ) ) ) ) : null; } if ( array_key_exists( 'page', $op ) ) { return is_array( $op['page'] ) ? array( 'page' => $op['page'] ) : null; } $id = (string) ( $op['id'] ?? '' ); if ( ! self::valid_id( $id ) ) { return null; } if ( null === ( $op['el'] ?? null ) ) { return array( 'id' => $id, 'el' => null, ); } $el = Sanitize::element( $id, $op['el'] ); return $el ? array( 'id' => $id, 'el' => $el, ) : null; } public static function valid_id( $id ) { return is_string( $id ) && (bool) preg_match( '/^[a-z0-9_-]{1,24}$/i', $id ); } private static function color( array $clients ) { $used = wp_list_pluck( $clients, 'color' ); foreach ( self::COLORS as $c ) { if ( ! in_array( $c, $used, true ) ) { return $c; } } return self::COLORS[ count( $clients ) % count( self::COLORS ) ]; } /** Row-level mutex: add_option fails while another request holds it (stale after 5 s). */ private static function lock( $id ) { global $wpdb; $name = 'akram_sync_lock_' . $id; for ( $i = 0; $i < 60; $i++ ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ( $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", $name, (string) time() ) ) ) { return true; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery $t = (int) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) ); if ( $t && time() - $t > 5 ) { self::unlock( $id ); continue; } usleep( 50000 ); } return false; } private static function unlock( $id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", 'akram_sync_lock_' . $id ) ); } }