'GET', 'callback' => [ $this, 'list_forms' ], 'permission_callback' => [ $this, 'can_manage' ], ], [ 'methods' => 'POST', 'callback' => [ $this, 'create_form' ], 'permission_callback' => [ $this, 'can_manage' ], ], ] ); // Separate from GET /forms (which the React builder already depends // on returning a plain array) — this returns Form::query()'s paginated // {items,total,pages,...} shape for the new Vue Forms-list screen. register_rest_route( self::NAMESPACE_V1, '/forms/query', [ 'methods' => 'GET', 'callback' => [ $this, 'query_forms' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'status' => [ 'type' => 'string', 'enum' => [ 'all', 'published', 'draft' ] ], 'sort' => [ 'type' => 'string', 'enum' => [ 'newest', 'oldest', 'title_asc', 'title_desc' ] ], 'search' => [ 'type' => 'string' ], 'category' => [ 'type' => 'string', 'enum' => [ '', 'multistep' ] ], 'date_from' => [ 'type' => 'string' ], 'date_to' => [ 'type' => 'string' ], 'page' => [ 'type' => 'integer', 'minimum' => 1 ], 'per_page' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => 100 ], ], ] ); register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/duplicate', [ 'methods' => 'POST', 'callback' => [ $this, 'duplicate_form' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ], ], ] ); register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/status', [ 'methods' => 'PATCH', 'callback' => [ $this, 'toggle_status' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ], 'status' => [ 'type' => 'string', 'required' => true, 'enum' => [ 'published', 'draft' ] ], ], ] ); register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)', [ [ 'methods' => 'GET', 'callback' => [ $this, 'get_form' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ] ], ], [ 'methods' => 'PUT', 'callback' => [ $this, 'update_form' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ] ], ], [ 'methods' => 'DELETE', 'callback' => [ $this, 'delete_form' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ] ], ], ] ); /* * Real bug found live: `ChargeFormsConfig.previewBaseUrl` (see * Menu::enqueue_builder_assets()) is localized ONCE, server-side, at * the builder page's initial load — for a brand-new form (`form_id= * new`) it's hardcoded to '' since no real id/nonce can exist yet. * That value never gets refreshed after a client-side save creates * the form, so "Preview & Design" stayed permanently broken (empty * href) for every new form's first save, for the rest of that * browser session. A signed preview URL needs a server-generated * nonce (wp_nonce_url()), so this can't be built purely client-side * — this tiny endpoint is what App.js's save() calls right after a * successful create, to get a real, working URL without a full page * reload. */ register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/preview-url', [ 'methods' => 'GET', 'callback' => [ $this, 'get_preview_url' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ] ], ] ); // Capability-gated proxy for files uploaded through a `file`/ // `image_upload` field — see Protected_Uploads's doc comment for // why this exists (previously such files sat in the public uploads // tree with no access control at all). ?path= is a relative path // under the uploads basedir; resolve_protected_path() confirms it // resolves inside chargeforms-private/ before this ever touches // the filesystem. register_rest_route( self::NAMESPACE_V1, '/protected-file', [ 'methods' => 'GET', 'callback' => [ $this, 'protected_file' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'path' => [ 'type' => 'string', 'required' => true ] ], ] ); register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/entries', [ 'methods' => 'GET', 'callback' => [ $this, 'list_entries' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ], 'page' => [ 'type' => 'integer', 'minimum' => 1 ], 'per_page' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => 200 ], ], ] ); // Public — any site visitor submitting a payment form needs this, // logged in or not. Rate-limited per IP+form inside the callback // itself since there's no capability check to lean on here. register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/create-payment-intent', [ 'methods' => 'POST', 'callback' => [ $this, 'create_payment_intent' ], 'permission_callback' => '__return_true', 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ], 'submitted_values' => [ 'type' => 'object' ], ], ] ); // Same shape/rate-limit as create-payment-intent above — PayPal's // Orders v2 flow just returns an order id instead of a client secret // (the buyer approves it on PayPal's own hosted UI, not an embedded // element), see Paypal_Gateway's class doc comment for why the two // gateways' REST shapes aren't identical. register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/create-paypal-order', [ 'methods' => 'POST', 'callback' => [ $this, 'create_paypal_order' ], 'permission_callback' => '__return_true', 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ], 'submitted_values' => [ 'type' => 'object' ], ], ] ); // Public, same rate-limit shape as create-payment-intent — starts a // recurring Stripe subscription for a Payment Method field configured // with billingType: 'recurring'. See Stripe_Gateway::create_subscription() // for why the flow still ends in the client confirming a // PaymentIntent/SetupIntent exactly like the one-time flow does. register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/create-subscription', [ 'methods' => 'POST', 'callback' => [ $this, 'create_subscription' ], 'permission_callback' => '__return_true', 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ], 'submitted_values' => [ 'type' => 'object' ], 'email' => [ 'type' => 'string' ], ], ] ); // Capability-checked (chargeforms_manage_forms) — an admin canceling a // subscriber's subscription from the Payments screen. Not exposed to // the public front end at all; a subscriber cancels via whatever // self-service mechanism the site owner builds around this (out of // scope here — see the feature's own "Remaining limitations"). register_rest_route( self::NAMESPACE_V1, '/subscriptions/(?P\d+)/cancel', [ 'methods' => 'POST', 'callback' => [ $this, 'cancel_subscription' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ], 'at_period_end' => [ 'type' => 'boolean', 'default' => false ], ], ] ); // Public — Stripe calls this directly, server-to-server, so there is // no WordPress session/nonce to check (see Stripe_Webhook_Handler's // own class doc comment for why that's the correct model here, not // an oversight). Authentication is the HMAC signature check inside // the handler itself, run against the RAW request body — every // caller of this route must be rejected before its payload is // trusted for anything if that check fails. register_rest_route( self::NAMESPACE_V1, '/webhooks/stripe', [ 'methods' => 'POST', 'callback' => [ $this, 'stripe_webhook' ], 'permission_callback' => '__return_true', ] ); // --- Save & Resume — all public (same trust model as the form's own // submission endpoint: anyone who can load/submit a published form // can save/resume a draft of it), each rate-limited individually // below since they have very different legitimate call frequencies // (autosave fires often; emailing a resume link should almost never // be needed more than once or twice). A CSRF nonce // (chargeforms_draft_) is required on every one of them, // same convention as the main submit action's chargeforms_submit_ // nonce — rate limiting alone blunts volume, the nonce is what // stops an arbitrary third-party page from silently creating/ // mutating drafts against a form a visitor never actually loaded. register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/draft', [ [ 'methods' => 'POST', 'callback' => [ $this, 'save_draft' ], 'permission_callback' => '__return_true', 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ] ], ], [ 'methods' => 'DELETE', 'callback' => [ $this, 'discard_draft' ], 'permission_callback' => '__return_true', 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ] ], ], ] ); // POST, not GET, deliberately — a resume token is a bearer secret; // keeping it out of the query string here means it never lands in // server/proxy access logs a second time (the ONE place it can't // be avoided is the emailed link itself, which necessarily is a // GET — see Shortcode's ?chargeforms_resume= handling). register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/draft/restore', [ 'methods' => 'POST', 'callback' => [ $this, 'restore_draft' ], 'permission_callback' => '__return_true', 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ] ], ] ); register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/draft/email', [ 'methods' => 'POST', 'callback' => [ $this, 'email_draft_link' ], 'permission_callback' => '__return_true', 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ] ], ] ); register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/draft/upload', [ 'methods' => 'POST', 'callback' => [ $this, 'stage_draft_file' ], 'permission_callback' => '__return_true', 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ] ], ] ); // Public — powers Reports' "view" and "start" counts. Both fire // from the shortcode's inline tracking snippet client-side (via // sendBeacon, never blocking the page) rather than a synchronous // server-side write during the form's own render — the same // event a heavily-embedded form generates on every single page // load shouldn't add a blocking DB write to that page's response. // A single parameterized route rather than one per event type, // since more event types are likely as Reports grows. register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/track-event', [ 'methods' => 'POST', 'callback' => [ $this, 'track_form_event' ], 'permission_callback' => '__return_true', 'args' => [ 'type' => [ 'required' => true, 'type' => 'string', 'enum' => [ 'view', 'start' ], ], ], ] ); // Public route (permission_callback: true) by design — an MCP // client's `initialize`/`tools/list` calls need a well-formed // JSON-RPC response even before authentication is established, // and a bare WP REST 401 isn't something an MCP client knows how // to parse. Real access control happens inside Mcp_Server::handle() // itself, which returns a proper JSON-RPC error object when the // resolved WP user (via Application Password Basic Auth, which WP // core resolves automatically for any REST request before this // callback ever runs) lacks chargeforms_manage_forms — see that // class's own doc comment. register_rest_route( self::NAMESPACE_V1, '/mcp', [ 'methods' => 'POST', 'callback' => [ $this, 'mcp' ], 'permission_callback' => '__return_true', ] ); // The front-end form's CSRF nonce (`_wpnonce`, minted at render // time) is only valid for ~12-24h. A page cached by a caching // plugin or CDN keeps serving that same nonce to every visitor // for as long as the cache entry lives, so once it expires every // submission is rejected by Submission_Handler::handle() with // "Security check failed" - silent breakage on exactly the cached // sites this plugin is expected to run on. The front-end bundle // calls this on load to swap the possibly stale nonce for a fresh // one; the response is explicitly non-cacheable. register_rest_route( self::NAMESPACE_V1, '/forms/(?P\d+)/submit-nonce', [ 'methods' => 'GET', 'callback' => [ $this, 'submit_nonce' ], 'permission_callback' => [ $this, 'submit_nonce_permission' ], 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ], ], ] ); } /** * Mints a fresh submission nonce for a form. Public by design: it * only issues the same CSRF token the page already prints for every * visitor, reveals nothing and grants nothing. See the route's own * comment above for why it exists (cache-plugin nonce staleness). */ public function submit_nonce( \WP_REST_Request $request ) { $form_id = (int) $request['id']; if ( ! Form::get( $form_id ) ) { return new \WP_Error( 'chargeforms_no_form', __( 'Form not found.', 'chargeforms' ), [ 'status' => 404 ] ); } $response = rest_ensure_response( [ 'nonce' => wp_create_nonce( 'chargeforms_submit_' . $form_id ), ] ); $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' ); return $response; } /** Only published forms are public; draft previews require the management capability. */ public function submit_nonce_permission( \WP_REST_Request $request ) { $form = Form::get( (int) $request['id'] ); return $form && ( 'published' === ( $form['status'] ?? '' ) || current_user_can( 'chargeforms_manage_forms' ) ); } public function mcp( \WP_REST_Request $request ) { $body = json_decode( $request->get_body(), true ); if ( ! is_array( $body ) ) { return rest_ensure_response( [ 'jsonrpc' => '2.0', 'id' => null, 'error' => [ 'code' => -32700, 'message' => __( 'Invalid JSON.', 'chargeforms' ) ] ] ); } $response = \ChargeForms\Includes\Mcp\Mcp_Server::handle( $body, get_current_user_id() ); // A JSON-RPC *notification* (no "id" in the request) has no // response body at all per spec — handle() signals that with null. return null === $response ? new \WP_REST_Response( null, 204 ) : rest_ensure_response( $response ); } public function track_form_event( \WP_REST_Request $request ) { $form_id = (int) $request->get_param( 'id' ); $type = $request->get_param( 'type' ); $form = Form::get( $form_id ); if ( ! $form ) { return new \WP_Error( 'chargeforms_not_found', __( 'Form not found.', 'chargeforms' ), [ 'status' => 404 ] ); } // Unlike every other public route here, this one had no rate limit // at all — anyone could hammer it to inflate/deflate a form's // Reports numbers (view/start counts) with no cost. Generous limit // (view/start fire on ordinary page loads, sometimes more than once // per real visitor) — this is an abuse guard, not a legitimate- // traffic throttle. $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; $key = 'chargeforms_track_rl_' . md5( $ip . '_' . $form_id ); if ( (int) get_transient( $key ) >= 60 ) { return new \WP_Error( 'chargeforms_rate_limited', __( 'Too many requests — please wait a minute and try again.', 'chargeforms' ), [ 'status' => 429 ] ); } set_transient( $key, (int) get_transient( $key ) + 1, MINUTE_IN_SECONDS ); if ( 'view' === $type ) { Form_Start::record_view( $form_id ); } else { Form_Start::record_start( $form_id ); } return rest_ensure_response( [ 'ok' => true ] ); } public function can_manage() { return current_user_can( 'chargeforms_manage_forms' ); } public function list_forms( \WP_REST_Request $request ) { return rest_ensure_response( Form::all() ); } /** * roadmap issue #25 ("Vue Forms List Is Partial and Has UX * Inconsistencies"): this route previously silently dropped * date_from/date_to/category — Form::query() has always supported * them (the PHP forms-list fallback page uses them), but the Vue * screen's REST calls had no way to actually filter by them even if * the UI grew controls for them, since this method never forwarded * the params through. */ /** * `settings_url`/`preview_url`/`export_url` are added here rather than * in Form::query() itself — Form is a data-layer class with no business * knowing about admin-post nonce URLs. The Vue forms-list screen * (assets/src/vue-admin/forms-list/App.vue) previously had no row menu * at all for these three actions, unlike the legacy PHP fallback table * (class-forms-list-page.php), because a client-rendered SPA can't * construct a per-form wp_nonce_url() itself — so the server has to * hand over the finished, nonce-protected URLs instead. */ public function query_forms( \WP_REST_Request $request ) { $result = Form::query( [ 'status' => $request->get_param( 'status' ) ?: 'all', 'search' => $request->get_param( 'search' ) ?: '', 'sort' => $request->get_param( 'sort' ) ?: 'newest', 'category' => $request->get_param( 'category' ) ?: '', 'date_from' => $request->get_param( 'date_from' ) ?: '', 'date_to' => $request->get_param( 'date_to' ) ?: '', 'page' => (int) ( $request->get_param( 'page' ) ?: 1 ), 'per_page' => (int) ( $request->get_param( 'per_page' ) ?: 10 ), ] ); foreach ( $result['items'] as &$form ) { $form['settings_url'] = admin_url( 'admin.php?page=chargeforms-form-settings&form_id=' . $form['id'] ); $form['preview_url'] = wp_nonce_url( admin_url( 'admin-post.php?action=chargeforms_preview_form&form_id=' . $form['id'] ), 'chargeforms_preview_' . $form['id'] ); $form['export_url'] = wp_nonce_url( admin_url( 'admin-post.php?action=chargeforms_export_single_form&form_id=' . $form['id'] ), 'chargeforms_export_single_' . $form['id'] ); // the reference product parity (AllForms.vue): a small $ indicator next to // the title for forms that collect payment, at a glance without // opening the builder. Reuses the same field-walk already built // for Commerce_Calculator's own payment-method lookup rather than // duplicating "does this field tree contain a payment field" logic. $form['has_payment'] = null !== Commerce_Calculator::find_payment_method_field( $form['fields'] ?? [] ); } unset( $form ); return rest_ensure_response( $result ); } public function duplicate_form( \WP_REST_Request $request ) { $form = Form::duplicate( (int) $request['id'] ); if ( ! $form ) { return new \WP_Error( 'chargeforms_not_found', __( 'Form not found.', 'chargeforms' ), [ 'status' => 404 ] ); } return rest_ensure_response( $form ); } public function toggle_status( \WP_REST_Request $request ) { $status = $request->get_param( 'status' ); if ( ! in_array( $status, [ 'published', 'draft' ], true ) ) { return new \WP_Error( 'chargeforms_invalid_status', __( 'Invalid status.', 'chargeforms' ), [ 'status' => 400 ] ); } $form = Form::update( (int) $request['id'], [ 'status' => $status ] ); return rest_ensure_response( $form ); } /** See the register_routes() doc comment on this route for why it exists. Mirrors Menu::enqueue_builder_assets()'s previewBaseUrl construction exactly — one place building this URL, not two that could drift. */ public function get_preview_url( \WP_REST_Request $request ) { $id = (int) $request['id']; if ( ! Form::get( $id ) ) { return new \WP_Error( 'chargeforms_not_found', __( 'Form not found.', 'chargeforms' ), [ 'status' => 404 ] ); } // wp_nonce_url() HTML-entity-encodes the query string's `&` (correct // for its usual "echo straight into an href attribute" use case) — // wrong here, since this URL goes out as a JSON string value and // gets set as a DOM element's href via JavaScript, which never runs // it through an HTML parser to decode those entities back. Left // un-decoded, the browser would request a URL with a literal // "amp;form_id=..." instead of "&form_id=...", breaking every param // after the first. html_entity_decode() undoes exactly that, then // esc_url_raw() re-validates/sanitizes the real URL before it goes out. $url = html_entity_decode( wp_nonce_url( admin_url( 'admin-post.php?action=chargeforms_preview_form&form_id=' . $id ), 'chargeforms_preview_' . $id ) ); return self::no_store( rest_ensure_response( [ 'preview_url' => esc_url_raw( $url ) ] ) ); } /** * Streams a file uploaded through a `file`/`image_upload` field — * `permission_callback => can_manage` above already refused anyone * without chargeforms_manage_forms before this method ever runs; * resolve_protected_path() below is the second, independent check that * ?path= can't escape chargeforms-private/ via a `..` traversal. Reads * and emits the file directly rather than a redirect, since a redirect * to the real (denied) URL would just 403 at the web-server level. */ public function protected_file( \WP_REST_Request $request ) { $path = Protected_Uploads::resolve_protected_path( (string) $request->get_param( 'path' ) ); if ( ! $path || ! is_file( $path ) ) { return new \WP_Error( 'chargeforms_file_not_found', __( 'File not found.', 'chargeforms' ), [ 'status' => 404 ] ); } $filetype = wp_check_filetype( $path ); $mime = $filetype['type'] ?: 'application/octet-stream'; nocache_headers(); header( 'Content-Type: ' . $mime ); header( 'Content-Length: ' . filesize( $path ) ); header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( basename( $path ) ) . '"' ); header( 'X-Content-Type-Options: nosniff' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming a validated, already-resolved-inside-the-protected-dir path; not user-controlled beyond that check. readfile( $path ); exit; } /** * Real bug found live (user report, 2026-08-25): a spurious "This form * was changed elsewhere" save-conflict error (see update_form()'s * expected_updated_at check) appearing even when nothing else actually * touched the form. Root cause: this response was never told NOT to be * cached — a page-cache plugin, CDN, or reverse proxy sitting in front * of a live site can and does cache GET requests to REST routes by * default unless a route explicitly opts out, which is exactly the * kind of layer that would serve a stale `updated_at` to the builder on * initial load. The very next save then correctly-per-its-own-logic * (but wrongly, since nothing really changed) rejects with a 409, * because "expected" (stale, cached) never matches "current" (fresh, * from the DB). This data is per-user, editable, and must never be * cached by anything — nocache_headers() is the standard WP core way * to tell every caching layer that, the same way WP core itself uses * it for admin-ajax and other logged-in-only dynamic responses. */ public function get_form( \WP_REST_Request $request ) { $form = Form::get( (int) $request['id'] ); if ( ! $form ) { return new \WP_Error( 'chargeforms_not_found', __( 'Form not found.', 'chargeforms' ), [ 'status' => 404 ] ); } return self::no_store( rest_ensure_response( $form ) ); } /** * Sets Cache-Control on the actual WP_REST_Response object rather than * calling nocache_headers()/header() directly — verified live that the * raw-header approach doesn't survive: WP_REST_Server::serve_request() * controls its own header-sending timeline via send_headers( $response ), * which runs after a callback returns and doesn't preserve headers a * callback set with bare header() calls. Setting them on the response * object is the only way that's guaranteed to actually reach the client * through that pipeline. */ private static function no_store( $response ) { if ( $response instanceof \WP_REST_Response ) { $response->header( 'Cache-Control', 'no-cache, no-store, must-revalidate, max-age=0' ); $response->header( 'Pragma', 'no-cache' ); } return $response; } public function create_form( \WP_REST_Request $request ) { $payload = $request->get_json_params() ?: []; $gate = Field_Type_Gate::reject_locked_fields( $payload['fields'] ?? [], [] ); if ( is_wp_error( $gate ) ) { return $gate; } $form = Form::create( $payload ); if ( is_wp_error( $form ) ) { return $form; } return rest_ensure_response( $form ); } /** * Optional `expected_updated_at` precondition (roadmap issue #16: * "Builder Save Payload Rewrites Entire Form" — no conflict detection * between two tabs/users editing the same form). The builder tracks * the `updated_at` it last saw (from load or its own last successful * save) and sends it back on every save; if the row's CURRENT * `updated_at` has since moved on — someone else saved in between — * this rejects the save with a 409 rather than silently overwriting * whatever they just did. Omitting the field (any caller that isn't * the builder's own save flow) skips the check entirely, so this is * purely additive for existing integrations. */ public function update_form( \WP_REST_Request $request ) { $id = (int) $request['id']; $payload = $request->get_json_params() ?: []; $expected_updated_at = $payload['expected_updated_at'] ?? null; unset( $payload['expected_updated_at'] ); if ( null !== $expected_updated_at ) { $current = Form::get( $id ); if ( $current && $current['updated_at'] !== $expected_updated_at ) { return new \WP_Error( 'chargeforms_save_conflict', __( 'This form was changed elsewhere since you loaded it. Reload to see the latest version before saving again.', 'chargeforms' ), [ 'status' => 409 ] ); } } if ( isset( $payload['fields'] ) ) { $existing = Form::get( $id ); $gate = Field_Type_Gate::reject_locked_fields( $payload['fields'], $existing['fields'] ?? [] ); if ( is_wp_error( $gate ) ) { return $gate; } } $form = Form::update( $id, $payload ); if ( is_wp_error( $form ) ) { return $form; } return self::no_store( rest_ensure_response( $form ) ); } public function delete_form( \WP_REST_Request $request ) { Form::delete( (int) $request['id'] ); return rest_ensure_response( [ 'deleted' => true ] ); } /** * Paginated (roadmap issue #18 — this used to return every entry for * the form in one response, which doesn't scale on a large form). * Defaults match Entry::query_for_form()'s own (page 1, 50 per page, * capped at 200) — no request param is required for existing callers * to keep working, they just now get page 1 instead of everything. */ public function list_entries( \WP_REST_Request $request ) { return rest_ensure_response( Entry::query_for_form( (int) $request['id'], [ 'page' => $request->get_param( 'page' ), 'per_page' => $request->get_param( 'per_page' ), ] ) ); } public function create_payment_intent( \WP_REST_Request $request ) { $form_id = (int) $request['id']; $form = Form::get( $form_id ); if ( ! $form || 'published' !== $form['status'] ) { return new \WP_Error( 'chargeforms_not_found', __( 'Form not found.', 'chargeforms' ), [ 'status' => 404 ] ); } $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; $key = 'chargeforms_pi_rl_' . md5( $ip . '_' . $form_id ); if ( (int) get_transient( $key ) >= 10 ) { return new \WP_Error( 'chargeforms_rate_limited', __( 'Too many attempts — please wait a minute and try again.', 'chargeforms' ), [ 'status' => 429 ] ); } set_transient( $key, (int) get_transient( $key ) + 1, MINUTE_IN_SECONDS ); // Every commerce field's submitted value (quantity, chosen shipping // option, custom amount) — see stripe-payment.js's // collectSubmittedValues() doc comment for why this must cover more // than just custom_amount fields now that calculate_total() prices // the whole commerce field set, not just legacy payment_item/ // custom_amount fields. $submitted_values = (array) ( $request->get_json_params()['submitted_values'] ?? [] ); $total = Stripe_Gateway::calculate_total( $form, $submitted_values ); if ( $total <= 0 ) { return new \WP_Error( 'chargeforms_invalid_amount', __( 'Nothing to charge.', 'chargeforms' ), [ 'status' => 400 ] ); } $intent = Stripe_Gateway::create_intent( $total, Settings::get( 'stripe_currency', 'usd' ), [ 'chargeforms_form_id' => $form_id, ] ); if ( is_wp_error( $intent ) ) { return new \WP_Error( 'chargeforms_stripe_error', $intent->get_error_message(), [ 'status' => 502 ] ); } return rest_ensure_response( [ 'client_secret' => $intent['client_secret'], 'amount' => $total, ] ); } public function create_paypal_order( \WP_REST_Request $request ) { $form_id = (int) $request['id']; $form = Form::get( $form_id ); if ( ! $form || 'published' !== $form['status'] ) { return new \WP_Error( 'chargeforms_not_found', __( 'Form not found.', 'chargeforms' ), [ 'status' => 404 ] ); } $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; $key = 'chargeforms_pp_rl_' . md5( $ip . '_' . $form_id ); if ( (int) get_transient( $key ) >= 10 ) { return new \WP_Error( 'chargeforms_rate_limited', __( 'Too many attempts — please wait a minute and try again.', 'chargeforms' ), [ 'status' => 429 ] ); } set_transient( $key, (int) get_transient( $key ) + 1, MINUTE_IN_SECONDS ); $submitted_values = (array) ( $request->get_json_params()['submitted_values'] ?? [] ); $total = Paypal_Gateway::calculate_total( $form, $submitted_values ); if ( $total <= 0 ) { return new \WP_Error( 'chargeforms_invalid_amount', __( 'Nothing to charge.', 'chargeforms' ), [ 'status' => 400 ] ); } $order = Paypal_Gateway::create_order( $total, Settings::get( 'paypal_currency', 'usd' ), [ 'chargeforms_form_id' => $form_id, ] ); if ( is_wp_error( $order ) ) { return new \WP_Error( 'chargeforms_paypal_error', $order->get_error_message(), [ 'status' => 502 ] ); } return rest_ensure_response( [ 'order_id' => $order['id'], 'amount' => $total, ] ); } /** * Starts a recurring Stripe subscription for a form's Payment Method * field configured with `billingType: 'recurring'`. Structurally * mirrors create_payment_intent() above (same not-found/rate-limit/ * amount-recomputation shape) but ends in a subscription rather than a * single PaymentIntent — the client still confirms a client_secret * exactly like the one-time flow, it's just either a PaymentIntent's * (real amount due now) or a SetupIntent's (a free-trial subscription: * nothing is due today, but a payment method must still be collected * and saved for when the trial ends) — `intent_type` tells the client * which `stripe.confirm*()` call to make (assets/js/stripe-payment.js). */ public function create_subscription( \WP_REST_Request $request ) { $form_id = (int) $request['id']; $form = Form::get( $form_id ); if ( ! $form || 'published' !== $form['status'] ) { return new \WP_Error( 'chargeforms_not_found', __( 'Form not found.', 'chargeforms' ), [ 'status' => 404 ] ); } $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; $key = 'chargeforms_sub_rl_' . md5( $ip . '_' . $form_id ); if ( (int) get_transient( $key ) >= 10 ) { return new \WP_Error( 'chargeforms_rate_limited', __( 'Too many attempts — please wait a minute and try again.', 'chargeforms' ), [ 'status' => 429 ] ); } set_transient( $key, (int) get_transient( $key ) + 1, MINUTE_IN_SECONDS ); $field = Commerce_Calculator::find_payment_method_field( $form['fields'] ?? [] ); if ( ! $field || 'recurring' !== ( $field['billingType'] ?? 'one_time' ) ) { return new \WP_Error( 'chargeforms_not_recurring', __( 'This form does not have a recurring payment field.', 'chargeforms' ), [ 'status' => 400 ] ); } // Recurring billing is currently Stripe-only (Gateway_Manager:: // supports_subscriptions()) — a form saved with a non-Stripe gateway // AND billingType: recurring (shouldn't happen via the builder's own // UI, which hides the option, but the form's stored JSON is not a // trusted input) is rejected rather than silently falling back. $gateway = $field['gateway'] ?? 'stripe'; if ( ! Gateway_Manager::supports_subscriptions( $gateway ) ) { return new \WP_Error( 'chargeforms_gateway_no_subscriptions', __( 'The configured payment gateway does not support recurring billing.', 'chargeforms' ), [ 'status' => 400 ] ); } $submitted_values = (array) ( $request->get_json_params()['submitted_values'] ?? [] ); $total = Commerce_Calculator::calculate_total( $form, $submitted_values ); if ( $total <= 0 ) { return new \WP_Error( 'chargeforms_invalid_amount', __( 'Nothing to charge.', 'chargeforms' ), [ 'status' => 400 ] ); } $interval = in_array( $field['billingInterval'] ?? '', [ 'month', 'year' ], true ) ? $field['billingInterval'] : 'month'; $trial_days = max( 0, (int) ( $field['trialDays'] ?? 0 ) ); $email = sanitize_email( (string) ( $request->get_json_params()['email'] ?? '' ) ); /** @var Subscription_Gateway_Interface|string $gateway_class */ $gateway_class = Gateway_Manager::get( $gateway ); $customer_id = $gateway_class::get_or_create_customer( $email ?: null, [ 'chargeforms_form_id' => $form_id ] ); if ( is_wp_error( $customer_id ) ) { return new \WP_Error( 'chargeforms_stripe_error', $customer_id->get_error_message(), [ 'status' => 502 ] ); } $subscription = $gateway_class::create_subscription( $customer_id, $total, Settings::get( 'stripe_currency', 'usd' ), $interval, $trial_days, [ 'chargeforms_form_id' => $form_id, 'chargeforms_field_id' => $field['id'], ] ); if ( is_wp_error( $subscription ) ) { return new \WP_Error( 'chargeforms_stripe_error', $subscription->get_error_message(), [ 'status' => 502 ] ); } // A trial subscription's first invoice is $0 — Stripe returns a // pending_setup_intent (collect + save a payment method with // nothing charged today) instead of a payable invoice. Check for // that first; every non-trial subscription falls through to the // normal payment_intent path. if ( ! empty( $subscription['pending_setup_intent']['client_secret'] ) ) { $client_secret = $subscription['pending_setup_intent']['client_secret']; $intent_type = 'setup'; } else { $client_secret = $subscription['latest_invoice']['payment_intent']['client_secret'] ?? null; $intent_type = 'payment'; } if ( ! $client_secret ) { return new \WP_Error( 'chargeforms_stripe_error', __( 'Stripe did not return a way to confirm this subscription.', 'chargeforms' ), [ 'status' => 502 ] ); } return rest_ensure_response( [ 'subscription_id' => $subscription['id'], 'client_secret' => $client_secret, 'intent_type' => $intent_type, 'amount' => $total, 'interval' => $interval, 'trial_days' => $trial_days, ] ); } /** * Admin-only (chargeforms_manage_forms via can_manage()) cancellation. * `$at_period_end` lets the site owner choose "stop billing but let * them keep access through what they already paid for" vs. immediate. * Cancels at the gateway FIRST, then only updates the local row if * that succeeded — a local-only cancellation that Stripe never heard * about would keep billing the subscriber while this site believes * it's canceled, which is worse than the reverse (an admin retrying * after a transient failure). */ public function cancel_subscription( \WP_REST_Request $request ) { $id = (int) $request['id']; $at_period_end = (bool) $request->get_param( 'at_period_end' ); $row = Subscription::get( $id ); if ( ! $row ) { return new \WP_Error( 'chargeforms_not_found', __( 'Subscription not found.', 'chargeforms' ), [ 'status' => 404 ] ); } if ( ! in_array( $row['status'], Subscription::CANCELABLE_STATUSES, true ) ) { return new \WP_Error( 'chargeforms_already_ended', __( 'This subscription has already ended.', 'chargeforms' ), [ 'status' => 400 ] ); } $gateway_class = Gateway_Manager::get( $row['gateway'] ); if ( ! Gateway_Manager::supports_subscriptions( $row['gateway'] ) ) { return new \WP_Error( 'chargeforms_gateway_no_subscriptions', __( 'This gateway does not support subscription cancellation.', 'chargeforms' ), [ 'status' => 400 ] ); } $result = $gateway_class::cancel_subscription( $row['gateway_subscription_id'], $at_period_end ); if ( is_wp_error( $result ) ) { return new \WP_Error( 'chargeforms_stripe_error', $result->get_error_message(), [ 'status' => 502 ] ); } $new_status = $at_period_end ? $row['status'] : 'canceled'; Subscription::update( $id, [ 'status' => $new_status, 'cancel_at_period_end' => $at_period_end ? 1 : 0, ] ); Entry::log_activity( $row['entry_id'], $at_period_end ? __( 'Subscription set to cancel at the end of the current period.', 'chargeforms' ) : __( 'Subscription canceled immediately by an admin.', 'chargeforms' ) ); Activity_Log::log( 'subscription_canceled', 'subscription', $id, __( 'Subscription canceled from the Payments screen.', 'chargeforms' ) ); return rest_ensure_response( [ 'canceled' => true, 'at_period_end' => $at_period_end ] ); } /** * The raw request body (not get_json_params(), which would already * have decoded — and thereby normalized whitespace/key order of — the * payload) is what Stripe's signature was actually computed over, so * the exact bytes must be what gets HMAC'd here too. See * Stripe_Webhook_Handler's own class doc comment for why this route * has no nonce and no capability check — HMAC signature verification * IS this endpoint's authentication. */ public function stripe_webhook( \WP_REST_Request $request ) { $result = Stripe_Webhook_Handler::handle( $request->get_body(), $request->get_header( 'stripe-signature' ) ); if ( is_wp_error( $result ) ) { return $result; } return rest_ensure_response( [ 'received' => true ] ); } /** Every draft route shares this same nonce + rate-limit + form-lookup preamble — factored out once rather than five near-identical copies. Returns the loaded, published $form array on success, or a WP_Error the caller returns as-is. */ private function draft_route_guard( \WP_REST_Request $request, $rl_prefix, $rl_limit, $rl_window ) { // Save & Resume is opt-in. Keep the route unavailable until the site // owner explicitly enables it in settings. if ( ! Settings::get( 'save_resume_enabled', false ) ) { return new \WP_Error( 'chargeforms_save_resume_disabled', __( 'Save & Resume is not enabled for this site.', 'chargeforms' ), [ 'status' => 404 ] ); } $form_id = (int) $request['id']; $form = Form::get( $form_id ); if ( ! $form || 'published' !== $form['status'] ) { return new \WP_Error( 'chargeforms_not_found', __( 'Form not found.', 'chargeforms' ), [ 'status' => 404 ] ); } $nonce = (string) ( $request->get_param( 'nonce' ) ?: '' ); if ( ! wp_verify_nonce( $nonce, 'chargeforms_draft_' . $form_id ) ) { return new \WP_Error( 'chargeforms_invalid_nonce', __( 'Security check failed. Please reload the page and try again.', 'chargeforms' ), [ 'status' => 403 ] ); } $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; $key = $rl_prefix . md5( $ip . '_' . $form_id ); if ( (int) get_transient( $key ) >= $rl_limit ) { return new \WP_Error( 'chargeforms_rate_limited', __( 'Too many attempts — please wait a moment and try again.', 'chargeforms' ), [ 'status' => 429 ] ); } set_transient( $key, (int) get_transient( $key ) + 1, $rl_window ); return $form; } /** * Generous rate limit (30/min) relative to the main submit endpoint's * 5/min — autosave legitimately fires every ~20s per active tab, and * this is the endpoint that call lands on. */ public function save_draft( \WP_REST_Request $request ) { $form = $this->draft_route_guard( $request, 'chargeforms_draft_rl_', 30, MINUTE_IN_SECONDS ); if ( is_wp_error( $form ) ) { return $form; } $body = $request->get_json_params() ?: []; $token = sanitize_text_field( (string) ( $body['token'] ?? '' ) ); $raw_values = is_array( $body['values'] ?? null ) ? $body['values'] : []; $current_step = (int) ( $body['current_step'] ?? 0 ); $manual = ! empty( $body['manual'] ); $expected_updated_at = isset( $body['expected_updated_at'] ) ? sanitize_text_field( $body['expected_updated_at'] ) : null; $result = Draft_Service::save( $form, $raw_values, $current_step, $token, $manual, $expected_updated_at ); if ( is_wp_error( $result ) ) { return $result; } return rest_ensure_response( $result ); } /** get_json_params() returns null (not []) for an empty/absent body — DELETE and some POST clients never send one, so every route reading a token from the body guards against that here rather than each repeating the same null-coalesce dance. */ private function request_token( \WP_REST_Request $request ) { $body = $request->get_json_params() ?: []; return sanitize_text_field( (string) ( $body['token'] ?? $request->get_param( 'token' ) ?? '' ) ); } public function discard_draft( \WP_REST_Request $request ) { $form = $this->draft_route_guard( $request, 'chargeforms_draft_rl_', 30, MINUTE_IN_SECONDS ); if ( is_wp_error( $form ) ) { return $form; } Draft_Service::discard( $this->request_token( $request ) ); return rest_ensure_response( [ 'discarded' => true ] ); } /** Slightly stricter than save (20/min) — this is the endpoint an attacker would actually use to brute-force-guess a token, on top of the token's own 256-bit keyspace making that already infeasible; rate limiting here is defense in depth, not the primary control. */ public function restore_draft( \WP_REST_Request $request ) { $form = $this->draft_route_guard( $request, 'chargeforms_draft_restore_rl_', 20, MINUTE_IN_SECONDS ); if ( is_wp_error( $form ) ) { return $form; } $result = Draft_Service::restore( $form, $this->request_token( $request ) ); if ( ! $result ) { return new \WP_Error( 'chargeforms_draft_not_found', __( 'This draft could not be found — it may have expired.', 'chargeforms' ), [ 'status' => 404 ] ); } return rest_ensure_response( $result ); } /** * Much stricter (3/hour per IP+form) — this is the one draft route * that sends real email to a real address, the actual abuse surface * (spamming someone else's inbox with resume links) the other four * routes don't have. On top of that per-IP throttle, the check below * also enforces a per-DRAFT minimum interval using `last_email_sent_at` * (a plain record column, not a policy of its own) — without it, a * single draft could still be re-emailed rapidly from many different * IPs, which the per-IP limit alone wouldn't catch. */ public function email_draft_link( \WP_REST_Request $request ) { $form = $this->draft_route_guard( $request, 'chargeforms_draft_email_rl_', 3, HOUR_IN_SECONDS ); if ( is_wp_error( $form ) ) { return $form; } $body = $request->get_json_params() ?: []; $token = sanitize_text_field( (string) ( $body['token'] ?? '' ) ); $email = sanitize_email( (string) ( $body['email'] ?? '' ) ); $existing = Draft::get_by_token( $token ); if ( $existing && ! empty( $existing['last_email_sent_at'] ) && strtotime( $existing['last_email_sent_at'] . ' UTC' ) > time() - MINUTE_IN_SECONDS ) { return new \WP_Error( 'chargeforms_rate_limited', __( 'A resume link was already sent — please wait a moment before requesting another.', 'chargeforms' ), [ 'status' => 429 ] ); } $result = Draft_Service::email_resume_link( $form, $token, $email ); if ( is_wp_error( $result ) ) { return $result; } return rest_ensure_response( [ 'sent' => true ] ); } /** * The immediate-upload endpoint a file/image_upload field's * calls on change while a draft is being edited — see * Draft_File_Stager's class doc comment for why this exists at all * (nothing was ever uploaded before final submit until this feature). * Same 20/min shape as restore — file uploads aren't autosave-frequent, * but a page with several upload fields could reasonably fire a few * in quick succession. */ public function stage_draft_file( \WP_REST_Request $request ) { $form = $this->draft_route_guard( $request, 'chargeforms_draft_upload_rl_', 20, MINUTE_IN_SECONDS ); if ( is_wp_error( $form ) ) { return $form; } $token = sanitize_text_field( (string) ( $request->get_param( 'token' ) ?? '' ) ); $field_id = sanitize_text_field( (string) ( $request->get_param( 'field_id' ) ?? '' ) ); $draft = Draft::get_by_token( $token ); if ( ! $draft || (int) $draft['form_id'] !== (int) $form['id'] ) { return new \WP_Error( 'chargeforms_draft_not_found', __( 'This draft could not be found — it may have expired.', 'chargeforms' ), [ 'status' => 404 ] ); } $field = Field_Sanitizer::find_field_by_id( $form['fields'], $field_id ); if ( ! $field || ! in_array( $field['type'], Draft_Service::FILE_FIELD_TYPES, true ) ) { return new \WP_Error( 'chargeforms_invalid_field', __( 'Invalid field.', 'chargeforms' ), [ 'status' => 400 ] ); } // The nonce check already happened in draft_route_guard() above, and // tmp_name is PHP's own upload-tmp-dir path, not attacker-supplied // content — the sniff can't see either fact across the helper-method // boundary. See Submission_Handler::handle_file_upload()'s identical // comment on name'/'type' being sanitized for hygiene only, since the // real type is re-derived from the file itself further down the // upload pipeline. if ( empty( $_FILES['file']['name'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing return new \WP_Error( 'chargeforms_no_file', __( 'No file was uploaded.', 'chargeforms' ), [ 'status' => 400 ] ); } $file = [ 'name' => sanitize_file_name( wp_unslash( $_FILES['file']['name'] ?? '' ) ), // phpcs:ignore WordPress.Security.NonceVerification.Missing 'type' => sanitize_mime_type( wp_unslash( $_FILES['file']['type'] ?? '' ) ), // phpcs:ignore WordPress.Security.NonceVerification.Missing 'tmp_name' => $_FILES['file']['tmp_name'] ?? '', // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 'error' => (int) ( $_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE ), // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- (int)-cast immediately. 'size' => (int) ( $_FILES['file']['size'] ?? 0 ), // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- (int)-cast immediately. ]; $staged = Draft_File_Stager::stage( $field, $file, $draft['id'] ); if ( is_wp_error( $staged ) ) { return $staged; } Draft_Service::record_staged_file( $draft, $field_id, $staged['url'] ); return rest_ensure_response( [ 'url' => $staged['url'], 'name' => $staged['name'] ] ); } }