进行了一些优化
This commit is contained in:
@@ -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 对比环境)。
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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,不自动续期。' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
@@ -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' => '评论审核',
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user