sessions = $sessions; $this->runner = $runner; } /** * Start: create a session and set the cookie. Refused while a recording or an automatic run is * in progress (the key rotation below would break it). * * @param string $exclude Excluded plugin file. * @return array{id:int,token:string,expires:int,expires_in:int,cookie:string}|\WP_Error */ public function start( string $exclude = '' ) { $valid = SessionRunner::validate_exclude( $exclude ); if ( is_wp_error( $valid ) ) { return $valid; } if ( $this->active() ) { return new \WP_Error( 'bloatwright_browse_active', __( 'Browse mode is already active.', 'bloatwright' ), array( 'status' => 409 ) ); } if ( $this->sessions->running_auto() ) { return new \WP_Error( 'bloatwright_running', __( 'A profiling run is in progress. Let it finish or cancel it before starting a browse-and-profile recording.', 'bloatwright' ), array( 'status' => 409 ) ); } Signature::rotate(); $ttl = (int) Settings::get( 'cookie_ttl', 120 ); $id = $this->sessions->create( array(), 1, 'browse', $exclude, '' ); SessionRunner::prune( $this->sessions ); $exp = time() + $ttl; // The recording ends when its cookie does: remember when, so active() can tell afterwards. $s = $this->sessions->find( $id ); $progress = (array) ( $s['progress'] ?? array() ); $progress['expires'] = $exp; $this->sessions->update( $id, array( 'progress' => $progress ) ); $token = Signature::sign( array( 'session' => $id, 'url' => '', 'pass' => 1, 'exclude' => $exclude, 'mode' => 'browse', 'exp' => $exp, ) ); $this->set_cookie( $token, $exp ); update_option( self::OPTION, $id, false ); return array( 'id' => $id, 'token' => $token, 'expires' => $exp, 'expires_in' => $ttl, 'cookie' => Signature::COOKIE, ); } /** * Stop: clear the cookie, collect visited URLs, finish. * * @param int $session_id Session. * @return array|\WP_Error */ public function stop( int $session_id ) { $this->set_cookie( '', time() - 3600 ); delete_option( self::OPTION ); $urls = array(); foreach ( $this->sessions->requests( $session_id ) as $r ) { $urls[ $r['url'] ] = true; } $this->sessions->update( $session_id, array( 'urls' => array_keys( $urls ), 'progress' => array( 'next' => count( $urls ), 'done' => count( $urls ), 'total' => count( $urls ), 'errors' => array(), 'cached' => 0, ), ) ); return $this->runner->finish( $session_id ); } /** * Active recording id (0 when none). A recording whose cookie has expired is not active: it is * finished here, on the first check after it expired, with whatever it recorded. */ public function active(): int { $s = self::recording( $this->sessions ); if ( null === $s ) { return 0; } if ( self::expires_at( $s ) <= time() ) { if ( delete_option( self::OPTION ) ) { // Only the request that clears the option finishes it. $this->stop( (int) $s['id'] ); } return 0; } return (int) $s['id']; } /** * Recording not stopped yet, whether or not its cookie expired (0 when none). Used by "stop". */ public function pending(): int { $s = self::recording( $this->sessions ); return null === $s ? 0 : (int) $s['id']; } /** * Expiry (unix time) of the active recording, 0 when none. */ public function expires(): int { $s = self::recording( $this->sessions ); return null === $s ? 0 : max( 0, self::expires_at( $s ) ); } /** * Id of the recording that is in progress and not expired (0 when none), without finishing an * expired one. For callers that must not have side effects (the runner deciding about the key). * * @param SessionRepository $sessions Sessions. */ public static function current( SessionRepository $sessions ): int { $s = self::recording( $sessions ); return null !== $s && self::expires_at( $s ) > time() ? (int) $s['id'] : 0; } /** * Session row of the recording the option points to, or null (a stale option is cleared). * * @param SessionRepository $sessions Sessions. */ private static function recording( SessionRepository $sessions ): ?array { $id = (int) get_option( self::OPTION, 0 ); if ( $id <= 0 ) { return null; } $s = $sessions->find( $id ); if ( ! $s || 'running' !== $s['status'] || 'browse' !== $s['mode'] ) { delete_option( self::OPTION ); return null; } return $s; } /** * When a recording ends: the stored expiry, else (a recording started before it was stored) the * start time plus the configured cookie lifetime. * * @param array $s Session row. */ private static function expires_at( array $s ): int { $exp = (int) ( $s['progress']['expires'] ?? 0 ); if ( $exp > 0 ) { return $exp; } $started = strtotime( (string) ( $s['started_at'] ?? '' ) . ' UTC' ); return ( false === $started ? 0 : $started ) + (int) Settings::get( 'cookie_ttl', 120 ); } /** * Cookie options: host-only (no Domain attribute, so it is never shared with other subdomains, * whatever COOKIE_DOMAIN says), first-party only (SameSite=Lax), HttpOnly, Secure on https. * * @param int $expires Expiry timestamp. * @param string $path Cookie path. * @return array{expires:int,path:string,domain:string,secure:bool,httponly:bool,samesite:string} */ public static function cookie_options( int $expires, string $path ): array { return array( 'expires' => $expires, 'path' => '' !== $path ? $path : '/', 'domain' => '', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ); } /** * Set/clear the cookie. * * @param string $value Value. * @param int $expires Expiry. */ private function set_cookie( string $value, int $expires ): void { if ( headers_sent() ) { return; } $path = (string) ( defined( 'COOKIEPATH' ) ? constant( 'COOKIEPATH' ) : '/' ); $site = (string) ( defined( 'SITECOOKIEPATH' ) ? constant( 'SITECOOKIEPATH' ) : '/' ); setcookie( Signature::COOKIE, $value, self::cookie_options( $expires, $path ) ); if ( '/' !== $site && $site !== $path ) { setcookie( Signature::COOKIE, $value, self::cookie_options( $expires, $site ) ); } } }