repository = $repository; $this->builder = $builder; $this->access = $access ? $access : new Access_Policy( $repository ); } /** * Produces the response for a slug. * * @param string $slug Requested slug. * @param string $if_none_match Value of the If-None-Match request header. * @param array $request Credentials and connection facts for protected feeds (see Access_Policy::authorize()). * @return array{status:int,headers:array,body:string,nocache:bool} */ public function respond( string $slug, string $if_none_match = '', array $request = array() ): array { try { return $this->build_response( $slug, $if_none_match, $request ); } catch ( \Throwable $e ) { return $this->unavailable(); } } /** * Builds the response; exceptions are handled by respond(). * * @param string $slug Requested slug. * @param string $if_none_match If-None-Match header. * @param array $request Request facts. * @return array{status:int,headers:array,body:string,nocache:bool} */ private function build_response( string $slug, string $if_none_match, array $request ): array { if ( 1 !== preg_match( '/^[a-z0-9]+(-[a-z0-9]+)*$/D', $slug ) || strlen( $slug ) > 60 ) { return $this->not_found(); } $feed_id = $this->repository->id_for_slug( $slug ); $config = $feed_id ? $this->repository->find( $feed_id ) : null; if ( ! $config || ! $config->enabled ) { return $this->not_found(); } // Authorization comes first: nothing below (the cache lookup, the query, the ETag // comparison, the 304) runs for a request that has not proven it may read this feed. $protected = Feed_Config::ACCESS_PROTECTED === $config->access; if ( $protected ) { $decision = $this->access->authorize( $config, $feed_id, $request ); if ( ! $decision->allowed ) { return array( 'status' => $decision->status, 'headers' => $decision->headers, 'body' => $decision->body, 'nocache' => true, ); } } $result = $this->builder->build( $config, true, false ); if ( ! $result->ok ) { return $this->unavailable(); } $etag = '"' . md5( $result->body ) . '"'; $max_age = max( 0, (int) apply_filters( 'feedweaver_http_max_age', 300, $config ) ); $headers = array( 'Content-Type' => $result->content_type, 'X-Content-Type-Options' => 'nosniff', 'X-Robots-Tag' => 'noindex', 'Cache-Control' => 'public, max-age=' . $max_age, 'ETag' => $etag, ); if ( $protected ) { // Never shareable: not by CDNs, proxies or page caches. Browsers may not store it either. $headers['Cache-Control'] = 'private, no-store, max-age=0'; $headers['Pragma'] = 'no-cache'; $headers['Expires'] = 'Wed, 11 Jan 1984 05:00:00 GMT'; $headers['Vary'] = 'Authorization'; $headers['Referrer-Policy'] = 'no-referrer'; } if ( $this->etag_matches( $if_none_match, $etag ) ) { return array( 'status' => 304, 'headers' => $headers, 'body' => '', 'nocache' => $protected, ); } return array( 'status' => 200, 'headers' => $headers, 'body' => $result->body, 'nocache' => $protected, ); } /** * Whether an If-None-Match header (list, weak validators, or *) matches the ETag. * * @param string $header If-None-Match value. * @param string $etag Current ETag, quoted. * @return bool */ private function etag_matches( string $header, string $etag ): bool { if ( '' === trim( $header ) ) { return false; } if ( '*' === trim( $header ) ) { return true; } foreach ( explode( ',', $header ) as $candidate ) { $candidate = trim( $candidate ); if ( 0 === strncmp( $candidate, 'W/', 2 ) ) { $candidate = substr( $candidate, 2 ); } if ( $candidate === $etag ) { return true; } } return false; } /** * A generic failure response that reveals nothing about the cause. * * @return array{status:int,headers:array,body:string,nocache:bool} */ private function unavailable(): array { return array( 'status' => 500, 'headers' => array( 'Content-Type' => 'text/plain; charset=UTF-8', 'Cache-Control' => 'no-store', 'X-Content-Type-Options' => 'nosniff', ), 'body' => 'Feed unavailable.', 'nocache' => true, ); } /** * A 404 response description. * * @return array{status:int,headers:array,body:string} */ private function not_found(): array { return array( 'status' => 404, 'headers' => array( 'Cache-Control' => 'no-store' ), 'body' => '', 'nocache' => true, ); } }