sites = $sites; $this->runs = $runs; $this->builder = $builder; } /** * Hooks the public download handler. * * @return void */ public function register() { add_action( 'init', array( $this, 'maybe_serve' ), 1 ); } /** * A signed link to a run's PDF. * * @param Profile $site Site. * @param int $run_id Run ID. * @param int|null $expires Unix time; defaults to the site's link lifetime. * @return string Empty when links are off for the site. */ public static function url( Profile $site, $run_id, $expires = null ) { if ( empty( $site->delivery['share_link'] ) || '' === (string) $site->delivery['link_key'] ) { return ''; } $expires = null === $expires ? time() + (int) $site->delivery['link_days'] * DAY_IN_SECONDS : (int) $expires; return add_query_arg( self::QUERY_VAR, self::token( $site, (int) $run_id, $expires ), home_url( '/' ) ); } /** * When a link made now would expire. * * @param Profile $site Site. * @return int Unix time. */ public static function expiry( Profile $site ) { return time() + (int) $site->delivery['link_days'] * DAY_IN_SECONDS; } /** * Signed token. * * @param Profile $site Site. * @param int $run_id Run ID. * @param int $expires Unix time. * @return string */ public static function token( Profile $site, $run_id, $expires ) { $payload = Base64Url::encode( (string) wp_json_encode( array( 's' => $site->id, 'r' => (int) $run_id, 'x' => (int) $expires, ) ) ); return $payload . '.' . Base64Url::encode( hash_hmac( 'sha256', $payload, self::key( $site ), true ) ); } /** * Signing key for a site. * * @param Profile $site Site. * @return string */ private static function key( Profile $site ) { return hash_hmac( 'sha256', 'turtlereport-share|' . $site->id . '|' . (string) $site->delivery['link_key'], wp_salt( 'auth' ), true ); } /** * Checks a token. * * @param string $token Token. * @param int|null $now Unix time. * @return array|\WP_Error ['site' => Profile, 'run' => array] when valid. */ public function verify( $token, $now = null ) { $now = null === $now ? time() : (int) $now; $invalid = new \WP_Error( 'turtlereport_link_invalid', __( 'This report link isn\'t valid. Ask the sender for a new one.', 'turtlereport' ), array( 'status' => 404 ) ); $token = (string) $token; if ( strlen( $token ) > 600 || ! preg_match( '/^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/', $token, $m ) ) { return $invalid; } $data = json_decode( Base64Url::decode( $m[1] ), true ); if ( ! is_array( $data ) || ! isset( $data['s'], $data['r'], $data['x'] ) || ! is_string( $data['s'] ) ) { return $invalid; } $site = $this->sites->find( $data['s'] ); // Same answer for an unknown site, links switched off and a bad signature: nothing leaks. if ( ! $site || empty( $site->delivery['share_link'] ) || '' === (string) $site->delivery['link_key'] ) { return $invalid; } $signed = hash_equals( Base64Url::encode( hash_hmac( 'sha256', $m[1], self::key( $site ), true ) ), $m[2] ); if ( ! $signed ) { return $invalid; } if ( (int) $data['x'] < $now ) { return new \WP_Error( 'turtlereport_link_expired', __( 'This report link has expired. Ask the sender for a new one.', 'turtlereport' ), array( 'status' => 410 ) ); } $run = $this->runs->find( (int) $data['r'] ); if ( ! $run || $run['site_id'] !== $site->id || RunRepository::CANCELLED === $run['status'] ) { return $invalid; } return array( 'site' => $site, 'run' => $run, ); } /** * Serves the PDF for a valid link, on any front-end request. * * @return void */ public function maybe_serve() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Authorised by the signed token instead. if ( ! isset( $_GET[ self::QUERY_VAR ] ) || is_admin() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Intentionally public bearer link; its shape and signature are checked below. if ( ! is_string( $_GET[ self::QUERY_VAR ] ) ) { wp_die( esc_html__( 'This report link isn\'t valid. Ask the sender for a new one.', 'turtlereport' ), esc_html__( 'Report', 'turtlereport' ), array( 'response' => 404 ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by verify(). $token = wp_unslash( $_GET[ self::QUERY_VAR ] ); nocache_headers(); header( 'X-Robots-Tag: noindex, nofollow', true ); header( 'Referrer-Policy: no-referrer', true ); header( 'X-Content-Type-Options: nosniff', true ); $found = $this->verify( $token ); if ( is_wp_error( $found ) ) { $data = $found->get_error_data(); wp_die( esc_html( $found->get_error_message() ), esc_html__( 'Report', 'turtlereport' ), array( 'response' => isset( $data['status'] ) ? (int) $data['status'] : 404 ) ); } // Counted only for valid links, so random tokens can't fill the options table. $limit = 'tly_share_' . md5( $token ); $hits = (int) get_transient( $limit ); if ( $hits >= self::RATE ) { wp_die( esc_html__( 'This link was opened too many times in the last hour. Try again later.', 'turtlereport' ), esc_html__( 'Report', 'turtlereport' ), array( 'response' => 429 ) ); } \TurtleReport\Support\TransientStore::set( $limit, $hits + 1, HOUR_IN_SECONDS ); $builder = is_callable( $this->builder ) ? call_user_func( $this->builder ) : $this->builder; $pdf = $builder->pdf( $found['site'], $found['run'] ); if ( is_wp_error( $pdf ) ) { wp_die( esc_html__( 'The report couldn\'t be created right now. Try again later.', 'turtlereport' ), esc_html__( 'Report', 'turtlereport' ), array( 'response' => 503 ) ); } header( 'Content-Type: application/pdf' ); header( 'Content-Disposition: inline; filename="' . ReportBuilder::pdf_name( $found['site'], $found['run'] ) . '"' ); header( 'Content-Length: ' . strlen( $pdf ) ); header( 'Cache-Control: private, no-store, max-age=0' ); echo $pdf; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary PDF. exit; } }