进行了一些优化

This commit is contained in:
2026-08-27 03:27:26 +08:00
parent ad128ef94e
commit 50c5769d01
8 changed files with 340 additions and 71 deletions
+13
View File
@@ -73,6 +73,19 @@ class SchemaPermissions {
]],
],
],
'download' => [
'key' => 'download',
'title' => '下载配置',
'version' => '1.0.0',
'option_key' => 'aksrt_download_config',
'groups' => [
['key' => 'basic', 'title' => '链接规则', 'fields' => [
['key' => 'url_alias', 'label' => '下载链接 URL 别名', 'type' => 'input', 'default' => 'download', 'required' => true, 'help' => '仅允许英文字母、数字、连字符和下划线;禁止中文、空格和 / \\ ? # & 等特殊符号。修改后旧别名路由立即失效,需重新获取下载地址。'],
['key' => 'default_max_uses', 'label' => '下载链接默认使用次数', 'type' => 'number', 'default' => 1, 'min' => 1, 'max' => 100, 'help' => '单个链接可完成下载的次数;文件完整送达后才计次(HEAD 探测、传输中断不计次)。耗尽后链接返回 410,需重新获取。'],
['key' => 'ttl_minutes', 'label' => '下载链接默认有效期限(分钟)', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 1440, 'help' => '链接签发后超过该时长即失效并返回 410,不自动续期,需重新获取。'],
]],
],
],
'moderation' => [
'key' => 'moderation',
'title' => '评论审核',
+25
View File
@@ -291,9 +291,34 @@ class SchemaProvider {
}
}
if ($domain === 'permissions' && $section === 'download') {
$alias = trim((string) ($data['url_alias'] ?? ''));
if ($alias === '') {
$errors[] = __('下载链接 URL 别名不能为空', 'aksrt');
} elseif (strlen($alias) > 48) {
$errors[] = __('下载链接 URL 别名长度不能超过 48 个字符', 'aksrt');
} elseif (!preg_match('/^[A-Za-z0-9_-]+$/', $alias)) {
$errors[] = __('下载链接 URL 别名只能包含英文字母、数字、连字符和下划线,禁止中文、空格和 / \\ ? # & 等特殊符号', 'aksrt');
} elseif (in_array(strtolower($alias), $this->reserved_download_aliases(), true)) {
$errors[] = __('该别名与系统保留路径冲突,请更换其他别名以避免路由 404', 'aksrt');
}
}
return ['valid' => empty($errors), 'errors' => $errors];
}
/**
* Top-level slugs that must never be shadowed by the download route.
*/
private function reserved_download_aliases(): array {
return [
'wp-json', 'wp-admin', 'wp-content', 'wp-includes',
'admin', 'login', 'logout', 'register', 'lostpassword',
'feed', 'search', 'author', 'page', 'comments',
'sitemap', 'sign', 'cgi-bin',
];
}
public function read_settings(string $domain, string $section = ''): array {
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
+136 -51
View File
@@ -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 {
+1
View File
@@ -38,6 +38,7 @@ class Settings {
'aksrt_oauth',
'aksrt_captcha_config',
'aksrt_moderation_config',
'aksrt_download_config',
'aksrt_footer_tabbar',
'aksrt_floating_buttons',
'aksrt_credit_settings',