Skip to content
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ $expiry = wp_get_presence_room_next_expiry( $rooms ); // array( $room => 'Y-m-
// Remove all presence entries for a user across all rooms.
wp_remove_user_presence( $user_id );

// Which browser this request comes from, as seen from one room; see session_hash below.
$session_hash = wp_presence_session_hash( $room );

// Check whether a user can access a room (edit_post for a post's room, otherwise any post type).
wp_can_access_presence_room( $room, $user_id = 0 );

Expand Down Expand Up @@ -220,6 +223,7 @@ Each entry object returned by `wp_get_presence()` or `wp_get_presence_by_room_pr
| `room` | `string` | The room the entry belongs to. |
| `client_id` | `string` | A `varchar` column, opaque, and not guaranteed numeric even when it looks like one. See [Client IDs](#client-ids). |
| `user_id` | `string` | `"0"` for an entry with no signed-in user. Every column comes back as a string, so cast before a strict comparison. |
| `session_hash` | `string` | The same for every tab of one login, so two values for one user mean two browsers. It is keyed per room and never the session token itself. Empty for a row written without the user's own login session, such as WP-CLI, cron or an application password. |
| `data` | `array` | Decoded from the stored JSON; an empty array if that JSON failed to decode. |
| `date_gmt` | `string` | A MySQL `datetime` string in UTC (e.g. `2024-01-01 12:00:00`), not a Unix timestamp. Convert with `strtotime( $entry->date_gmt . ' UTC' )`. See below for how far behind a live client it can sit. |

Expand Down
28 changes: 23 additions & 5 deletions plugins/presence-api/includes/presence.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ function wp_presence_room_rows( $room, $timeout = null, $client_prefix = '' ) {
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT room, client_id, user_id, data, date_gmt FROM {$wpdb->presence} WHERE room = %s AND expires_gmt > %s AND date_gmt > %s{$client_clause} ORDER BY date_gmt DESC",
"SELECT room, client_id, user_id, session_hash, data, date_gmt FROM {$wpdb->presence} WHERE room = %s AND expires_gmt > %s AND date_gmt > %s{$client_clause} ORDER BY date_gmt DESC",
...$args
)
);
Expand Down Expand Up @@ -874,8 +874,11 @@ function wp_presence_write_row( $room, $client_id, $user_id, $data_json, $date_g
$expires_gmt = wp_presence_expiry_for( $date_gmt );
}

// A session belongs to the person making the request, so a row written for anyone else carries none.
$session_hash = ! wp_presence_is_reserved_client_id( $client_id ) && $user_id && get_current_user_id() === (int) $user_id ? wp_presence_session_hash( $room ) : '';

$date_clause = 'date_gmt = VALUES(date_gmt), expires_gmt = VALUES(expires_gmt)';
$args = array( $room, $client_id, $user_id, $data_json, $date_gmt, $expires_gmt );
$args = array( $room, $client_id, $user_id, $session_hash, $data_json, $date_gmt, $expires_gmt );
$now = gmdate( 'Y-m-d H:i:s' );
$on_sqlite = is_a( $wpdb, 'WP_SQLite_DB' );

Expand All @@ -888,7 +891,7 @@ function wp_presence_write_row( $room, $client_id, $user_id, $data_json, $date_g
*/
$signal = '';
if ( ! $on_sqlite ) {
$signal = 'id = id + 0 * LAST_INSERT_ID( CASE WHEN data <> VALUES(data) OR user_id <> VALUES(user_id) OR expires_gmt <= %s THEN 1 ELSE 2 END ), ';
$signal = 'id = id + 0 * LAST_INSERT_ID( CASE WHEN data <> VALUES(data) OR user_id <> VALUES(user_id) OR session_hash <> VALUES(session_hash) OR expires_gmt <= %s THEN 1 ELSE 2 END ), ';
$args[] = $now;
}

Expand All @@ -907,7 +910,7 @@ function wp_presence_write_row( $room, $client_id, $user_id, $data_json, $date_g
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"INSERT INTO {$wpdb->presence} (room, client_id, user_id, data, date_gmt, expires_gmt) VALUES (%s, %s, %d, %s, %s, %s) ON DUPLICATE KEY UPDATE {$signal}user_id = VALUES(user_id), {$date_clause}, data = VALUES(data)",
"INSERT INTO {$wpdb->presence} (room, client_id, user_id, session_hash, data, date_gmt, expires_gmt) VALUES (%s, %s, %d, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE {$signal}user_id = VALUES(user_id), session_hash = VALUES(session_hash), {$date_clause}, data = VALUES(data)",
Comment thread
josephfusco marked this conversation as resolved.
...$args
)
);
Expand All @@ -930,6 +933,21 @@ function wp_presence_write_row( $room, $client_id, $user_id, $data_json, $date_g
return false === $result ? false : (int) $result;
}

/**
* Returns which browser the current request comes from, as a per-room hash of its login session that every tab of that login shares.
*
* @since 0.18.0
*
* @param string $room The room identifier.
* @return string Sixteen hex characters, or an empty string for a request with
* no login session, such as cron, WP-CLI or an application password.
*/
function wp_presence_session_hash( $room ) {
$token = wp_get_session_token();

return '' === $token ? '' : substr( hash_hmac( 'sha256', (string) $room, $token ), 0, 16 );
}

/**
* Removes a client from a room.
*
Expand Down Expand Up @@ -1723,7 +1741,7 @@ function wp_get_presence_by_room_prefix( $prefix, $timeout = null ) {
return wp_presence_cached_rows(
"prefix:{$prefix}",
$wpdb->prepare(
"SELECT room, client_id, user_id, data, date_gmt FROM {$wpdb->presence} WHERE room LIKE %s AND expires_gmt > %s AND date_gmt > %s AND client_id NOT LIKE %s ORDER BY date_gmt DESC",
"SELECT room, client_id, user_id, session_hash, data, date_gmt FROM {$wpdb->presence} WHERE room LIKE %s AND expires_gmt > %s AND date_gmt > %s AND client_id NOT LIKE %s ORDER BY date_gmt DESC",
$wpdb->esc_like( $prefix ) . '%',
$cutoff,
$stale,
Expand Down
9 changes: 8 additions & 1 deletion plugins/presence-api/includes/privacy.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ function wp_presence_get_rows_for_user( $user_id ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
return $wpdb->get_results(
$wpdb->prepare(
"SELECT room, data, date_gmt FROM {$wpdb->presence} WHERE user_id = %d ORDER BY date_gmt DESC",
"SELECT room, session_hash, data, date_gmt FROM {$wpdb->presence} WHERE user_id = %d ORDER BY date_gmt DESC",
$user_id
)
);
Expand Down Expand Up @@ -153,6 +153,13 @@ function wp_presence_personal_data_exporter( $email_address ) {
);
}

if ( '' !== (string) $row->session_hash ) {
$data[] = array(
'name' => __( 'Browser', 'presence-api' ),
'value' => $row->session_hash,
);
}

$data[] = array(
'name' => __( 'Last active', 'presence-api' ),
'value' => $row->date_gmt . ' GMT',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ public function get_items( $request ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT room, client_id, user_id, data, date_gmt FROM {$wpdb->presence} WHERE room = %s AND expires_gmt > %s AND client_id NOT LIKE %s ORDER BY date_gmt DESC LIMIT %d OFFSET %d",
"SELECT room, client_id, user_id, session_hash, data, date_gmt FROM {$wpdb->presence} WHERE room = %s AND expires_gmt > %s AND client_id NOT LIKE %s ORDER BY date_gmt DESC LIMIT %d OFFSET %d",
$room,
$cutoff,
wp_presence_reserved_client_id_pattern(),
Expand Down Expand Up @@ -564,11 +564,12 @@ public function create_item( $request ) {
}

$entry = (object) array(
'room' => $room,
'client_id' => $client_id,
'user_id' => $current_user_id,
'data' => $data,
'date_gmt' => gmdate( 'Y-m-d H:i:s' ),
'room' => $room,
'client_id' => $client_id,
'user_id' => $current_user_id,
'session_hash' => wp_presence_session_hash( $room ),
'data' => $data,
'date_gmt' => gmdate( 'Y-m-d H:i:s' ),
);

return rest_ensure_response( $this->prepare_item_for_response( $entry, $request )->get_data() );
Expand Down Expand Up @@ -761,6 +762,10 @@ public function prepare_item_for_response( $item, $request ) {
$data['user_id'] = (int) $item->user_id;
}

if ( rest_is_field_included( 'session_hash', $fields ) ) {
$data['session_hash'] = isset( $item->session_hash ) ? (string) $item->session_hash : '';
}

$user = get_userdata( $item->user_id );

if ( rest_is_field_included( 'display_name', $fields ) ) {
Expand Down Expand Up @@ -876,6 +881,12 @@ public function get_item_schema() {
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'session_hash' => array(
'description' => __( 'Which browser the entry was written from, the same for every tab of one login and different per room; empty when written without a login session.', 'presence-api' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'display_name' => array(
'description' => __( 'The display name of the user.', 'presence-api' ),
'type' => 'string',
Expand Down
1 change: 1 addition & 0 deletions plugins/presence-api/includes/schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ function wp_maybe_create_presence_table() {
room varchar({$max_index_length}) NOT NULL default '',
client_id varchar({$max_index_length}) NOT NULL default '',
user_id bigint(20) unsigned NOT NULL default '0',
session_hash varchar(16) NOT NULL default '',
data longtext NOT NULL,
date_gmt datetime NOT NULL default '0000-00-00 00:00:00',
expires_gmt datetime NOT NULL default '0000-00-00 00:00:00',
Expand Down
2 changes: 1 addition & 1 deletion plugins/presence-api/presence-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function () {
}

define( 'WP_PRESENCE_VERSION', '0.17.0' );
define( 'WP_PRESENCE_DB_VERSION', 3 );
define( 'WP_PRESENCE_DB_VERSION', 4 );
define( 'WP_PRESENCE_NETWORK_SUMMARY_DB_VERSION', 1 );
define( 'WP_PRESENCE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'WP_PRESENCE_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
Expand Down
170 changes: 170 additions & 0 deletions plugins/presence-api/tests/test-session-hash.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php
/**
* Tests for the session hash that tells a second browser from a second tab.
*
* @package Presence_API
*
* @group presence
*/
class WP_Test_Presence_Session_Hash extends WP_Presence_UnitTestCase {

/**
* Editor who writes the rows.
*
* @var int
*/
protected static $editor_id;

public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) );
}

public function tear_down() {
unset( $_COOKIE[ LOGGED_IN_COOKIE ] );
parent::tear_down();
}

/**
* Signs the editor in with a fresh login session, as a browser of their own would.
*
* @return string The session token.
*/
private function sign_in() {
$expiration = time() + DAY_IN_SECONDS;
$token = WP_Session_Tokens::get_instance( self::$editor_id )->create( $expiration );

$_COOKIE[ LOGGED_IN_COOKIE ] = wp_generate_auth_cookie( self::$editor_id, $expiration, 'logged_in', $token );
wp_set_current_user( self::$editor_id );

return $token;
}

/**
* Returns the session_hash stored for a client.
*
* @param string $room Room.
* @param string $client_id Client ID.
* @return string The stored value.
*/
private function stored_hash( $room, $client_id ) {
foreach ( wp_get_presence( $room ) as $entry ) {
if ( $entry->client_id === $client_id ) {
return $entry->session_hash;
}
}
return null;
}

/**
* Two tabs of one login share a value and a second login gets another, and no token reaches the table.
*
* @covers ::wp_presence_session_hash
* @covers ::wp_set_presence
*/
public function test_tabs_share_a_session_and_browsers_do_not() {
global $wpdb;

$room = 'postType/post:1';

$laptop = $this->sign_in();
wp_set_presence( $room, 'tab-1', array(), array( 'user_id' => self::$editor_id ) );
wp_set_presence( $room, 'tab-2', array(), array( 'user_id' => self::$editor_id ) );

$phone = $this->sign_in();
wp_set_presence( $room, 'tab-3', array(), array( 'user_id' => self::$editor_id ) );

$first = $this->stored_hash( $room, 'tab-1' );
$this->assertMatchesRegularExpression( '/^[0-9a-f]{16}$/', $first );
$this->assertSame( $first, $this->stored_hash( $room, 'tab-2' ) );
$this->assertNotSame( $first, $this->stored_hash( $room, 'tab-3' ) );

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = wp_json_encode( $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->presence} WHERE room = %s", $room ) ) );
$this->assertStringNotContainsString( $laptop, $rows );
$this->assertStringNotContainsString( $phone, $rows );
}

/**
* Signing in again in the same tab changes what peers see, so it moves the room version like any other change.
*
* @covers ::wp_presence_write_row
*/
public function test_a_new_login_in_the_same_tab_moves_the_version() {
$room = 'postType/post:1';

$this->sign_in();
wp_set_presence( $room, 'tab-1', array(), array( 'user_id' => self::$editor_id ) );
$before = wp_get_presence_room_versions( $room )[ $room ];

$this->sign_in();
wp_set_presence( $room, 'tab-1', array(), array( 'user_id' => self::$editor_id ) );

$this->assertSame( (string) ( $before + 1 ), wp_get_presence_room_versions( $room )[ $room ] );
}

/**
* One session hashes differently per room, so the value cannot follow it from room to room.
*
* @covers ::wp_presence_session_hash
*/
public function test_one_session_differs_per_room() {
$this->sign_in();

$this->assertNotSame( wp_presence_session_hash( 'postType/post:1' ), wp_presence_session_hash( 'postType/post:2' ) );
}

/**
* A write without the user's own session, or for another user, records no browser.
*
* @covers ::wp_set_presence
*/
public function test_writes_without_the_users_session_stay_empty() {
$room = 'postType/post:1';

wp_set_current_user( self::$editor_id );
wp_set_presence( $room, 'cli-1', array(), array( 'user_id' => self::$editor_id ) );
$this->assertSame( '', $this->stored_hash( $room, 'cli-1' ) );

$this->sign_in();
wp_set_presence( $room, 'agent-1', array(), array( 'user_id' => self::factory()->user->create() ) );
$this->assertSame( '', $this->stored_hash( $room, 'agent-1' ) );
}

/**
* The personal data export names the browser a row came from, and leaves it out for a row with none.
*
* @covers ::wp_presence_personal_data_exporter
*/
public function test_the_export_includes_the_browser() {
$room = 'postType/post:1';
$this->sign_in();
wp_set_presence( $room, 'tab-1', array(), array( 'user_id' => self::$editor_id ) );

$export = wp_presence_personal_data_exporter( get_userdata( self::$editor_id )->user_email );
$this->assertContains( wp_presence_session_hash( $room ), wp_list_pluck( $export['data'][0]['data'], 'value' ) );

unset( $_COOKIE[ LOGGED_IN_COOKIE ] );
wp_set_presence( $room, 'tab-1', array(), array( 'user_id' => self::$editor_id ) );

$export = wp_presence_personal_data_exporter( get_userdata( self::$editor_id )->user_email );
$this->assertNotContains( 'Browser', wp_list_pluck( $export['data'][0]['data'], 'name' ) );
}

/**
* The REST collection returns the hash a browser wrote, and never the token behind it.
*
* @covers WP_REST_Presence_Controller::prepare_item_for_response
*/
public function test_rest_returns_the_hash() {
$room = 'postType/post:' . self::factory()->post->create( array( 'post_author' => self::$editor_id ) );
$token = $this->sign_in();
wp_set_presence( $room, 'tab-1', array(), array( 'user_id' => self::$editor_id ) );

$request = new WP_REST_Request( 'GET', '/wp-presence/v1/presence' );
$request->set_param( 'room', $room );
$data = rest_get_server()->dispatch( $request )->get_data();

$this->assertSame( wp_presence_session_hash( $room ), $data[0]['session_hash'] );
$this->assertStringNotContainsString( $token, wp_json_encode( $data ) );
}
}
Loading