WP_REST_Server::CREATABLE, 'callback' => [ $this, 'submit' ], // Logged-in only, and deliberately nothing finer. Who may submit is // "a customer with an account"; everything else about the submission // is validated inside, where the answer can be a useful message. 'permission_callback' => static fn(): bool => is_user_logged_in(), ] ); } /** * Allowlist for the face photo slot: ext => mime. * * @return array */ private static function photoMimes(): array { return array( 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', ); } /** * Allowlist for the media slots: ext => mime. * * @return array */ private static function mediaMimes(): array { // Superset: image + audio + video. The resulting attachment's MIME tells // us which media_type to store. return array( 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'ogg|oga' => 'audio/ogg', 'wav' => 'audio/wav', 'mp4|m4v' => 'video/mp4', 'webm' => 'video/webm', ); } /** * Which bucket a MIME type belongs to. * * @param string $mime Detected MIME. */ private static function mimeBucket( $mime ): string { $mime = (string) $mime; if ( 0 === strpos( $mime, 'image/' ) ) { return 'image'; } if ( 0 === strpos( $mime, 'audio/' ) ) { return 'audio'; } if ( 0 === strpos( $mime, 'video/' ) ) { return 'video'; } return 'none'; } /* * The two methods below read $_FILES directly. Neither sniff applies here: * * - Nonce: these run only as a REST callback. WordPress's cookie * authentication checks the X-WP-Nonce header before a callback is * reached, and permission_callback requires a logged-in user. * - Sanitising: the entries are handed unchanged to media_handle_upload(), * which sanitises the file name, checks the real bytes against the MIME * allowlist and moves the temp file. Rewriting `name` or `tmp_name` first * would break that, not harden it. Only sizes and error codes are read * here, and those are cast to int. */ // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized /** * Validate one uploaded slot and sideload it as an attachment. * * @param string $slot $_FILES key. * @param int $parent_id Post to attach to. * @param array $mimes ext => mime allowlist for this slot. * @param int $max_bytes Hard size cap. * * @return int|WP_Error Attachment id, or the reason it was refused. */ private static function handleUpload( $slot, $parent_id, $mimes, $max_bytes ) { if ( empty( $_FILES[ $slot ] ) || ! isset( $_FILES[ $slot ]['name'] ) || '' === $_FILES[ $slot ]['name'] ) { return new WP_Error( 'rh_tm_no_file', __( 'فایلی ارسال نشد.', 'rahkar-toolkit' ), array( 'status' => 400 ) ); } $upload_err = isset( $_FILES[ $slot ]['error'] ) ? (int) $_FILES[ $slot ]['error'] : UPLOAD_ERR_NO_FILE; if ( UPLOAD_ERR_OK !== $upload_err ) { return new WP_Error( 'rh_tm_upload_err', __( 'بارگذاری فایل ناموفق بود.', 'rahkar-toolkit' ), array( 'status' => 400 ) ); } $size = isset( $_FILES[ $slot ]['size'] ) ? (int) $_FILES[ $slot ]['size'] : 0; if ( $size < 1 ) { return new WP_Error( 'rh_tm_empty', __( 'فایل خالی است.', 'rahkar-toolkit' ), array( 'status' => 400 ) ); } if ( $size > $max_bytes ) { return new WP_Error( 'rh_tm_too_big', __( 'حجم فایل بیش از حد مجاز است.', 'rahkar-toolkit' ), array( 'status' => 413 ) ); } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; // `mimes` override → WP core runs wp_check_filetype_and_ext, which inspects // the REAL file bytes (finfo) and rejects mismatches. test_form=false // because this is a REST request, not the classic media-new form. $overrides = array( 'test_form' => false, 'mimes' => $mimes, ); $attachment_id = media_handle_upload( $slot, $parent_id, array(), $overrides ); if ( is_wp_error( $attachment_id ) ) { // Normalise core's message to our type-rejection copy when it's a // filetype problem so the user gets actionable Persian text. return new WP_Error( 'rh_tm_bad_type', __( 'نوع فایل پشتیبانی نمی‌شود.', 'rahkar-toolkit' ), array( 'status' => 415 ) ); } return (int) $attachment_id; } /** * Accept a submission. * * @param WP_REST_Request $request The submission. * * @internal Hooked callback. */ public function submit( WP_REST_Request $request ): WP_REST_Response|WP_Error { $user_id = get_current_user_id(); if ( $user_id < 1 ) { return new WP_Error( 'rh_tm_auth', __( 'برای ثبت نظر ابتدا وارد شوید.', 'rahkar-toolkit' ), array( 'status' => 401 ) ); } // ── Rate limit ──────────────────────────────────────────────────────── $cooldown_key = 'rh_tm_cd_' . $user_id; if ( false !== get_transient( $cooldown_key ) ) { return new WP_Error( 'rh_tm_cooldown', __( 'لطفاً کمی بعد دوباره تلاش کنید.', 'rahkar-toolkit' ), array( 'status' => 429 ) ); } // One pending submission per user at a time — keeps the moderation queue // (and storage) from being flooded by a single account. $pending = get_posts( array( 'post_type' => TestimonialType::POST_TYPE, 'post_status' => 'pending', 'author' => $user_id, 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, ) ); if ( ! empty( $pending ) ) { return new WP_Error( 'rh_tm_has_pending', __( 'یک نظر شما در حال بررسی است. پس از تأیید، می‌توانید نظر جدید بفرستید.', 'rahkar-toolkit' ), array( 'status' => 409 ) ); } // ── Text fields ───────────────────────────────────────────────────── $name = sanitize_text_field( (string) $request->get_param( 'name' ) ); if ( '' === $name ) { // Fall back to the user's known billing name if available. $name = trim( (string) get_user_meta( $user_id, 'billing_first_name', true ) . ' ' . (string) get_user_meta( $user_id, 'billing_last_name', true ) ); } $name = trim( $name ); if ( '' === $name || mb_strlen( $name ) > 60 ) { return new WP_Error( 'rh_tm_bad_name', __( 'نام خود را وارد کنید.', 'rahkar-toolkit' ), array( 'status' => 400 ) ); } $content = wp_strip_all_tags( (string) $request->get_param( 'content' ) ); $content = trim( $content ); $len = function_exists( 'mb_strlen' ) ? mb_strlen( $content ) : strlen( $content ); if ( $len < TestimonialType::TEXT_MIN ) { return new WP_Error( 'rh_tm_short', __( 'متن نظر را کامل‌تر بنویسید.', 'rahkar-toolkit' ), array( 'status' => 400 ) ); } if ( $len > TestimonialType::TEXT_MAX ) { $content = mb_substr( $content, 0, TestimonialType::TEXT_MAX ); } $config = FormConfig::get(); $instagram = $config['instagram_id'] ? sanitize_text_field( (string) $request->get_param( 'instagram' ) ) : ''; $via_instagram = $config['instagram_purchase'] && ! empty( $request->get_param( 'via_instagram' ) ); $has_photo = ! empty( $_FILES['photo'] ) && ! empty( $_FILES['photo']['name'] ); if ( FormConfig::PHOTO_REQUIRED === $config['photo'] && ! $has_photo ) { return new WP_Error( 'rh_tm_no_photo', __( 'لطفاً یک عکس صورت انتخاب کنید.', 'rahkar-toolkit' ), array( 'status' => 400 ) ); } $take_photo = $has_photo && FormConfig::PHOTO_OFF !== $config['photo']; // Refuse switched-off media before anything is written: the form never // offers it, so arriving here means a hand-made request. $has_media = ! empty( $_FILES['media'] ) && ! empty( array_filter( (array) ( $_FILES['media']['name'] ?? array() ) ) ); if ( $has_media && empty( $config['media'] ) ) { return new WP_Error( 'rh_tm_media_off', __( 'ارسال فایل رسانه غیرفعال است.', 'rahkar-toolkit' ), array( 'status' => 400 ) ); } // Product tag. `purchased` accepts only what this customer has a paid // order for; `all` any product a shopper can reach. $product_id = 0; $bought = array(); if ( FormConfig::PRODUCT_OFF !== $config['product_pick'] ) { $product_id = absint( $request->get_param( 'product' ) ); $bought = Purchases::productIds( $user_id ); if ( $product_id > 0 ) { $product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null; $valid = $product && 'publish' === $product->get_status() && 'hidden' !== $product->get_catalog_visibility() && ( FormConfig::PRODUCT_ALL === $config['product_pick'] || in_array( $product_id, $bought, true ) ); if ( ! $valid ) { return new WP_Error( 'rh_tm_bad_product', __( 'این محصول را نمی‌توان انتخاب کرد.', 'rahkar-toolkit' ), array( 'status' => 400 ) ); } } } // ── Create the pending post FIRST so uploads attach to it. ────────────── $post_id = wp_insert_post( array( 'post_type' => TestimonialType::POST_TYPE, 'post_title' => $name, 'post_content' => $content, 'post_status' => 'pending', // never auto-publish user content. 'post_author' => $user_id, ), true ); if ( is_wp_error( $post_id ) ) { return new WP_Error( 'rh_tm_insert', __( 'ثبت نظر ناموفق بود. دوباره تلاش کنید.', 'rahkar-toolkit' ), array( 'status' => 500 ) ); } // ── Face photo (when taken) ───────────────────────────────────────── $photo_id = 0; if ( $take_photo ) { $photo_id = self::handleUpload( 'photo', $post_id, self::photoMimes(), TestimonialType::PHOTO_MAX_BYTES ); if ( is_wp_error( $photo_id ) ) { // Roll back the empty post so we don't leave orphan pending rows. wp_delete_post( $post_id, true ); return $photo_id; } set_post_thumbnail( $post_id, $photo_id ); } // ── Optional media (one or more: images, and/or a voice/video) ────── // The form sends media[] so $_FILES['media'] arrives in PHP's "array" // shape (each key holds a list). We re-map one entry at a time onto a // single-file pseudo-slot so media_handle_upload (which expects one file) // can validate + sideload it. Any rejection rolls back EVERYTHING already // uploaded for this submission so we never leave half a post behind. $media_ids = array(); if ( ! empty( $_FILES['media'] ) && isset( $_FILES['media']['name'] ) ) { $names = (array) $_FILES['media']['name']; $count = min( count( $names ), self::MAX_MEDIA ); $rollback = static function ( array $uploaded ) use ( $post_id, $photo_id ): void { foreach ( $uploaded as $mid ) { wp_delete_attachment( $mid, true ); } if ( $photo_id ) { wp_delete_attachment( $photo_id, true ); } wp_delete_post( $post_id, true ); }; for ( $k = 0; $k < $count; $k++ ) { if ( empty( $_FILES['media']['name'][ $k ] ) ) { continue; } // Build a single-file slot from the k-th element of the array shape. $_FILES['rh_tm_media_one'] = array( 'name' => $_FILES['media']['name'][ $k ], 'type' => isset( $_FILES['media']['type'][ $k ] ) ? $_FILES['media']['type'][ $k ] : '', 'tmp_name' => isset( $_FILES['media']['tmp_name'][ $k ] ) ? $_FILES['media']['tmp_name'][ $k ] : '', 'error' => isset( $_FILES['media']['error'][ $k ] ) ? $_FILES['media']['error'][ $k ] : UPLOAD_ERR_NO_FILE, 'size' => isset( $_FILES['media']['size'][ $k ] ) ? $_FILES['media']['size'][ $k ] : 0, ); $one = self::handleUpload( 'rh_tm_media_one', $post_id, self::mediaMimes(), TestimonialType::VIDEO_MAX_BYTES // Ceiling; tightened per detected bucket below. ); unset( $_FILES['rh_tm_media_one'] ); if ( is_wp_error( $one ) ) { $rollback( $media_ids ); return $one; } $bucket = self::mimeBucket( (string) get_post_mime_type( $one ) ); $path = get_attached_file( $one ); $bytes = $path ? (int) filesize( $path ) : 0; $cap = ( 'image' === $bucket ) ? TestimonialType::PHOTO_MAX_BYTES : ( ( 'audio' === $bucket ) ? TestimonialType::AUDIO_MAX_BYTES : TestimonialType::VIDEO_MAX_BYTES ); // A kind the shop switched off is refused like an unknown one. if ( 'none' === $bucket || ! in_array( $bucket, $config['media'], true ) || $bytes > $cap ) { wp_delete_attachment( $one, true ); $rollback( $media_ids ); return new WP_Error( 'rh_tm_media_reject', __( 'فایل رسانه پشتیبانی نمی‌شود یا حجمش زیاد است.', 'rahkar-toolkit' ), array( 'status' => 415 ) ); } $media_ids[] = (int) $one; } } // ── Meta ──────────────────────────────────────────────────────────── if ( ! empty( $media_ids ) ) { update_post_meta( $post_id, '_rh_tm_media_ids', $media_ids ); } if ( '' !== $instagram ) { update_post_meta( $post_id, '_rh_tm_instagram', $instagram ); } if ( $via_instagram ) { update_post_meta( $post_id, '_rh_tm_via_instagram', 1 ); } if ( $product_id > 0 ) { update_post_meta( $post_id, '_rh_tm_product', $product_id ); // «خریدار سایت» comes from the order table, never from the form. if ( in_array( $product_id, $bought, true ) ) { update_post_meta( $post_id, '_rh_tm_verified', 1 ); } } /* * Stamp the submitter's verified phone for moderation context. Never * shown to anyone but a moderator — it is there so a suspicious * submission can be traced to an account that proved a number. * * Read through a filter rather than by calling whatever happens to be * defined: this used to reach for a function the theme supplied, which is * backwards — a plugin cannot depend on a theme being installed. The * otp_auth module answers this when it is on, and when nothing answers, * the meta is simply not written. */ $phone = (string) apply_filters( 'rahkar_wp_verified_phone', '', get_current_user_id() ); if ( $phone ) { update_post_meta( $post_id, '_rh_tm_submitter_phone', $phone ); } set_transient( $cooldown_key, 1, self::COOLDOWN ); return rest_ensure_response( array( 'ok' => true, 'message' => __( 'نظر شما ثبت شد. پس از تأیید نمایش داده می‌شود.', 'rahkar-toolkit' ), ) ); } // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized }