feat(download): switch download buttons to direct source links

The token relay still stood between the button and the file: every click
issued a short-lived token URL, so downloads never reached the real
source address directly.

- Render direct anchors (target=_blank) to the raw source URL in the
  download box and the paid card, with inline extract-code buttons
- Retire the relay: delete Content\Download plus its init wiring, header
  query-var branch and SEO noindex branch, and drop the dead
  download-request flow from cms-download.js (cashier, scan-pay and
  error views stay)
- Keep the dl_token table and the download logs page as read-only
  history; unscheduling of the orphaned cleanup cron is preserved
This commit is contained in:
kate522 committed 2026-09-24 19:12:45 +08:00
1 parent bc97c4ddc3
commit ab495854d5
10 files changed
+118 -841

No files matched your search

+1 -1
View File
@@ -76,7 +76,7 @@ class PayDownloadLogs {
$total = $result['total'];
$pages = max(1, (int) ceil($total / $per_page));
echo '<p class="description">' . esc_html__('基于下载令牌表(aksrt_dl_token)。已使用记录会保留一段时间后清理;未使用且过期的令牌会被定期删除。', 'aksrt') . '</p>';
echo '<p class="description">' . esc_html__('基于下载令牌表(aksrt_dl_token)。下载已改为直连模式,不再签发新令牌,以下为历史记录。', 'aksrt') . '</p>';
echo '<form method="get" style="margin:12px 0;">';
echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" />';
-538
View File
@@ -1,538 +0,0 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Content;
defined('ABSPATH') || exit;
class Download {
const TOKEN_LEN = 48;
const QUERY_VAR = 'aksrt_dl_token';
// Fixed relay configuration. The admin download-config settings page was
// removed, so these constants are the single source of truth for the
// site-routed download system (endpoint, per-token uses, TTL).
const ENDPOINT = 'download';
const MAX_USES = 1;
const TTL_MINUTES = 10;
private static $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('init', [$this, 'register_rewrite']);
add_filter('query_vars', [$this, 'add_query_var']);
add_action('template_redirect', [$this, 'maybe_serve_download'], 0);
add_action('rest_api_init', [$this, 'register_routes']);
add_action('init', [$this, 'maybe_flush_rewrite'], 99);
add_action('wp', [$this, 'maybe_schedule_cleanup']);
add_action('aksrt_dl_token_cleanup', [$this, 'cleanup_expired_tokens']);
}
public function maybe_schedule_cleanup(): void {
if (!wp_next_scheduled('aksrt_dl_token_cleanup')) {
$next = (new \DateTimeImmutable('tomorrow 03:30', wp_timezone()))->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
));
}
/**
* Effective download settings for the fixed relay configuration.
*/
private function config(): array {
return [
'endpoint' => self::ENDPOINT,
'max_uses' => self::MAX_USES,
'ttl' => self::TTL_MINUTES * MINUTE_IN_SECONDS,
];
}
public function register_rewrite(): void {
add_rewrite_rule(
'^' . preg_quote($this->config()['endpoint'], '/') . '/([A-Za-z0-9]{1,64})/?$',
'index.php?' . self::QUERY_VAR . '=$matches[1]',
'top'
);
}
/**
* Re-flush rewrite rules on theme version changes, using the same
* version-marker approach as Auth\Router.
*/
public function maybe_flush_rewrite(): void {
$version = AKSRT_VERSION . ':download-endpoint:' . self::ENDPOINT;
if ((string) get_option('aksrt_dl_rewrite_version') !== $version) {
flush_rewrite_rules(false);
update_option('aksrt_dl_rewrite_version', $version);
}
}
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'],
]);
}
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_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;
}
}
$cap_payload = isset($params['captcha']) && is_array($params['captcha']) ? $params['captcha'] : [];
$verify = aksrt_require_captcha('download', $cap_payload);
if ($verify !== true) {
$code = is_wp_error($verify) ? $verify->get_error_code() : 'aksrt_captcha_failed';
$msg = is_wp_error($verify) ? $verify->get_error_message() : __('人机验证失败', 'aksrt');
return new \WP_Error($code ?: 'aksrt_captcha_failed', $msg, ['status' => 400]);
}
// Purchase is one-shot via cashier (resource order). Token issue does not re-deduct.
$config = $this->config();
$token = $this->issue_token($uid, $post_id, $source_index, 0, $config);
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('/' . $config['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' => $config['ttl'],
'source_label' => $source['label'] ?? '',
'source_type' => $source['type'] ?? 'direct',
'has_extract' => $extract_code !== '',
'extract_code' => $extract_code,
'balance' => $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 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();
// Probes (HEAD requests and Speculative-Loading prefetches) validate
// availability but must never consume a use nor waste bandwidth.
$is_probe = $this->is_probe_request();
$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->send_download_headers($local_path);
if (!$is_probe) {
// Count only fully delivered bodies; aborted transfers stay reusable.
if ($this->stream_body($local_path)) {
$this->consume_token($row, $token);
}
}
exit;
}
nocache_headers();
wp_redirect($target_url, 302);
// Remote hosts give no completion signal, so the redirect hand-off
// counts as delivery; probes still never consume.
if (!$is_probe) {
$this->consume_token($row, $token);
}
exit;
}
/**
* Requests that inspect the link instead of taking the download: plain
* HEAD plus browser speculative prefetches (Sec-Purpose: prefetch).
*/
private function is_probe_request(): bool {
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'HEAD') {
return true;
}
$purpose = strtolower((string) ($_SERVER['HTTP_SEC_PURPOSE'] ?? ($_SERVER['HTTP_PURPOSE'] ?? '')));
return $purpose !== '' && str_contains($purpose, 'prefetch');
}
/**
* Emit the attachment headers for a local download.
*/
private function send_download_headers(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);
}
/**
* Stream the local file body after its headers. Returns true only when
* every byte was flushed to the client.
*/
private function stream_body(string $path): bool {
$size = filesize($path);
if ($size === false) {
$this->fail_410();
}
$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)) {
if (connection_aborted()) {
fclose($fh);
return false;
}
$buf = fread($fh, $chunk);
if ($buf === false) {
fclose($fh);
return false;
}
echo $buf;
if (function_exists('flush')) {
flush();
}
}
fclose($fh);
return !connection_aborted();
}
/**
* 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 '';
}
/**
* Count a delivered download. The guard caps concurrent finishers at
* max_uses instead of overshooting the counter.
*/
private function consume_token(array $row, string $token): void {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('dl_token');
if (!$table) return;
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$table} SET used_count = used_count + 1 WHERE token = %s AND used_count < max_uses",
$token
));
if (!$updated) {
return;
}
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);
}
}
private function fail_410(): void {
status_header(410);
nocache_headers();
// Themed expiry page; wp_die stays only as the safety net for the
// paths that already streamed headers/body (send_download_headers,
// stream_body) where the theme chrome can no longer be emitted.
if (function_exists('get_header') && !headers_sent()) {
$this->render_expired_page();
exit;
}
wp_die(
esc_html__('下载链接已过期或无效,请回到资源页重新获取。', 'aksrt'),
esc_html__('下载链接已失效', 'aksrt'),
['response' => 410]
);
}
/**
* Flat, theme-native 410 page. All colors resolve from the runtime CSS
* variables (--color-primary plus its html.dark override, legacy ink
* vars flipped by body.dark-theme), so the admin-configured primary
* color and the light/dark mode apply with no extra scripts.
*/
private function render_expired_page(): void {
add_action('wp_head', static function (): void {
$css = <<<'CSS'
.aksrt-410{display:flex;align-items:center;justify-content:center;min-height:72vh;padding:48px 20px;text-align:center}
.aksrt-410__card{max-width:460px}
.aksrt-410__icon{width:92px;height:92px;margin:0 auto 28px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:2.25rem;background:var(--color-primary-soft,rgba(0,0,0,.06));color:var(--color-primary,#f04494)}
.aksrt-410__code{font-family:var(--font-display,var(--font-body,inherit));font-size:clamp(4rem,10vw,5.5rem);font-weight:800;line-height:1;letter-spacing:.02em;color:var(--muted-4-color,#d2d2d2);margin:0 0 10px}
.aksrt-410__title{font-family:var(--font-display,inherit);font-size:clamp(20px,3vw,26px);font-weight:700;line-height:1.3;color:var(--key-color,#333);margin:0 0 12px}
.aksrt-410__desc{font-size:15px;line-height:1.7;color:var(--muted-color,#777);margin:0 0 32px}
.aksrt-410__actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
.aksrt-410__actions .btn-primary,.aksrt-410__actions .btn-outline{--btn-h:42px;--btn-px:22px}
CSS;
echo '<style id="aksrt-410-page">' . $css . '</style>' . "\n";
}, 99);
$title_filter = static function (array $parts): array {
$parts['title'] = __('下载链接已失效', 'aksrt');
return $parts;
};
add_filter('document_title_parts', $title_filter);
get_header();
remove_filter('document_title_parts', $title_filter);
?>
<main id="main-content" class="aksrt-410" tabindex="-1">
<div class="aksrt-410__card">
<span class="aksrt-410__icon" aria-hidden="true"><i class="fa fas fa-hourglass-end"></i></span>
<p class="aksrt-410__code">410</p>
<h1 class="aksrt-410__title"><?php esc_html_e('下载链接已失效', 'aksrt'); ?></h1>
<p class="aksrt-410__desc"><?php esc_html_e('下载链接已过期或无效,请回到资源页重新获取。', 'aksrt'); ?></p>
<div class="aksrt-410__actions">
<a href="javascript:history.back()" class="btn-primary">
<i class="fa fas fa-arrow-left" aria-hidden="true"></i>
<span><?php esc_html_e('返回上一页', 'aksrt'); ?></span>
</a>
<a href="<?php echo esc_url(home_url('/')); ?>" class="btn-outline">
<i class="fa fas fa-home" aria-hidden="true"></i>
<span><?php esc_html_e('返回首页', 'aksrt'); ?></span>
</a>
</div>
</div>
</main>
<?php
get_footer();
exit;
}
private function issue_token(int $user_id, int $post_id, int $source_index, int $cost, array $config = []) {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('dl_token');
if (!$table) {
return new \WP_Error('aksrt_no_table', __('下载令牌表未初始化', 'aksrt'), ['status' => 500]);
}
if (empty($config)) {
$config = $this->config();
}
$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('+' . (int) $config['ttl'] . ' seconds')
->format('Y-m-d H:i:s');
// PRIMARY KEY(token) already guarantees uniqueness; retry on the
// astronomically unlikely collision instead of failing the request.
for ($attempt = 0; $attempt < 3; $attempt++) {
$token = $this->random_token(self::TOKEN_LEN);
$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' => (int) $config['max_uses'],
'expires_at' => $expires_utc,
'created_at' => $now_utc,
],
['%s', '%d', '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%s', '%s']
);
if ($ok !== false) {
return $token;
}
}
return new \WP_Error('aksrt_token_insert', __('生成下载令牌失败', 'aksrt'), ['status' => 500]);
}
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;
}
}
-11
View File
@@ -26,8 +26,6 @@ class SEOHead {
}
// 单一出口:所有 SEO/OG/JSON-LD 输出集中在 priority 2。
add_action('wp_head', [$this, 'render'], 2);
// robots:dl token 页面 noindex(独立、最早输出)。
add_action('wp_head', [$this, 'render_robots'], 1);
}
/**
@@ -229,15 +227,6 @@ class SEOHead {
return '';
}
/**
* dl token 页面 noindex。
*/
public function render_robots(): void {
if (function_exists('get_query_var') && get_query_var('aksrt_dl_token')) {
echo '<meta name="robots" content="noindex,nofollow">' . "\n";
}
}
/**
* 输出 head(favicon + canonical + description/keywords + OG/Twitter + JSON-LD)。
*/