status(); return ! is_wp_error( $status ) && in_array( $status['status'], array( 'connected', 'offline' ), true ) ? (int) ( $status['account']['id'] ?? 0 ) : 0; } public static function error( string $code = 'embed_unavailable', int $status = 503 ): WP_Error { return new WP_Error( $code, __( 'This Waitlistize embed is unavailable. Check its account binding and connection settings.', 'waitlistize' ), array( 'status' => $status ) ); } /** W1 alone owns refresh/rotation. This adapter never writes credential state. */ private function request( int $account_id, string $endpoint, array $query ): array|WP_Error { if ( $account_id <= 0 || $this->account_id() !== $account_id ) { return self::error( 'embed_binding', 409 ); } $store = new TokenStore(); $state = $store->read(); if ( time() + 60 >= ( $state['expires_at'] ?? 0 ) ) { $check = ( new ConnectionManager() )->check(); if ( is_wp_error( $check ) ) { return $check; } } $mutex = new Mutex(); return $mutex->run( function () use ( $store, $mutex, $account_id, $endpoint, $query ) { $state = $store->read(); if ( $store->disabled() || ! in_array( $state['status'], array( 'connected', 'offline' ), true ) || ! empty( $state['verify_account_slug'] ) || ( $state['account_id'] ?? 0 ) !== $account_id || ( $state['account']['id'] ?? 0 ) !== $account_id || empty( $state['access_token'] ) || time() >= ( $state['expires_at'] ?? 0 ) ) { return self::error(); } $origin = ( new Client() )->origin(); if ( is_wp_error( $origin ) ) { return $origin; } $response = wp_remote_get( add_query_arg( $query, $origin . Client::PREFIX . $endpoint ), array( 'headers' => array( 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $state['access_token'], ), 'timeout' => 12, 'redirection' => 0, 'sslverify' => true, 'limit_response_size' => 1048576, 'cookies' => array(), ) ); // Disconnect can disable locally while this worker holds the mutex. if ( ! $mutex->owned() || $store->disabled() || ( $store->read()['account_id'] ?? 0 ) !== $account_id ) { return self::error(); } if ( is_wp_error( $response ) ) { return self::error(); } $status = wp_remote_retrieve_response_code( $response ); if ( 200 !== $status ) { // In particular, 403 is NOT a request to delete or broaden the connection. return self::error( 'embed_api', in_array( $status, array( 401, 403, 404, 429 ), true ) ? $status : 502 ); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); return is_array( $data ) && isset( $data['data'] ) && is_array( $data['data'] ) ? $data : self::error( 'embed_contract', 502 ); } ); } private function query( string $locale ): array { return in_array( $locale, array( 'hu', 'en', 'de' ), true ) ? array( 'locale' => $locale ) : array(); } public function catalog( int $account_id, int $page = 1, int $per_page = 50, string $locale = 'auto' ): array|WP_Error { if ( $page < 1 || $page > 100000 || $per_page < 1 || $per_page > 100 || ! in_array( $locale, array( 'auto', 'hu', 'en', 'de' ), true ) ) { return self::error( 'embed_parameters', 400 ); } $result = $this->request( $account_id, 'services', $this->query( $locale ) + array( 'page' => $page, 'per_page' => $per_page, ) ); if ( is_wp_error( $result ) ) { return $result; } $meta = $result['meta'] ?? array(); if ( ! is_int( $meta['last_page'] ?? null ) || $meta['last_page'] < 1 || ( $meta['page'] ?? null ) !== $page || ! is_int( $meta['total'] ?? null ) || count( $result['data'] ) > $per_page ) { return self::error( 'embed_contract', 502 ); } $rows = array(); foreach ( $result['data'] as $row ) { $service = $this->project( $row ); if ( is_wp_error( $service ) ) { return $service; } $rows[] = $service; } return array( 'data' => $rows, 'meta' => array_intersect_key( $meta, array_flip( array( 'page', 'per_page', 'total', 'last_page', 'locale', 'timezone' ) ) ), ); } public function detail( int $account_id, int $id, string $locale = 'auto' ): array|WP_Error { if ( $id <= 0 || ! in_array( $locale, array( 'auto', 'hu', 'en', 'de' ), true ) ) { return self::error( 'embed_parameters', 400 ); } $result = $this->request( $account_id, 'services/' . $id, $this->query( $locale ) ); if ( is_wp_error( $result ) ) { return $result; } $row = $this->project( $result['data'] ); return ! is_wp_error( $row ) && $row['id'] === $id ? $row : self::error( 'embed_contract', 502 ); } /** No raw settings, forms, resources, credentials or admin links in editor data. */ private function project( mixed $row ): array|WP_Error { if ( ! is_array( $row ) || ! is_int( $row['id'] ?? null ) || $row['id'] <= 0 ) { return self::error( 'embed_contract', 502 ); } foreach ( array( 'slug', 'type', 'name', 'timezone' ) as $key ) { if ( ! is_string( $row[ $key ] ?? null ) ) { return self::error( 'embed_contract', 502 ); } } $result = array_intersect_key( $row, array_flip( array( 'id', 'slug', 'type', 'name', 'timezone' ) ) ); foreach ( array( 'description', 'public_price_amount', 'public_price_currency' ) as $key ) { $result[ $key ] = is_scalar( $row[ $key ] ?? null ) ? (string) $row[ $key ] : ''; } $result['accent_color'] = (string) sanitize_hex_color( is_string( $row['appearance']['accent_color'] ?? null ) ? $row['appearance']['accent_color'] : '' ); return $result; } /** Resolve only through the tenant-scoped GET catalog; never invent a slug route. */ public function by_slug( int $account_id, string $slug, string $locale ): array|WP_Error { for ( $page = 1; $page <= 20; ++$page ) { $catalog = $this->catalog( $account_id, $page, 100, $locale ); if ( is_wp_error( $catalog ) ) { return $catalog; } foreach ( $catalog['data'] as $service ) { if ( $service['slug'] === $slug ) { return $this->detail( $account_id, $service['id'], $locale ); } } if ( $page >= $catalog['meta']['last_page'] ) { return self::error( 'embed_service_missing', 404 ); } } return self::error( 'embed_catalog_limit', 503 ); } }