file_id ); $is_url = $file && 'url' === ( $file['source_type'] ?? 'upload' ); if ( ! $file || ( ! $is_url && ! file_exists( $file['path'] ) ) || ( $is_url && empty( $file['url'] ) ) ) { self::die_error( __( 'The file could not be found.', 'acquisitionsuite' ), 404 ); } // ── Free download limit enforcement ──────────────────────────────── // Check per-lead, per-file download count against the admin-configured limit. // Uses the analytics table (event_type='download') which is already written // by track_download() — so no extra table is needed. $free_limit = absint( get_option( 'acquis_free_download_limit', 0 ) ); if ( $free_limit > 0 && $token_data->lead_id ) { global $wpdb; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- table name via %i; all values via %s/%d; per-request download-limit check on a custom table, not cacheable $dl_count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE event_type = %s AND lead_id = %d AND file_id = %d', $wpdb->prefix . 'acquis_analytics', 'download', absint( $token_data->lead_id ), absint( $token_data->file_id ) ) ); if ( $dl_count >= $free_limit ) { self::die_error( sprintf( /* translators: %d: download limit count */ _n( 'You have reached the download limit (%d download) for this file.', 'You have reached the download limit (%d downloads) for this file.', $free_limit, 'acquisitionsuite' ), $free_limit ), 403 ); } } // Mark token as used (single-use) ACQUIS_Database::mark_token_used( $token ); // ── Phase 3: record the download event ────────────────────────────── // This is what powers the "Downloads" KPI, the Downloads-over-time // chart, and the per-file download counts shown on Upload Files. $this->track_download( $token_data ); // ── Serve the file ──────────────────────────────────────────────── self::serve_file( $file ); } /** * Record a 'download' analytics event for a successfully validated, * single-use download token. Best-effort: attributes the event back to * the form + source page that originally captured the lead, when that * submission record can still be found. * * @param object $token_data Row from ACQUIS_Database::validate_token() */ private function track_download( $token_data ) { if ( ! class_exists( 'ACQUIS_P3_DB' ) ) { return; } $file_id = absint( $token_data->file_id ?? 0 ); $lead_id = absint( $token_data->lead_id ?? 0 ); $event_data = array( 'file_id' => $file_id, 'lead_id' => $lead_id, 'ip' => $this->get_client_ip(), ); // Try to attribute this download to the form + source page that // captured the lead, by looking up their most recent submission // for this same file. if ( $lead_id && $file_id ) { $sub = ACQUIS_P3_DB::get_submission_for_lead_and_file( $lead_id, $file_id ); if ( $sub ) { $event_data['form_id'] = absint( $sub->form_id ?? 0 ); $event_data['source_post'] = absint( $sub->source_post ?? 0 ); $event_data['source_url'] = $sub->source_url ?? ''; $event_data['utm_source'] = $sub->utm_source ?? ''; $event_data['utm_medium'] = $sub->utm_medium ?? ''; $event_data['utm_campaign']= $sub->utm_campaign ?? ''; } } ACQUIS_P3_DB::track_event( 'download', $event_data ); // ── Phase 4: lead scoring + automation trigger for downloads ─────── if ( $lead_id && class_exists( 'ACQUIS_Lead_Scorer' ) ) { ACQUIS_Lead_Scorer::add_event_points( $lead_id, 'download' ); } if ( $lead_id && class_exists( 'ACQUIS_Automation_Engine' ) ) { $automation_context = array_merge( $event_data, array( 'lead_score' => class_exists( 'ACQUIS_Lead_Scorer' ) ? ACQUIS_Lead_Scorer::current_total( $lead_id ) : 0, ) ); ACQUIS_Automation_Engine::run( 'download', $lead_id, $automation_context ); } } /** * Resolve the visitor's IP for analytics logging (best-effort, same * precedence used elsewhere in the plugin). */ private function get_client_ip() { // For analytics/logging only — NOT used for security gating or rate limiting. // Proxy headers are acceptable here as IP accuracy is best-effort for analytics. $keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR' ); foreach ( $keys as $key ) { if ( ! empty( $_SERVER[ $key ] ) ) { $ip = trim( explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) )[0] ); if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) return $ip; } } return '0.0.0.0'; } /** * Stream file with proper headers. * * @param array $file */ public static function serve_file( $file ) { if ( ob_get_level() ) { ob_end_clean(); } // URL-gated file (external host e.g. Google Drive/Dropbox): the // token/expiry/single-use/analytics machinery above is identical // for every file only the final "hand over the content" step // differs there's no local file to stream, so send the visitor's // browser to the external URL instead. The server never fetches // the URL itself (no SSRF surface). wp_safe_redirect() is NOT used // here on purpose: it only allows same-host (or explicitly // whitelisted) targets, which would silently reject every // external URL and redirect to the site's default location // instead. The URL is admin-configured (esc_url_raw()'d and // validated on save via wp_http_validate_url()), never derived // from the request, so it isn't the open-redirect case // wp_safe_redirect() guards against. if ( 'url' === ( $file['source_type'] ?? 'upload' ) ) { // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- admin-configured URL, not user input; see comment above wp_redirect( esc_url_raw( $file['url'] ) ); exit; } $file_path = $file['path']; // Path traversal protection: resolve symlinks and ensure the file // is inside the WordPress uploads directory. A manipulated file // path could otherwise serve any readable file on the server. $real_path = realpath( $file_path ); $uploads_dir = realpath( wp_upload_dir()['basedir'] ); if ( ! $real_path || ! $uploads_dir || strpos( $real_path, $uploads_dir ) !== 0 ) { status_header( 403 ); exit( 'Access denied.' ); } if ( ! file_exists( $real_path ) ) { status_header( 404 ); exit( 'File not found.' ); } $file_path = $real_path; $file_size = filesize( $file_path ); $mime = ! empty( $file['mime_type'] ) ? $file['mime_type'] : 'application/octet-stream'; $filename = sanitize_file_name( $file['name'] ); nocache_headers(); header( 'Content-Type: ' . $mime ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header( 'Content-Length: ' . $file_size ); header( 'X-Content-Type-Options: nosniff' ); header( 'Cache-Control: private, no-cache, no-store, must-revalidate' ); header( 'Pragma: no-cache' ); header( 'Expires: 0' ); // Stream in chunks to handle large files. WP_Filesystem::get_contents() // reads the whole file into memory in one go and has no chunked-read // equivalent, using it here would defeat the point of chunking and // could exhaust memory on large downloads, so raw fopen/fread/fclose // is intentional. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- see comment above; WP_Filesystem has no chunked-read API $chunk_size = 1024 * 1024; // 1 MB $handle = fopen( $file_path, 'rb' ); if ( $handle ) { while ( ! feof( $handle ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.Security.EscapeOutput.OutputNotEscaped -- raw binary file bytes streamed to the browser, not HTML; escaping would corrupt the file echo fread( $handle, $chunk_size ); flush(); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with fopen() above fclose( $handle ); } exit; } public static function die_error( $message, $status = 403 ) { status_header( $status ); wp_die( '
' . '

⚠ Download Error

' . '

' . esc_html( $message ) . '

' . '' . esc_html__( '← Go Back', 'acquisitionsuite' ) . '' . '
', esc_html__( 'Download Error', 'acquisitionsuite' ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $status is an internal HTTP status code (int), passed to wp_die()'s args, never rendered as HTML array( 'response' => $status, 'back_link' => false ) ); } }