setting( 'save_progress', null ); if ( null !== $override ) { $enabled = (bool) $override; } } /** * Filters whether a form may save progress. * * @since 1.0.0 * * @param bool $enabled Whether saving is allowed. * @param Form|null $form Form being filled in. */ return (bool) apply_filters( 'formpilot_save_progress_enabled', $enabled, $form ); } /** * Returns how long saved progress survives. */ public function lifetime(): int { $days = Settings::number( 'progress_lifetime' ); $days = min( 90, max( 1, 0 === $days ? 7 : $days ) ); /** * Filters the lifetime of saved progress. * * @since 1.0.0 * * @param int $seconds Lifetime in seconds. */ return (int) apply_filters( 'formpilot_progress_lifetime', $days * DAY_IN_SECONDS ); } /** * Stores the values entered so far. * * Passing the token of an earlier save overwrites that record instead of * leaving a trail of abandoned ones behind. * * @param int $form_id Form being filled in. * @param int $step Step the visitor stopped on. * @param array $values Sanitised values keyed by field key. * @param string $token Optional. Token of an earlier save. Default empty. * * @return string|WP_Error The token to resume with, or the reason it was refused. */ public function save( int $form_id, int $step, array $values, string $token = '' ): string|WP_Error { if ( $form_id <= 0 ) { return new WP_Error( 'formpilot_unknown_form', __( 'This form is no longer available.', 'devplugins-custom-form-builder' ) ); } $record = array( 'form_id' => $form_id, 'step' => max( 0, $step ), 'values' => $values, 'saved_at' => time(), ); $payload = maybe_serialize( $record ); if ( is_string( $payload ) && strlen( $payload ) > self::MAX_BYTES ) { return new WP_Error( 'formpilot_progress_too_large', __( 'There is too much in this form to save it for later.', 'devplugins-custom-form-builder' ) ); } /* * An existing token is reused only after it is checked against this form, * so a token handed out for one form cannot be pointed at another. */ if ( '' !== $token ) { $existing = $this->load( $token ); if ( null === $existing || $form_id !== (int) $existing['form_id'] ) { $token = ''; } } $fresh = '' === $token; /* * Overwriting an existing draft is free; starting new ones is not. Without * this, anything that can reach the endpoint could fill the options table * one 64 KB row at a time. */ if ( $fresh && ! $this->may_create() ) { return new WP_Error( 'formpilot_progress_throttled', __( 'Too many forms have been saved from here recently. Please try again later.', 'devplugins-custom-form-builder' ) ); } if ( $fresh ) { $token = $this->new_token(); } $stored = set_transient( self::PREFIX . $token, $record, $this->lifetime() ); if ( false !== $stored && $fresh ) { $this->count_creation(); } if ( false === $stored ) { return new WP_Error( 'formpilot_progress_failed', __( 'Your progress could not be saved. Please try again.', 'devplugins-custom-form-builder' ) ); } /** * Fires after progress was saved. * * @since 1.0.0 * * @param string $token Token to resume with. * @param int $form_id Form being filled in. * @param array $record Stored record. */ do_action( 'formpilot_progress_saved', $token, $form_id, $record ); return $token; } /** * Reads back a saved record. * * @param string $token Token handed out when the progress was saved. * * @return array{form_id: int, step: int, values: array, saved_at: int}|null The record, or null. */ public function load( string $token ): ?array { if ( ! self::is_token( $token ) ) { return null; } $record = get_transient( self::PREFIX . $token ); if ( ! is_array( $record ) || ! isset( $record['form_id'] ) ) { return null; } return array( 'form_id' => (int) $record['form_id'], 'step' => (int) ( $record['step'] ?? 0 ), 'values' => is_array( $record['values'] ?? null ) ? $record['values'] : array(), 'saved_at' => (int) ( $record['saved_at'] ?? 0 ), ); } /** * Deletes a saved record. * * @param string $token Token handed out when the progress was saved. */ public function forget( string $token ): void { if ( ! self::is_token( $token ) ) { return; } delete_transient( self::PREFIX . $token ); } /** * Builds the link that resumes a saved form. * * @param string $token Token handed out when the progress was saved. * @param string $url Optional. Page holding the form. Defaults to the current one. */ public function resume_url( string $token, string $url = '' ): string { if ( ! self::is_token( $token ) ) { return ''; } if ( '' === $url ) { $url = (string) wp_get_referer(); } if ( '' === $url ) { $url = home_url( '/' ); } return add_query_arg( self::QUERY_ARG, $token, $url ); } /** * Determines whether a string has the shape of a token. * * Checked before the value reaches a transient name, so a crafted token can * neither read another option nor create one with an unexpected name. * * @param string $token Candidate token. * * @return bool True when the value is a well formed token. */ public static function is_token( string $token ): bool { return 1 === preg_match( '/^[A-Za-z0-9]{' . self::TOKEN_LENGTH . '}$/', $token ); } /** * Determines whether this address may start another draft. * * @return bool True when the address is within its allowance. */ private function may_create(): bool { $limit = $this->creation_limit(); if ( $limit <= 0 ) { return true; } return (int) get_transient( $this->throttle_key() ) < $limit; } /** * Counts a draft against this address. */ private function count_creation(): void { if ( $this->creation_limit() <= 0 ) { return; } $key = $this->throttle_key(); set_transient( $key, ( (int) get_transient( $key ) ) + 1, self::THROTTLE_WINDOW ); } /** * Returns how many drafts one address may start per hour. * * @return int The limit, or 0 to allow any number. */ private function creation_limit(): int { /** * Filters how many drafts one address may start per hour. * * @since 1.0.0 * * @param int $limit Number of drafts, or 0 for no limit. */ return (int) apply_filters( 'formpilot_progress_creation_limit', 20 ); } /** * Returns the transient name counting drafts for this address. * * The address is hashed rather than stored: the count is all this needs, and * an option name is not a place to keep one. */ private function throttle_key(): string { /* * Through the same filter the spam counters use. Keyed on `REMOTE_ADDR` * alone, a site behind a proxy shares one key, so the 20-drafts-an-hour limit * became 20 for the whole site — one of the three symptoms BUG-119 named, and * the one the first fix left behind. */ $ip = trim( (string) apply_filters( 'formpilot_client_ip', Request::ip() ) ); $ip = false === filter_var( $ip, FILTER_VALIDATE_IP ) ? Request::ip() : $ip; return 'formpilot_progress_rate_' . md5( $ip ); } /** * Returns a token that is not in use. */ private function new_token(): string { do { $token = wp_generate_password( self::TOKEN_LENGTH, false, false ); } while ( false !== get_transient( self::PREFIX . $token ) ); return $token; } }