getTimestamp(); wp_schedule_event($next, 'daily', 'aksrt_dl_token_cleanup'); } } public function cleanup_expired_tokens(): void { global $wpdb; if (!class_exists('\AKSRT\Core\DBMigrator')) return; $table = \AKSRT\Core\DBMigrator::table('dl_token'); if (!$table) return; $now_utc = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'); $audit_cutoff = (new \DateTimeImmutable('-7 days', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'); $wpdb->query($wpdb->prepare( "DELETE FROM {$table} WHERE expires_at < %s", $now_utc )); $wpdb->query($wpdb->prepare( "DELETE FROM {$table} WHERE used_count >= max_uses AND created_at < %s", $audit_cutoff )); } public function register_rewrite(): void { add_rewrite_rule( '^' . self::ENDPOINT . '/([A-Za-z0-9]{1,64})/?$', 'index.php?' . self::QUERY_VAR . '=$matches[1]', 'top' ); } public function add_query_var(array $vars): array { $vars[] = self::QUERY_VAR; return $vars; } public function register_routes(): void { register_rest_route('aksrt/v1', '/download/request', [ 'methods' => 'POST', 'callback' => [$this, 'rest_request'], 'permission_callback' => [$this, 'check_rest_nonce'], ]); register_rest_route('aksrt/v1', '/download/policy/(?P\d+)', [ 'methods' => 'GET', 'callback' => [$this, 'rest_policy'], 'permission_callback' => '__return_true', ]); } public function check_rest_nonce(\WP_REST_Request $request) { // The capability matrix can explicitly allow resource downloads for // everyone. A WordPress REST nonce only exists for authenticated // sessions, so requiring one from visitors would make the "all" // column ineffective. Anonymous requests still pass the unified // download gate (capability, payment and captcha) in rest_request(). if (get_current_user_id() <= 0) { return true; } $nonce = $request->get_header('x-wp-nonce'); if (!$nonce) { return new \WP_Error('aksrt_rest_no_nonce', __('缺少安全令牌', 'aksrt'), ['status' => 403]); } if (!wp_verify_nonce($nonce, 'wp_rest')) { return new \WP_Error('aksrt_rest_bad_nonce', __('安全令牌已过期,请刷新页面', 'aksrt'), ['status' => 403]); } return true; } public function rest_policy(\WP_REST_Request $request) { $post_id = (int) $request['id']; $post = get_post($post_id); if (!$post || !function_exists('aksrt_is_downloadable_content') || !aksrt_is_downloadable_content($post)) { return new \WP_Error('aksrt_invalid', __('内容不存在或未启用下载能力', 'aksrt'), ['status' => 404]); } $meta = function_exists('aksrt_get_download_box_meta') ? aksrt_get_download_box_meta($post) : []; $cost = aksrt_resource_download_cost($post); $uid = get_current_user_id(); $priced = function_exists('aksrt_get_user_pay_price') ? aksrt_get_user_pay_price($post, $uid) : null; $pay_modo = is_array($priced) ? ((($priced['modo'] ?? '0') === 'points') ? 'points' : '0') : ((($meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0'); $display_price = is_array($priced) ? (float) ($priced['amount'] ?? 0) : (float) ($meta['price'] ?? 0); $access = class_exists('AKSRT\\Content\\Visibility') ? Visibility::get_post_access_state($post, $uid) : []; $need_captcha = function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('download'); return new \WP_REST_Response([ 'post_id' => $post_id, 'price' => $display_price, 'base_price' => is_array($priced) ? (float) ($priced['base'] ?? $display_price) : (float) ($meta['price'] ?? 0), 'pay_modo' => $pay_modo, 'pay_limit' => (int) ($meta['pay_limit'] ?? 0), 'cost' => $cost, 'require_login' => !empty($access['need_login']), 'require_captcha' => $need_captcha, 'logged_in' => $uid > 0, 'balance' => $uid > 0 && class_exists('\AKSRT\Credits\Credits') ? \AKSRT\Credits\Credits::balance($uid) : 0, 'has_paid_access' => !empty($access['has_paid_access']), 'need_purchase' => !empty($access['need_purchase']), 'need_vip' => !empty($access['need_vip']), 'need_login' => !empty($access['need_login']) && $uid <= 0, 'can_download' => !empty($access['can_download']), 'login_url' => wp_login_url(get_permalink($post)), 'vip_level' => (int) ($access['user_vip_level'] ?? 0), 'free_limit' => $uid > 0 && function_exists('aksrt_get_user_free_download_limit') ? aksrt_get_user_free_download_limit($uid) : 0, 'free_used' => $uid > 0 && function_exists('aksrt_get_user_free_downloaded_number') ? aksrt_get_user_free_downloaded_number($uid) : 0, 'free_surplus' => $uid > 0 && function_exists('aksrt_get_free_download_surplus') ? aksrt_get_free_download_surplus($uid) : null, 'sources' => array_map(function ($row, $idx) { $extract_code = trim((string) ($row['extract_code'] ?? '')); return [ 'index' => $idx, 'label' => $row['label'] ?? '', 'type' => $row['type'] ?? 'direct', 'has_extract' => $extract_code !== '', ]; }, $meta['downloads'] ?? [], array_keys($meta['downloads'] ?? [])), ], 200); } public function rest_request(\WP_REST_Request $request) { $params = $request->get_json_params(); if (!is_array($params)) $params = $request->get_params(); $post_id = isset($params['post_id']) ? (int) $params['post_id'] : 0; $source_index = isset($params['source_index']) ? (int) $params['source_index'] : 0; $post = get_post($post_id); if (!$post || !function_exists('aksrt_is_downloadable_content') || !aksrt_is_downloadable_content($post)) { return new \WP_Error('aksrt_invalid', __('内容不存在或未启用下载能力', 'aksrt'), ['status' => 404]); } $meta = function_exists('aksrt_get_download_box_meta') ? aksrt_get_download_box_meta($post) : []; if (empty($meta['downloads'][$source_index])) { return new \WP_Error('aksrt_no_source', __('下载源不存在', 'aksrt'), ['status' => 404]); } $source = $meta['downloads'][$source_index]; $gate = aksrt_can_download($post); if (is_wp_error($gate)) return $gate; $uid = get_current_user_id(); $cost = aksrt_resource_download_cost($post); // Daily free-download quota (legacy). if ($cost <= 0 && $uid > 0 && function_exists('aksrt_check_free_download_quota')) { $quota = aksrt_check_free_download_quota($uid, $cost); if (is_wp_error($quota)) { return $quota; } } $need_captcha = function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('download'); if ($need_captcha && function_exists('aksrt_require_captcha')) { $cap_payload = isset($params['captcha']) && is_array($params['captcha']) ? $params['captcha'] : []; // 场景未启用时 require 直接放行;启用时强制校验 token。 if (function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('download')) { $verify = aksrt_require_captcha('download', $cap_payload); if ($verify !== true) { $msg = is_string($verify) ? $verify : __('人机验证失败', 'aksrt'); return new \WP_Error('aksrt_captcha_failed', $msg, ['status' => 400]); } } } // Purchase is one-shot via cashier (resource order). Token issue does not re-deduct. $token = $this->issue_token($uid, $post_id, $source_index, 0); if (is_wp_error($token)) { return $token; } // Record free download against daily quota after token issued. if ($cost <= 0 && $uid > 0 && function_exists('aksrt_record_user_free_download')) { aksrt_record_user_free_download($uid, $post_id); } $dl_url = home_url('/' . self::ENDPOINT . '/' . $token); if ($uid > 0 && function_exists('aksrt_add_user_notification')) { aksrt_add_user_notification($uid, [ 'type' => 'download_ready', 'title' => __('资源下载已就绪', 'aksrt'), 'desc' => sprintf(__('《%s》下载链接已生成,可立即开始下载。', 'aksrt'), get_the_title($post)), 'url' => $dl_url, ]); } $extract_code = trim((string) ($source['extract_code'] ?? '')); return new \WP_REST_Response([ 'success' => true, 'token' => $token, 'dl_url' => $dl_url, 'expires_in' => self::TOKEN_TTL, 'source_label' => $source['label'] ?? '', 'source_type' => $source['type'] ?? 'direct', 'has_extract' => $extract_code !== '', 'extract_code' => $extract_code, 'balance' => $uid > 0 && class_exists('\AKSRT\Credits\Credits') ? \AKSRT\Credits\Credits::balance($uid) : 0, ], 200); } public function maybe_serve_download(): void { $token = get_query_var(self::QUERY_VAR); if (!$token) return; $token = (string) $token; if (strlen($token) > 64 || !preg_match('/^[A-Za-z0-9]+$/', $token)) { $this->fail_410(); } global $wpdb; $table = \AKSRT\Core\DBMigrator::table('dl_token'); if (!$table) $this->fail_410(); $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$table} WHERE token = %s LIMIT 1", $token ), ARRAY_A); if (!$row) $this->fail_410(); $expires_at = strtotime($row['expires_at'] . ' UTC'); if ($expires_at !== false && time() > $expires_at) $this->fail_410(); if ((int) $row['used_count'] >= (int) $row['max_uses']) $this->fail_410(); $ip = function_exists('aksrt_get_client_ip') ? (string) aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''); $ua_hash = sha1((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); if (!empty($row['ip_bind']) && $row['ip_bind'] !== $ip) $this->fail_410(); if (!empty($row['ua_hash']) && $row['ua_hash'] !== $ua_hash) $this->fail_410(); $post = get_post((int) $row['post_id']); if (!$post || !function_exists('aksrt_is_downloadable_content') || !aksrt_is_downloadable_content($post)) $this->fail_410(); $meta = function_exists('aksrt_get_download_box_meta') ? aksrt_get_download_box_meta($post) : []; $idx = (int) $row['source_index']; if (empty($meta['downloads'][$idx]['url'])) $this->fail_410(); $wpdb->update( $table, ['used_count' => (int) $row['used_count'] + 1], ['token' => $token], ['%d'], ['%s'] ); // Count on actual token consumption. if (function_exists('aksrt_update_post_counter')) { aksrt_update_post_counter((int) $row['post_id'], '_aksrt_res_download_count', 1); } else { $count = (int) get_post_meta((int) $row['post_id'], '_aksrt_res_download_count', true); update_post_meta((int) $row['post_id'], '_aksrt_res_download_count', $count + 1); } $target_url = esc_url_raw((string) $meta['downloads'][$idx]['url']); // Local attachment / uploads file: stream without artificial throttling. $local_path = $this->resolve_local_download_path($target_url); if ($local_path !== '' && is_readable($local_path)) { $this->stream_local_file($local_path); exit; } nocache_headers(); wp_redirect($target_url, 302); exit; } /** * Map a site-local URL to a filesystem path under uploads (or ABSPATH). */ private function resolve_local_download_path(string $url): string { if ($url === '') { return ''; } $uploads = wp_upload_dir(); $baseurl = trailingslashit((string) ($uploads['baseurl'] ?? '')); $basedir = trailingslashit((string) ($uploads['basedir'] ?? '')); if ($baseurl !== '' && str_starts_with($url, $baseurl)) { $rel = substr($url, strlen($baseurl)); $path = $basedir . ltrim(rawurldecode($rel), '/\\'); $real = realpath($path); $root = realpath($basedir); if ($real && $root && str_starts_with($real, $root) && is_file($real)) { return $real; } } // Same-host path under ABSPATH. $home = trailingslashit(home_url('/')); if (str_starts_with($url, $home)) { $rel = substr($url, strlen($home)); $path = ABSPATH . ltrim(rawurldecode($rel), '/\\'); $real = realpath($path); $root = realpath(ABSPATH); if ($real && $root && str_starts_with($real, $root) && is_file($real)) { return $real; } } return ''; } /** * Stream a local file. */ private function stream_local_file(string $path): void { $filename = basename($path); $size = filesize($path); if ($size === false) { $this->fail_410(); } nocache_headers(); header('Content-Description: File Transfer'); header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="' . rawurlencode($filename) . '"'); header('Content-Transfer-Encoding: binary'); header('Content-Length: ' . (string) $size); $chunk = 1024 * 64; // 64KB $fh = fopen($path, 'rb'); if (!$fh) { $this->fail_410(); } // Disable output compression so Content-Length remains accurate. if (function_exists('apache_setenv')) { @apache_setenv('no-gzip', '1'); } @ini_set('zlib.output_compression', 'Off'); while (!feof($fh)) { $buf = fread($fh, $chunk); if ($buf === false) { break; } echo $buf; if (function_exists('flush')) { flush(); } } fclose($fh); } private function fail_410(): void { status_header(410); nocache_headers(); wp_die(esc_html__('下载链接已过期或无效,请回到资源页重新获取。', 'aksrt'), 410); } private function issue_token(int $user_id, int $post_id, int $source_index, int $cost) { global $wpdb; $table = \AKSRT\Core\DBMigrator::table('dl_token'); if (!$table) { return new \WP_Error('aksrt_no_table', __('下载令牌表未初始化', 'aksrt'), ['status' => 500]); } $token = $this->random_token(self::TOKEN_LEN); $ip = function_exists('aksrt_get_client_ip') ? (string) aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''); $ua_hash = sha1((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); $now_utc = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'); $expires_utc = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->modify('+' . self::TOKEN_TTL . ' seconds')->format('Y-m-d H:i:s'); $ok = $wpdb->insert( $table, [ 'token' => $token, 'user_id' => $user_id, 'post_id' => $post_id, 'source_index' => $source_index, 'cost' => $cost, 'ip_bind' => $ip, 'ua_hash' => $ua_hash, 'used_count' => 0, 'max_uses' => 1, 'expires_at' => $expires_utc, 'created_at' => $now_utc, ], ['%s', '%d', '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%s', '%s'] ); if ($ok === false) { return new \WP_Error('aksrt_token_insert', __('生成下载令牌失败', 'aksrt'), ['status' => 500]); } return $token; } private function random_token(int $len): string { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; $max = strlen($alphabet) - 1; $out = ''; for ($i = 0; $i < $len; $i++) { $out .= $alphabet[random_int(0, $max)]; } return $out; } }