reader_registration_preflight(); if ( is_wp_error( $preflight ) ) { return $preflight; } $site = Prweb_Site_License::ensure(); if ( is_wp_error( $site ) ) { return $site; } $reseller_id = (string) ( Prweb_Plugin::current_pds_user_id() ?? '' ); if ( '' === $reseller_id ) { return new WP_Error( 'prweb_no_publisher', 'Site is not connected to PDS yet.' ); } global $wpdb; $table = Prweb_Brand::table( self::TABLE ); // MySQL caps an advisory lock name at 64 characters and errors past // it, so size the hash slice from what the prefix actually costs // rather than a number that silently goes stale when the prefix moves // — which is exactly what ORL-213 did to the previous hardcoded 50. $lock_prefix = Prweb_Brand::option( 'reader_' ); $lock = $lock_prefix . substr( hash( 'sha256', $registration_key ), 0, self::LOCK_NAME_MAX_LENGTH - strlen( $lock_prefix ) ); if ( 1 !== (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 5)', $lock ) ) ) { return new WP_Error( 'prweb_reader_busy', 'Reader registration is already in progress. Retry shortly.' ); } try { $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE registration_key = %s LIMIT 1', $table, $registration_key ), ARRAY_A ); $token = self::token_for_registration( $registration_key ); $email = is_string( $email ) ? sanitize_email( $email ) : null; $email_to_save = $email && is_email( $email ) ? $email : null; if ( ! $row ) { $inserted = $wpdb->insert( $table, [ 'session_token_hash' => hash( 'sha256', $token ), 'registration_key' => $registration_key, 'registration_state' => 'creating', 'pds_user_id' => '', 'pds_prv_key_enc' => '', 'email' => $email_to_save, 'site_offer_id' => (int) $site['offer_id'], 'last_seen_at' => current_time( 'mysql', true ), ] ); if ( false === $inserted ) { return new WP_Error( 'prweb_reader_persist_failed', 'Could not persist the reader registration attempt.' ); } $row = [ 'id' => (int) $wpdb->insert_id, 'registration_state' => 'creating', 'pds_user_id' => null, 'pds_prv_key_enc' => null, 'license_buy_offer_id' => null, ]; } $state = (string) ( $row['registration_state'] ?? 'creating' ); $user_id = (string) ( $row['pds_user_id'] ?? '' ); $prv_key = '' !== (string) ( $row['pds_prv_key_enc'] ?? '' ) ? (string) ( self::decrypt( (string) $row['pds_prv_key_enc'] ) ?? '' ) : ''; if ( 'creating' === $state ) { $user = $client->create_user( $registration_key ); if ( is_wp_error( $user ) ) { return $user; } $user_id = $user['user_id']; $prv_key = $user['prv_key']; if ( false === $wpdb->update( $table, [ 'pds_user_id' => $user_id, 'pds_prv_key_enc' => self::encrypt( $prv_key ), 'registration_state' => 'created', ], [ 'id' => (int) $row['id'] ] ) ) { return new WP_Error( 'prweb_reader_persist_failed', 'Could not persist the created reader identity.' ); } $state = 'created'; } if ( '' === $user_id || '' === $prv_key ) { return new WP_Error( 'prweb_reader_state_invalid', 'Stored reader registration state is incomplete.' ); } if ( 'created' === $state ) { $funded = $client->fund_user( $user_id, self::FUND_LEOS, $registration_key ); if ( is_wp_error( $funded ) ) { return $funded; } if ( false === $wpdb->update( $table, [ 'registration_state' => 'funded' ], [ 'id' => (int) $row['id'] ] ) ) { return new WP_Error( 'prweb_reader_persist_failed', 'Could not persist the reader funding result.' ); } $state = 'funded'; } $license_id = (string) ( $row['license_buy_offer_id'] ?? '' ); if ( 'funded' === $state ) { $deal = $client->open_buy_offer( $user_id, $prv_key, $reseller_id, (int) $site['dab_id'], Prweb_Site_License::MIN_PRICE_LEOS, $registration_key ); if ( is_wp_error( $deal ) ) { return $deal; } $license_id = (string) ( $deal['offer_id'] ?? '' ); if ( false === $wpdb->update( $table, [ 'license_buy_offer_id' => $license_id, 'registration_state' => 'complete', ], [ 'id' => (int) $row['id'] ] ) ) { return new WP_Error( 'prweb_reader_persist_failed', 'Could not persist the ACCESS license.' ); } } return [ 'token' => $token, 'pds_user_id' => $user_id, 'license_id' => $license_id, 'site_dab_id' => (int) $site['dab_id'], ]; } finally { $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } private static function token_for_registration( string $registration_key ): string { return hash_hmac( 'sha256', 'reader-session|' . $registration_key, wp_salt( 'auth' ) ); } /** * Look up the row backing a cookie. Returns null if the cookie is * missing/forged. Updates `last_seen_at` as a side effect (cheap and * lets the admin Readers page show activity). */ public static function verify( ?string $cookie_value ): ?array { if ( ! $cookie_value || ! preg_match( self::TOKEN_SHAPE, $cookie_value ) ) { return null; } global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT id, pds_user_id, license_buy_offer_id, email, last_seen_at, created_at FROM %i WHERE session_token_hash = %s LIMIT 1', Prweb_Brand::table( self::TABLE ), hash( 'sha256', $cookie_value ) ), ARRAY_A ); if ( ! $row ) { return null; } // Throttle the touch to once per minute to avoid a write per pageview. $last_seen = $row['last_seen_at'] ? strtotime( $row['last_seen_at'] . ' UTC' ) : 0; if ( time() - $last_seen > 60 ) { $wpdb->update( Prweb_Brand::table( self::TABLE ), [ 'last_seen_at' => current_time( 'mysql', true ) ], [ 'id' => (int) $row['id'] ] ); } return $row; } /** * Drop the row matching this cookie value (the row whose hash matches). * Used by /reader/logout. Does NOT cancel the on-chain BUY offer — that * already closed the deal at register-time. */ public static function forget( ?string $cookie_value ): void { if ( ! $cookie_value || ! preg_match( self::TOKEN_SHAPE, $cookie_value ) ) { return; } global $wpdb; $wpdb->delete( Prweb_Brand::table( self::TABLE ), [ 'session_token_hash' => hash( 'sha256', $cookie_value ) ], [ '%s' ] ); } /** @return array{count:int,active_24h:int} */ public static function stats(): array { global $wpdb; $table = Prweb_Brand::table( self::TABLE ); $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); $active_24h = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE last_seen_at > UTC_TIMESTAMP() - INTERVAL 1 DAY', $table ) ); return [ 'count' => $count, 'active_24h' => $active_24h ]; } /** * Set the session cookie on the response. HTTPOnly + SameSite=Lax; * Secure flag follows the request scheme. Any historical spelling the * browser is still carrying is expired in the same response, so the * reader leaves with exactly one session cookie. */ public static function set_cookie( string $token ): void { if ( headers_sent() ) { return; } self::write_cookie( self::COOKIE, $token, time() + self::COOKIE_TTL_S ); $_COOKIE[ self::COOKIE ] = $token; self::expire_legacy_cookies(); } /** * Expire every spelling, not just the current one. A browser that * upgraded across the prefix move still holds the legacy cookie, and * clearing only the new name would leave cookie_value() re-authenticating * a reader who just signed out (ORL-213). */ public static function clear_cookie(): void { if ( headers_sent() ) { return; } foreach ( self::cookie_names() as $name ) { self::write_cookie( $name, '', time() - 3600 ); unset( $_COOKIE[ $name ] ); } } /** * Read the session token from whichever spelling the browser sent, * current name first. * * The cookie name moved with the storage prefix in ORL-213, and no * server-side migration can reach a value that lives in the visitor's * browser — accepting the old spelling on read is the only thing that * keeps an issued session alive. Refusing it signs every existing reader * out, and because register() dedupes on a sessionStorage key that moved * in the same release, the re-registration mints a second funded PDS * identity and opens a second paid BUY license per reader. */ public static function cookie_value(): ?string { foreach ( self::cookie_names() as $name ) { if ( ! is_string( $_COOKIE[ $name ] ?? null ) ) { continue; } // Tokens are 64-char hex and verify() re-checks that, so // unslashing + sanitizing here cannot corrupt a legitimate value. $val = sanitize_text_field( wp_unslash( $_COOKIE[ $name ] ) ); if ( '' === $val ) { continue; } if ( self::COOKIE !== $name && preg_match( self::TOKEN_SHAPE, $val ) ) { // Adopt the current spelling while the response can still // carry headers. Mid-render this is a no-op and the legacy // cookie simply keeps resolving on the next request. Only a // well-shaped token is adopted — verify() still decides // whether it resolves to a session, but there is no reason to // re-issue somebody else's junk under our own name. self::set_cookie( $val ); } return $val; } return null; } /** * Every cookie name a reader session may arrive under, current first. * * @return string[] */ private static function cookie_names(): array { return Prweb_Brand::cookie_candidates( self::COOKIE_NAME ); } /** Expire the historical spellings the browser actually sent. */ private static function expire_legacy_cookies(): void { foreach ( self::cookie_names() as $name ) { if ( self::COOKIE === $name || ! isset( $_COOKIE[ $name ] ) ) { continue; } self::write_cookie( $name, '', time() - 3600 ); unset( $_COOKIE[ $name ] ); } } /** Single place the cookie attributes are decided. */ private static function write_cookie( string $name, string $value, int $expires ): void { setcookie( $name, $value, [ 'expires' => $expires, 'path' => '/', 'domain' => '', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ] ); } // ------------------------------------------------------------------------- // Encryption — sodium_secretbox keyed by wp_salt('auth') // ------------------------------------------------------------------------- private static function key(): string { // wp_salt seeded into a 256-bit key. Stable across requests on the // same install; rotating salt rotates the key (existing rows become // unreadable, which is fine — readers just re-register). return sodium_crypto_generichash( wp_salt( 'auth' ) . '|webapp-reader', '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES ); } public static function encrypt( string $plain ): string { $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); $cipher = sodium_crypto_secretbox( $plain, $nonce, self::key() ); return base64_encode( $nonce . $cipher ); } public static function decrypt( string $encoded ): ?string { $bin = base64_decode( $encoded, true ); if ( $bin === false || strlen( $bin ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) { return null; } $nonce = substr( $bin, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); $cipher = substr( $bin, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); $plain = sodium_crypto_secretbox_open( $cipher, $nonce, self::key() ); return $plain === false ? null : $plain; } }