From 50c5769d01abd66ff39fcb1b26d4697c5b8a279c Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 27 Aug 2026 03:27:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E4=BA=86=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ss_72ae2697-306c-467a-8269-53caeb827381.md | 53 +++++ .../src/constants/sections/page-display.js | 94 +++++++-- .../src/constants/sections/permissions.js | 10 + .../src/schema/sections/permissions/index.js | 28 +++ src/Admin/Schema/SchemaPermissions.php | 13 ++ src/Admin/SchemaProvider.php | 25 +++ src/Content/Download.php | 187 +++++++++++++----- src/Settings/Settings.php | 1 + 8 files changed, 340 insertions(+), 71 deletions(-) create mode 100644 .zcode/plans/plan-sess_72ae2697-306c-467a-8269-53caeb827381.md diff --git a/.zcode/plans/plan-sess_72ae2697-306c-467a-8269-53caeb827381.md b/.zcode/plans/plan-sess_72ae2697-306c-467a-8269-53caeb827381.md new file mode 100644 index 00000000..5f21b0e7 --- /dev/null +++ b/.zcode/plans/plan-sess_72ae2697-306c-467a-8269-53caeb827381.md @@ -0,0 +1,53 @@ +## 现状结论(影响方案的既有事实) + +- 下载系统集中在 `src/Content/Download.php`:路由前缀硬编码为常量 `ENDPOINT='aksrt-dl'`(同时用于 rewrite 正则 :68 和 URL 拼接 :226)、TTL 硬编码 `TOKEN_TTL=600`(:12)、`max_uses` 固定写死 1(:422)、`expires_in` 返回固定值(:242)。 +- 扣次在"点击兑换"时发生(`Download.php:286-292`),无 HEAD 特判、无断开检测,需按需求重构。 +- 过期/耗尽已是 `fail_410()`(HTTP 410 + wp_die 提示"回到资源页重新获取"),**不自动生成新链接** —— 该行为现状已满足,保持不变。 +- `aksest_dl_token` 表(`src/Core/DBMigratorPart2.php:133-150`)的 token 列已是 `PRIMARY KEY`(即唯一索引),重复密钥不可能入库;需补的是 issue_token 插入失败时的换号重试。 +- 设置体系是双端 schema:PHP 权威在 `src/Admin/Schema/*.php`,Vue 镜像在 `assets/admin/src/schema/sections/**`,菜单在 `assets/admin/src/constants/sections/permissions.js`,REST 用泛型路由自动生效。"功能&权限"= SPA 内 permissions 域。 +- `Auth\Router::maybe_flush_rewrite_rules()`(:78-84) 已有"版本标记式 flush_rewrite_rules()"先例,可直接套用。 + +## 一、新增设置分区(permissions 域 → "下载配置") + +存储为独立 option `aksrt_download_config`(沿用 captcha/moderation 的 standalone 模式),三个字段: + +| key | 类型 | 默认 | 校验 | +|---|---|---|---| +| `url_alias` | input | `download` | 必填;trim 后非空;仅允许 `[A-Za-z0-9_-]`(禁中文/空格// \ ? # & 等);≤48 字符;拒绝保留字黑名单(wp-json、wp-admin、wp-content、wp-includes、admin、login、logout、register、lostpassword、feed、search、author、page、comments、sitemap、sign 等,防路由冲突/404) | +| `default_max_uses` | number | `1` | min 1 / max 100 | +| `ttl_minutes` | number | `10` | min 1 / max 1440 | + +修改文件: +1. **`src/Admin/Schema/SchemaPermissions.php`**:新增 `download` section(version 1.0.0,option_key `aksrt_download_config`,单组 basic 三字段,含中文标签/help 说明"完整下载才计次、到期或耗尽后 410 需重新获取")。 +2. **`src/Admin/SchemaProvider.php` `validate()`**:仿照 payment/withdrawal 特例先例(:269-292),对 `permissions/download` 增加中文错误消息校验块(空值/非法字符/过长/保留字)——422 错误经前端 `message.error('保存失败: …')` 弹窗呈现,满足"后台弹窗提示"。 +3. **`src/Settings/Settings.php`**:`STANDALONE_OPTION_KEYS` 追加 `aksrt_download_config`(导入导出完整性 + 缓存失效覆盖)。 +4. **`assets/admin/src/schema/sections/permissions/index.js`**:镜像新增 `download` 分区(defaults 与 PHP 完全一致)。 +5. **`assets/admin/src/constants/sections/permissions.js`**:追加子页 `schemaPage({ key:'permissions-download', route:'/permissions/download', label:'下载配置', section:'download' })`,置于附件配置之后。泛型 REST 路由与 SchemaPage 无需改动。 + +## 二、Download.php 改造(`src/Content/Download.php`) + +1. **动态前缀**:删掉 `const ENDPOINT`;新增私有 `endpoint()` 读配置(无效/被污染时回落 `download`);`register_rewrite()` 用 `preg_quote($endpoint)` 拼正则(:66-72);URL 拼接(:226)同源。 +2. **别名变更刷新路由**:仿 `Auth\Router` 版本标记法——`init` 尾部挂 `maybe_flush_rewrite()`(priority 99):比较 `AKSRT_VERSION + 当前别名` 与 option `aksrt_dl_rewrite_version`,不一致则 `flush_rewrite_rules(false)` 并回写。导入/重置/直接改库都覆盖,无双路兼容逻辑。 +3. **动态 TTL / 次数**:删掉 `TOKEN_TTL` 常量引用;`issue_token()` 写入 `max_uses = 配置.default_max_uses`、`expires_at = now + 配置.ttl_minutes*60`;`rest_request` 的 `expires_in` 改为动态秒数。配置读取做 clamp 兜底(别名非法回落 download、次数 1..100、分钟 1..1440)。 +4. **完整下载才扣次**: + - 将扣次(UPDATE used_count)与文章计数 `_aksrt_res_download_count`(:286-300) 移到交付成功之后的新方法 `consume_token()`;UPDATE 加原子条件 `WHERE token=%s AND used_count < max_uses`,并发下计数封顶不超发。 + - `stream_local_file()` 改为返回是否完整送达:输出循环中 `connection_aborted()` 为真立即中断并返回 false;收尾再核验一次。仅当完整送达且非 HEAD 才 `consume_token()`——网络波动中断不消耗次数,用户可重新点同一链接续下。 + - **HEAD 探测**:`REQUEST_METHOD === 'HEAD'` 时照常走校验/输出 headers 但不扣次。 + - **外链 302 源**(远端主机无法感知"是否下完"):把 302 交接视为一次交付,重定向发出后扣次(HEAD 时同样跳过)。此为唯一可实现语义,计划中明示。 + - 过期/耗尽仍走现有 `fail_410()`(改动前后行为一致)。 +5. **密钥冲突重试**:`issue_token()` 插入失败时用全新随机串重试至多 3 次(PRIMARY KEY 已保证唯一,此为极低概率兜底;表缺失仍在入口提前返回 WP_Error 不进循环)。 + +## 三、清理与文档 + +- 删除不再使用的 `Download::ENDPOINT` / `Download::TOKEN_TTL` 常量及全部残留引用(实现时全库 grep 复核;已知其余命中仅为 CSS 类名巧合,不动)。 +- 更新 `CLAUDE.md:132` 文档行:rewrite 前缀改为"可配置别名(默认 download)",并补充"完整送达才计次"契约。 +- 不写任何迁移/双路:现有表结构不动,存量过期 token 由每日 cron 自然清理。 +- **关于唯一索引**:PRIMARY KEY(token) 本身即唯一索引且已存在(不加冗余 UNIQUE KEY,也不做表结构变更——与"不写迁移"一致),以插入重试落实需求的防冲突意图。 + +## 四、验证 + +1. 新增 Node 契约测试 `tests/widget-system/download-config-contract.test.cjs`:断言双端 schema 的字段/默认值/option_key 一致、菜单子项注册、Download.php 关键契约(动态 endpoint、流式完成后才条件 UPDATE 计次、HEAD 跳过、410 不自动续签、无 ENDPOINT/TOKEN_TTL 常量残留)。 +2. `node --test tests/widget-system/download-config-contract.test.cjs` + 跑现有相关契约测试确认无回归。 +3. `php -l` 全部改动的 PHP 文件;`php tools/audit-admin-contracts.php` 通过双端合同审计。 +4. `cd assets/admin && npm ci && npm run build`,确认 build 产物完整(PHP 端有 chunk 缺失探测)。 +5. 视觉不做真实验收(按约定跳过 Docker 对比环境)。 \ No newline at end of file diff --git a/assets/admin/src/constants/sections/page-display.js b/assets/admin/src/constants/sections/page-display.js index 8b0bb158..3a752a7e 100644 --- a/assets/admin/src/constants/sections/page-display.js +++ b/assets/admin/src/constants/sections/page-display.js @@ -1,28 +1,82 @@ import { renderFaIcon, schemaPage } from './helpers' -const pages = [ - ['header-nav', '顶部导航栏'], - ['header-components', '顶部多功能组件'], - ['footer', '底部页脚'], - ['homepage', '首页配置'], - ['category', '分类页面'], - ['tag', '标签页面'], - ['user-home', '用户主页'], - ['other', '其它页面'], -] - export const pageDisplaySection = { key: 'page-display', label: '页面&显示', icon: renderFaIcon('fa fas fa-desktop'), route: '/page-display', - children: pages.map(([section, label]) => schemaPage({ - key: `page-display-${section}`, - name: `PageDisplay${section.split('-').map(part => part[0].toUpperCase() + part.slice(1)).join('')}`, - label, - route: `/page-display/${section}`, - title: label, - canonicalDomain: 'page_display', - section, - })), + children: [ + schemaPage({ + key: 'page-display-header-nav', + name: 'PageDisplayHeaderNav', + label: '顶部导航栏', + route: '/page-display/header-nav', + title: '顶部导航栏', + canonicalDomain: 'page_display', + section: 'header-nav', + }), + schemaPage({ + key: 'page-display-header-components', + name: 'PageDisplayHeaderComponents', + label: '顶部多功能组件', + route: '/page-display/header-components', + title: '顶部多功能组件', + canonicalDomain: 'page_display', + section: 'header-components', + }), + schemaPage({ + key: 'page-display-footer', + name: 'PageDisplayFooter', + label: '底部页脚', + route: '/page-display/footer', + title: '底部页脚', + canonicalDomain: 'page_display', + section: 'footer', + }), + schemaPage({ + key: 'page-display-homepage', + name: 'PageDisplayHomepage', + label: '首页配置', + route: '/page-display/homepage', + title: '首页配置', + canonicalDomain: 'page_display', + section: 'homepage', + }), + schemaPage({ + key: 'page-display-category', + name: 'PageDisplayCategory', + label: '分类页面', + route: '/page-display/category', + title: '分类页面', + canonicalDomain: 'page_display', + section: 'category', + }), + schemaPage({ + key: 'page-display-tag', + name: 'PageDisplayTag', + label: '标签页面', + route: '/page-display/tag', + title: '标签页面', + canonicalDomain: 'page_display', + section: 'tag', + }), + schemaPage({ + key: 'page-display-user-home', + name: 'PageDisplayUserHome', + label: '用户主页', + route: '/page-display/user-home', + title: '用户主页', + canonicalDomain: 'page_display', + section: 'user-home', + }), + schemaPage({ + key: 'page-display-other', + name: 'PageDisplayOther', + label: '其它页面', + route: '/page-display/other', + title: '其它页面', + canonicalDomain: 'page_display', + section: 'other', + }), + ], } diff --git a/assets/admin/src/constants/sections/permissions.js b/assets/admin/src/constants/sections/permissions.js index bc11efb9..40ade320 100644 --- a/assets/admin/src/constants/sections/permissions.js +++ b/assets/admin/src/constants/sections/permissions.js @@ -48,5 +48,15 @@ export const permissionsSection = { canonicalDomain: 'permissions', section: 'attachment', }), + schemaPage({ + key: 'permissions-download', + name: 'PermissionsDownload', + label: '下载配置', + route: '/permissions/download', + title: '下载配置', + desc: '下载链接别名、默认使用次数与有效期限规则。', + canonicalDomain: 'permissions', + section: 'download', + }), ], } diff --git a/assets/admin/src/schema/sections/permissions/index.js b/assets/admin/src/schema/sections/permissions/index.js index 9a0870d6..7fcad622 100644 --- a/assets/admin/src/schema/sections/permissions/index.js +++ b/assets/admin/src/schema/sections/permissions/index.js @@ -251,4 +251,32 @@ export const permissionsSchemas = { ], }, + /** + * Download link configuration + */ + download: { + key: 'permissions.download', + title: '下载配置', + icon: 'fa fas fa-download', + description: '下载链接别名、默认使用次数与有效期限规则。', + defaults: { + url_alias: 'download', + default_max_uses: 1, + ttl_minutes: 10, + }, + groups: [ + { + key: 'basic', + title: '链接规则', + icon: 'fa fas fa-link', + description: '文件完整送达才计次(HEAD 探测、传输中断不计次);到期或耗尽后访问旧地址返回 410,需重新获取,不会自动续签。', + fields: [ + { key: 'url_alias', label: '下载链接 URL 别名', type: 'input', placeholder: 'download', required: true, help: '仅允许英文字母、数字、连字符和下划线;禁止中文、空格和 / \\ ? # & 等特殊符号。修改后旧别名路由立即失效。' }, + { key: 'default_max_uses', label: '下载链接默认使用次数', type: 'number', min: 1, max: 100, help: '单个链接可完成下载的次数;耗尽后链接返回 410,需重新获取。' }, + { key: 'ttl_minutes', label: '下载链接默认有效期限(分钟)', type: 'number', min: 1, max: 1440, help: '链接签发后超过该时长即失效并返回 410,不自动续期。' }, + ], + }, + ], + }, + }; diff --git a/src/Admin/Schema/SchemaPermissions.php b/src/Admin/Schema/SchemaPermissions.php index cf8927a9..5f6bd149 100644 --- a/src/Admin/Schema/SchemaPermissions.php +++ b/src/Admin/Schema/SchemaPermissions.php @@ -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' => '评论审核', diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php index 09586a7e..b7c3b7cb 100644 --- a/src/Admin/SchemaProvider.php +++ b/src/Admin/SchemaProvider.php @@ -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); diff --git a/src/Content/Download.php b/src/Content/Download.php index f21915e5..2f99e604 100644 --- a/src/Content/Download.php +++ b/src/Content/Download.php @@ -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 { diff --git a/src/Settings/Settings.php b/src/Settings/Settings.php index c559fc14..510ebd6e 100644 --- a/src/Settings/Settings.php +++ b/src/Settings/Settings.php @@ -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',