` * calls (via a new REST route) the moment a visitor picks a file WHILE * still editing a draft — before this feature, nothing was ever uploaded * until final submission (confirmed: no partial/staging upload mechanism * existed anywhere in this codebase). Staged files live under an isolated * `chargeforms-private/drafts/` uploads subfolder specifically so the cleanup cron * (Draft_Cleanup) can safely delete anything under it that belongs to an * expired draft without any risk of touching a real entry's attachment — * see Draft::detach_file() for how a file stops being "the draft's" the * moment it's promoted into a real submission. */ class Draft_File_Stager { /** * Validates via the same File_Upload_Validator a final-submission * upload is checked against (Save & Resume is not a weaker-security * path), then moves the file into the isolated drafts subfolder and * records it. Returns `['url','path','name']` on success, or a * WP_Error — this is a REST callback's job to translate into an HTTP * response, not this method's. */ public static function stage( array $field, array $file, $draft_id ) { $validation_error = File_Upload_Validator::validate( $field, $file ); if ( null !== $validation_error ) { return new \WP_Error( 'chargeforms_invalid_file', $validation_error ); } require_once ABSPATH . 'wp-admin/includes/file.php'; Protected_Uploads::ensure_protected_root(); add_filter( 'upload_dir', [ __CLASS__, 'filter_upload_dir' ] ); $moved = wp_handle_upload( $file, [ 'test_form' => false ] ); remove_filter( 'upload_dir', [ __CLASS__, 'filter_upload_dir' ] ); if ( isset( $moved['error'] ) ) { return new \WP_Error( 'chargeforms_upload_failed', __( 'There was a problem uploading your file. Please try again.', 'chargeforms' ) ); } $result = [ // Proxied through RestApi::protected_file() (chargeforms_manage_ // forms-gated) rather than the real, now web-server-denied URL — // see Protected_Uploads's doc comment for why this moved out of // the public uploads tree. 'url' => Protected_Uploads::mediated_url( $moved['file'] ), 'path' => $moved['file'], 'name' => sanitize_file_name( $file['name'] ?? '' ), ]; Draft::record_file( $draft_id, $field['id'], $result ); return $result; } /** Redirects wp_handle_upload() into uploads/chargeforms-private/drafts/Y/m/ instead of the standard year/month root — "drafts" keeps the bulk "delete everything under this prefix that belongs to an expired draft" cleanup safe without touching real entry attachments, same as before; chargeforms-private/ additionally puts it behind Protected_Uploads' web-server deny rule. */ public static function filter_upload_dir( array $dirs ) { return Protected_Uploads::filter_upload_dir( $dirs, 'drafts' ); } /** Best-effort unlink — a file that's already gone (manually removed, host-level cleanup) is not an error worth surfacing, this is always called from cleanup/replace paths that have no user waiting on the result. */ public static function delete_file( array $file_row ) { $path = $file_row['file_path'] ?? ''; if ( $path && file_exists( $path ) ) { wp_delete_file( $path ); } } }