$id, 'label' => '' !== $label ? $label : __( 'Client', 'finishcoat' ), 'code' => $code, 'created' => isset( $c['created'] ) ? max( 0, (int) $c['created'] ) : 0, 'revoked' => isset( $c['revoked'] ) ? max( 0, (int) $c['revoked'] ) : 0, ); if ( count( $out ) >= finishcoat_code_max() ) { break; } } return $out; } /** * A new code entry. * * @param array $codes Existing codes, for uniqueness. * @param string $label Label. * @return array */ function finishcoat_code_new( $codes, $label ) { $label = finishcoat_code_clean_label( $label ); return array( 'id' => finishcoat_random_hex( 12 ), 'label' => '' !== $label ? $label : __( 'Client', 'finishcoat' ), 'code' => finishcoat_code_generate( $codes ), 'created' => time(), 'revoked' => 0, ); } /** * The site's codes. The first call on a new site makes one, labeled Client. * * @return array */ function finishcoat_codes() { $raw = get_option( 'finishcoat_codes', false ); if ( false === $raw ) { $first = array( finishcoat_code_new( array(), __( 'Client', 'finishcoat' ) ) ); add_option( 'finishcoat_codes', $first, '', false ); return $first; } return finishcoat_sanitize_codes( $raw ); } /** * Save the site's codes. * * @param array $codes Codes. */ function finishcoat_save_codes( $codes ) { update_option( 'finishcoat_codes', finishcoat_sanitize_codes( $codes ), false ); } /** * The codes that open a project. Lite has one project; an add-on can give each project its own codes. * * @param string $slug Project slug. * @return array */ function finishcoat_project_codes( $slug ) { $codes = apply_filters( 'finishcoat_access_codes', finishcoat_codes(), $slug ); return is_array( $codes ) ? $codes : array(); } /* * ------------------------------------------------------------------------ * The check * ------------------------------------------------------------------------ */ /** * Transient key for this visitor's wrong codes (a hashed IP address, never the address itself). * * @return string */ function finishcoat_code_fail_key() { return 'finishcoat_cf_' . substr( hash_hmac( 'sha256', 'code|' . finishcoat_client_ip(), wp_salt( 'nonce' ) ), 0, 20 ); } /** * This visitor's wrong codes in the current hour: [ count, start ]. * * @return array{0: int, 1: int} */ function finishcoat_code_fails() { $v = get_transient( finishcoat_code_fail_key() ); if ( ! is_array( $v ) || ! isset( $v[0], $v[1] ) || (int) $v[1] + HOUR_IN_SECONDS <= time() ) { return array( 0, 0 ); } return array( (int) $v[0], (int) $v[1] ); } /** * Count one wrong code. The hour runs from the first wrong code. */ function finishcoat_code_fail_add() { list( $n, $start ) = finishcoat_code_fails(); $now = time(); $start = $n ? $start : $now; set_transient( finishcoat_code_fail_key(), array( $n + 1, $start ), max( 1, $start + HOUR_IN_SECONDS - $now ) ); } /** * What the last check found: '' (passed), 'required' (missing or wrong) or 'limited', plus the label of the code * that passed, for storing on what the request makes. * * @param array|null $set New state. * @return array{why: string, label: string} */ function finishcoat_code_state( $set = null ) { static $state = array( 'why' => '', 'label' => '', ); if ( is_array( $set ) ) { $state = array_merge( $state, $set ); } return $state; } /** * The code sent with this request, normalized, or ''. * * @return string */ function finishcoat_request_code() { // phpcs:disable WordPress.Security.NonceVerification -- the review code is the check here. $raw = ''; if ( isset( $_POST['code'] ) ) { $raw = sanitize_text_field( wp_unslash( $_POST['code'] ) ); } elseif ( isset( $_GET['code'] ) ) { $raw = sanitize_text_field( wp_unslash( $_GET['code'] ) ); } // phpcs:enable return finishcoat_code_normalize( $raw ); } /** * Whether this request may use the project: a verified studio request, or an unrevoked code of the project * (compared in constant time). A wrong code counts against the visitor's hourly limit. Past the limit every code * is refused until the hour is up. * * @param string $slug Project slug. * @param bool $studio Verified studio request (logged in, editor, valid studio nonce). * @return bool */ function finishcoat_check_code( $slug, $studio ) { finishcoat_code_state( array( 'why' => '', 'label' => '', ) ); if ( $studio ) { return (bool) apply_filters( 'finishcoat_access_check', true, $slug, true ); } list( $fails ) = finishcoat_code_fails(); if ( $fails >= finishcoat_code_fail_limit() ) { finishcoat_code_state( array( 'why' => 'limited' ) ); return false; } $given = finishcoat_request_code(); if ( '' === $given ) { finishcoat_code_state( array( 'why' => 'required' ) ); return false; } // Compare keyed hashes, so every comparison has the same length, and look at every code. $key = wp_salt( 'nonce' ); $mine = hash_hmac( 'sha256', $given, $key ); $label = ''; $hit = false; foreach ( finishcoat_project_codes( $slug ) as $c ) { if ( ! is_array( $c ) || ! isset( $c['code'] ) ) { continue; } $same = hash_equals( hash_hmac( 'sha256', (string) $c['code'], $key ), $mine ); if ( $same && empty( $c['revoked'] ) && ! $hit ) { $hit = true; $label = isset( $c['label'] ) ? (string) $c['label'] : ''; } } $hit = (bool) apply_filters( 'finishcoat_access_check', $hit, $slug, false ); if ( ! $hit ) { finishcoat_code_fail_add(); finishcoat_code_state( array( 'why' => 'required' ) ); return false; } finishcoat_code_state( array( 'label' => $label ) ); return true; } /** * Stop the request unless the check passes: 403 { ok: false, code_required: true } for a missing or wrong code, * 429 for too many wrong ones. Neither says which codes exist. * * @param string $slug Project slug. * @param bool $studio Verified studio request. */ function finishcoat_require_code( $slug, $studio ) { if ( finishcoat_check_code( $slug, $studio ) ) { return; } $st = finishcoat_code_state(); if ( 'limited' === $st['why'] ) { finishcoat_json( array( 'ok' => false, 'code_required' => true, 'limited' => true, 'message' => __( 'Too many tries. Try again in an hour.', 'finishcoat' ), ), 429 ); } finishcoat_json( array( 'ok' => false, 'code_required' => true, 'message' => __( 'Enter your review code.', 'finishcoat' ), ), 403 ); } /** * Label of the code this request passed with, or ''. * * @return string */ function finishcoat_code_label() { $st = finishcoat_code_state(); return (string) $st['label']; } /** * Store the code's label on a note or photo made with a code. * * @param int $id Post ID. */ function finishcoat_code_mark( $id ) { $label = finishcoat_code_label(); if ( '' !== $label && $id ) { update_post_meta( (int) $id, '_finishcoat_code_label', wp_slash( $label ) ); } } /** * The code label stored on a post, or ''. * * @param int $id Post ID. * @return string */ function finishcoat_code_label_of( $id ) { return (string) get_post_meta( (int) $id, '_finishcoat_code_label', true ); } /** * The studio verified on this request: logged in, editor, valid studio nonce. * * @return bool */ function finishcoat_studio_request() { if ( ! finishcoat_is_studio() ) { return false; } // phpcs:disable WordPress.Security.NonceVerification -- this reads the nonce to verify it. $n = ''; if ( isset( $_POST['nonce'] ) ) { $n = sanitize_text_field( wp_unslash( $_POST['nonce'] ) ); } elseif ( isset( $_GET['nonce'] ) ) { $n = sanitize_text_field( wp_unslash( $_GET['nonce'] ) ); } // phpcs:enable return '' !== $n && false !== wp_verify_nonce( $n, 'finishcoat_studio' ); } /* * ------------------------------------------------------------------------ * Settings: the Review codes box * ------------------------------------------------------------------------ */ /** * The review link for a code. * * @param string $code Stored code. * @return string */ function finishcoat_code_link( $code ) { return add_query_arg( 'finishcoat', rawurlencode( (string) $code ), home_url( '/' ) ); } /** * Hidden fields shared by every codes form. * * @param string $act Action. * @param string $id Code ID, or ''. */ function finishcoat_codes_hidden( $act, $id = '' ) { echo ''; if ( '' !== $id ) { echo ''; } wp_nonce_field( 'finishcoat_codes' ); } /** * The Review codes box on the Settings screen. Each row has its own small forms. */ function finishcoat_codes_section() { $codes = finishcoat_codes(); $post = admin_url( 'admin-post.php' ); $active = 0; foreach ( $codes as $c ) { if ( empty( $c['revoked'] ) ) { ++$active; } } echo '
' . esc_html__( 'Clients need a code to read and send notes. Send each person or team the review link for their code: opening it saves the code in their browser for 90 days.', 'finishcoat' ) . '
'; if ( ! $active ) { echo '' . esc_html__( 'No code works right now, so only logged-in editors can see notes. Add a code below.', 'finishcoat' ) . '
| ' . esc_html( $h ) . ' | '; } echo '||||
|---|---|---|---|---|
| '; echo ' | ' . esc_html( finishcoat_code_format( $c['code'] ) ) . ' | ';
echo '' . esc_html( $c['created'] ? wp_date( get_option( 'date_format' ), $c['created'] ) : '' ) . ' | '; echo '' . esc_html( $off /* translators: %s: date the code was revoked. */ ? sprintf( __( 'Revoked %s', 'finishcoat' ), wp_date( get_option( 'date_format' ), $c['revoked'] ) ) : __( 'Active', 'finishcoat' ) ) . ' | '; if ( ! $off ) { $link = finishcoat_code_link( $c['code'] ); echo ' '; echo ' '; echo ''; } else { echo ''; } echo ' |
' . esc_html__( 'The label shows next to the name on each note, so you know who sent what.', 'finishcoat' ) . '
'; } else { echo '' . esc_html( /* translators: %d: most codes allowed. */ sprintf( __( 'You have the most codes a site can hold (%d). Remove a revoked code to add another.', 'finishcoat' ), finishcoat_code_max() ) ) . '
'; } } /** * Add, rename, regenerate, revoke or remove a code (admin-post action finishcoat_codes). */ function finishcoat_handle_codes() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'finishcoat' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'finishcoat_codes' ); $do = isset( $_POST['do'] ) ? sanitize_key( wp_unslash( $_POST['do'] ) ) : ''; $id = isset( $_POST['id'] ) ? sanitize_key( wp_unslash( $_POST['id'] ) ) : ''; $label = finishcoat_code_clean_label( isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : '' ); $codes = finishcoat_codes(); $i = -1; foreach ( $codes as $k => $c ) { if ( '' !== $id && $c['id'] === $id ) { $i = (int) $k; } } if ( 'add' === $do ) { if ( count( $codes ) >= finishcoat_code_max() ) { finishcoat_add_notice( 'error', __( 'You have the most codes a site can hold.', 'finishcoat' ) ); } else { $c = finishcoat_code_new( $codes, $label ); $codes[] = $c; /* translators: %s: code label. */ finishcoat_add_notice( 'success', sprintf( __( 'Code added: %s. Copy its review link and send it to the client.', 'finishcoat' ), $c['label'] ) ); } } elseif ( $i < 0 ) { finishcoat_add_notice( 'error', __( 'That code is gone.', 'finishcoat' ) ); } elseif ( 'rename' === $do ) { if ( '' === $label ) { finishcoat_add_notice( 'error', __( 'Give the code a label.', 'finishcoat' ) ); } else { $codes[ $i ]['label'] = $label; /* translators: %s: code label. */ finishcoat_add_notice( 'success', sprintf( __( 'Code renamed: %s.', 'finishcoat' ), $label ) ); } } elseif ( 'regenerate' === $do && empty( $codes[ $i ]['revoked'] ) ) { $codes[ $i ]['code'] = finishcoat_code_generate( $codes ); $codes[ $i ]['created'] = time(); /* translators: %s: code label. */ finishcoat_add_notice( 'success', sprintf( __( 'New code made for %s. The old code stopped working. Send the new review link.', 'finishcoat' ), $codes[ $i ]['label'] ) ); } elseif ( 'revoke' === $do ) { if ( empty( $codes[ $i ]['revoked'] ) ) { $codes[ $i ]['revoked'] = time(); } /* translators: %s: code label. */ finishcoat_add_notice( 'success', sprintf( __( 'Code revoked: %s.', 'finishcoat' ), $codes[ $i ]['label'] ) ); } elseif ( 'remove' === $do && ! empty( $codes[ $i ]['revoked'] ) ) { array_splice( $codes, $i, 1 ); finishcoat_add_notice( 'success', __( 'Revoked code removed from the list.', 'finishcoat' ) ); } else { finishcoat_add_notice( 'error', __( 'Unknown action.', 'finishcoat' ) ); } finishcoat_save_codes( $codes ); wp_safe_redirect( finishcoat_settings_url() . '#finishcoat-codes' ); exit; } add_action( 'admin_post_finishcoat_codes', 'finishcoat_handle_codes' );