进行了一些优化
This commit is contained in:
+136
-51
@@ -9,10 +9,14 @@ defined('ABSPATH') || exit;
|
||||
|
||||
class Download {
|
||||
|
||||
const TOKEN_TTL = 600; // 10 minutes
|
||||
const TOKEN_LEN = 48;
|
||||
const QUERY_VAR = 'aksrt_dl_token';
|
||||
const ENDPOINT = 'aksrt-dl';
|
||||
|
||||
// Hardened fallbacks used when the aksrt_download_config option is
|
||||
// missing or contains values the admin validation would reject.
|
||||
const FALLBACK_ENDPOINT = 'download';
|
||||
const FALLBACK_MAX_USES = 1;
|
||||
const FALLBACK_TTL_MINUTES = 10;
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
@@ -31,6 +35,7 @@ class Download {
|
||||
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']);
|
||||
}
|
||||
@@ -63,14 +68,54 @@ class Download {
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective download settings (aksrt_download_config), clamped so a
|
||||
* corrupted option can never break routing or accounting.
|
||||
*/
|
||||
private function config(): array {
|
||||
$cfg = get_option('aksrt_download_config', []);
|
||||
if (!is_array($cfg)) {
|
||||
$cfg = [];
|
||||
}
|
||||
|
||||
$alias = trim((string) ($cfg['url_alias'] ?? self::FALLBACK_ENDPOINT));
|
||||
if ($alias === '' || strlen($alias) > 48 || !preg_match('/^[A-Za-z0-9_-]+$/', $alias)) {
|
||||
$alias = self::FALLBACK_ENDPOINT;
|
||||
}
|
||||
|
||||
$max_uses = isset($cfg['default_max_uses']) ? (int) $cfg['default_max_uses'] : self::FALLBACK_MAX_USES;
|
||||
$max_uses = min(100, max(1, $max_uses));
|
||||
|
||||
$ttl_minutes = isset($cfg['ttl_minutes']) ? (int) $cfg['ttl_minutes'] : self::FALLBACK_TTL_MINUTES;
|
||||
$ttl_minutes = min(1440, max(1, $ttl_minutes));
|
||||
|
||||
return [
|
||||
'endpoint' => $alias,
|
||||
'max_uses' => $max_uses,
|
||||
'ttl' => $ttl_minutes * MINUTE_IN_SECONDS,
|
||||
];
|
||||
}
|
||||
|
||||
public function register_rewrite(): void {
|
||||
add_rewrite_rule(
|
||||
'^' . self::ENDPOINT . '/([A-Za-z0-9]{1,64})/?$',
|
||||
'^' . preg_quote($this->config()['endpoint'], '/') . '/([A-Za-z0-9]{1,64})/?$',
|
||||
'index.php?' . self::QUERY_VAR . '=$matches[1]',
|
||||
'top'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-flush rewrite rules whenever the configured alias changes, using
|
||||
* the same version-marker approach as Auth\Router.
|
||||
*/
|
||||
public function maybe_flush_rewrite(): void {
|
||||
$version = AKSRT_VERSION . ':download-endpoint:' . $this->config()['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;
|
||||
@@ -126,7 +171,7 @@ class Download {
|
||||
: ((($meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0');
|
||||
$display_price = is_array($priced)
|
||||
? ($priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0.00'))
|
||||
: ($pay_modo === 'points' ? (int) ($meta['points_price'] ?? 0) : (string) ($meta['price'] ?? '0.00'));
|
||||
: (($pay_modo === 'points') ? (int) ($meta['points_price'] ?? 0) : (string) ($meta['price'] ?? '0.00'));
|
||||
|
||||
$access = class_exists('AKSRT\\Content\\Visibility')
|
||||
? Visibility::get_post_access_state($post, $uid)
|
||||
@@ -144,7 +189,7 @@ class Download {
|
||||
'require_login' => !empty($access['need_login']),
|
||||
'require_captcha' => $need_captcha,
|
||||
'logged_in' => $uid > 0,
|
||||
'balance' => $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0,
|
||||
'balance' => $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0,
|
||||
'has_paid_access' => !empty($access['has_paid_access']),
|
||||
'need_purchase' => !empty($access['need_purchase']),
|
||||
'need_vip' => !empty($access['need_vip']),
|
||||
@@ -213,7 +258,8 @@ class Download {
|
||||
}
|
||||
|
||||
// Purchase is one-shot via cashier (resource order). Token issue does not re-deduct.
|
||||
$token = $this->issue_token($uid, $post_id, $source_index, 0);
|
||||
$config = $this->config();
|
||||
$token = $this->issue_token($uid, $post_id, $source_index, 0, $config);
|
||||
if (is_wp_error($token)) {
|
||||
return $token;
|
||||
}
|
||||
@@ -223,7 +269,7 @@ class Download {
|
||||
aksrt_record_user_free_download($uid, $post_id);
|
||||
}
|
||||
|
||||
$dl_url = home_url('/' . self::ENDPOINT . '/' . $token);
|
||||
$dl_url = home_url('/' . $config['endpoint'] . '/' . $token);
|
||||
if ($uid > 0 && function_exists('aksrt_add_user_notification')) {
|
||||
aksrt_add_user_notification($uid, [
|
||||
'type' => 'download_ready',
|
||||
@@ -239,12 +285,12 @@ class Download {
|
||||
'success' => true,
|
||||
'token' => $token,
|
||||
'dl_url' => $dl_url,
|
||||
'expires_in' => self::TOKEN_TTL,
|
||||
'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,
|
||||
'balance' => $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0,
|
||||
], 200);
|
||||
}
|
||||
|
||||
@@ -283,33 +329,29 @@ class Download {
|
||||
$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);
|
||||
}
|
||||
// HEAD probes validate the link but must never consume it.
|
||||
$is_head = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'HEAD';
|
||||
|
||||
$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);
|
||||
$delivered = $this->stream_local_file($local_path);
|
||||
// Count only fully delivered bodies; aborted transfers stay reusable.
|
||||
if ($delivered && !$is_head) {
|
||||
$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; HEAD probes still never consume.
|
||||
if (!$is_head) {
|
||||
$this->consume_token($row, $token);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -347,9 +389,9 @@ class Download {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a local file.
|
||||
* Stream a local file. Returns true only when the body was sent in full.
|
||||
*/
|
||||
private function stream_local_file(string $path): void {
|
||||
private function stream_local_file(string $path): bool {
|
||||
$filename = basename($path);
|
||||
$size = filesize($path);
|
||||
if ($size === false) {
|
||||
@@ -375,9 +417,14 @@ class Download {
|
||||
}
|
||||
@ini_set('zlib.output_compression', 'Off');
|
||||
while (!feof($fh)) {
|
||||
if (connection_aborted()) {
|
||||
fclose($fh);
|
||||
return false;
|
||||
}
|
||||
$buf = fread($fh, $chunk);
|
||||
if ($buf === false) {
|
||||
break;
|
||||
fclose($fh);
|
||||
return false;
|
||||
}
|
||||
echo $buf;
|
||||
if (function_exists('flush')) {
|
||||
@@ -385,6 +432,34 @@ class Download {
|
||||
}
|
||||
}
|
||||
fclose($fh);
|
||||
|
||||
return !connection_aborted();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -393,44 +468,54 @@ class Download {
|
||||
wp_die(esc_html__('下载链接已过期或无效,请回到资源页重新获取。', 'aksrt'), 410);
|
||||
}
|
||||
|
||||
private function issue_token(int $user_id, int $post_id, int $source_index, int $cost) {
|
||||
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]);
|
||||
}
|
||||
|
||||
$token = $this->random_token(self::TOKEN_LEN);
|
||||
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('+' . self::TOKEN_TTL . ' seconds')->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');
|
||||
|
||||
$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']
|
||||
);
|
||||
// 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);
|
||||
|
||||
if ($ok === false) {
|
||||
return new \WP_Error('aksrt_token_insert', __('生成下载令牌失败', 'aksrt'), ['status' => 500]);
|
||||
$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 $token;
|
||||
return new \WP_Error('aksrt_token_insert', __('生成下载令牌失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
private function random_token(int $len): string {
|
||||
|
||||
Reference in New Issue
Block a user