From 4aec0b4fcefc20fea7c3f18b804dbd976b36291d Mon Sep 17 00:00:00 2001 From: root Date: Tue, 2 Jun 2026 00:32:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=9F=E5=90=8D=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/admin/command/Install/domain.sql | 26 ++ .../admin/command/Install/install.lock | 0 application/admin/controller/Domain.php | 247 +++++++++++++ application/admin/lang/zh-cn/domain.php | 36 ++ application/admin/model/Domain.php | 189 ++++++++++ application/admin/validate/Domain.php | 39 +++ application/admin/view/domain/add.html | 29 ++ application/admin/view/domain/detail.html | 62 ++++ application/admin/view/domain/index.html | 20 ++ .../common/service/CloudflareService.php | 331 ++++++++++++++++++ .../service/DomainDetectRateLimitService.php | 67 ++++ application/extra/cloudflare.php | 12 + application/extra/site.php | 14 +- deploy-domain-detect-rate-limit.sh | 14 + deploy-server-ip-dns.sh | 11 + .../application/admin/controller/Domain.php | 247 +++++++++++++ .../common/service/CloudflareService.php | 331 ++++++++++++++++++ .../service/DomainDetectRateLimitService.php | 67 ++++ patches/application/extra/cloudflare.php | 12 + public/assets/js/backend/domain.js | 132 +++++++ 20 files changed, 1879 insertions(+), 7 deletions(-) create mode 100755 application/admin/command/Install/domain.sql mode change 100644 => 100755 application/admin/command/Install/install.lock create mode 100755 application/admin/controller/Domain.php create mode 100755 application/admin/lang/zh-cn/domain.php create mode 100755 application/admin/model/Domain.php create mode 100755 application/admin/validate/Domain.php create mode 100755 application/admin/view/domain/add.html create mode 100755 application/admin/view/domain/detail.html create mode 100755 application/admin/view/domain/index.html create mode 100755 application/common/service/CloudflareService.php create mode 100644 application/common/service/DomainDetectRateLimitService.php create mode 100755 application/extra/cloudflare.php create mode 100644 deploy-domain-detect-rate-limit.sh create mode 100755 deploy-server-ip-dns.sh create mode 100644 patches/application/admin/controller/Domain.php create mode 100644 patches/application/common/service/CloudflareService.php create mode 100644 patches/application/common/service/DomainDetectRateLimitService.php create mode 100644 patches/application/extra/cloudflare.php create mode 100755 public/assets/js/backend/domain.js diff --git a/application/admin/command/Install/domain.sql b/application/admin/command/Install/domain.sql new file mode 100755 index 0000000..2520891 --- /dev/null +++ b/application/admin/command/Install/domain.sql @@ -0,0 +1,26 @@ +-- 域名管理模块表结构 +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS `fa_domain`; +CREATE TABLE `fa_domain` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID', + `domain` varchar(255) NOT NULL DEFAULT '' COMMENT '根域名', + `full_url` varchar(512) NOT NULL DEFAULT '' COMMENT '完整链接', + `zone_id` varchar(64) NOT NULL DEFAULT '' COMMENT 'Cloudflare Zone ID', + `nameservers` text COMMENT 'CF分配的NS(JSON)', + `zone_status` enum('pending','active','failed') NOT NULL DEFAULT 'pending' COMMENT 'Zone状态', + `ns_status` enum('pending','verified','failed') NOT NULL DEFAULT 'pending' COMMENT 'NS状态', + `dns_status` enum('pending','created','failed') NOT NULL DEFAULT 'pending' COMMENT 'DNS状态', + `check_time` bigint(16) DEFAULT NULL COMMENT '检测时间', + `check_result` varchar(2000) DEFAULT '' COMMENT '最近检测结果', + `createtime` bigint(16) DEFAULT NULL COMMENT '创建时间', + `updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `domain` (`domain`), + KEY `admin_id` (`admin_id`), + KEY `zone_status` (`zone_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='域名管理表'; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/application/admin/command/Install/install.lock b/application/admin/command/Install/install.lock old mode 100644 new mode 100755 diff --git a/application/admin/controller/Domain.php b/application/admin/controller/Domain.php new file mode 100755 index 0000000..5a6742f --- /dev/null +++ b/application/admin/controller/Domain.php @@ -0,0 +1,247 @@ +model = new DomainModel(); + $this->view->assign('zoneStatusList', $this->model->getZoneStatusList()); + $this->view->assign('nsStatusList', $this->model->getNsStatusList()); + $this->view->assign('dnsStatusList', $this->model->getDnsStatusList()); + $this->assignconfig('zoneStatusList', $this->model->getZoneStatusList()); + $this->assignconfig('nsStatusList', $this->model->getNsStatusList()); + $this->assignconfig('dnsStatusList', $this->model->getDnsStatusList()); + } + + /** + * 禁止编辑 + */ + public function edit($ids = null) + { + $this->error(__('Domain cannot be edited after creation')); + } + + /** + * 禁止批量操作 + */ + public function multi($ids = '') + { + $this->error(__('Invalid parameters')); + } + + /** + * 添加域名并创建 Cloudflare Zone + */ + public function add() + { + if (false === $this->request->isPost()) { + return $this->view->fetch(); + } + + $params = $this->request->post('row/a', []); + if ($params === []) { + $this->error(__('Parameter %s can not be empty', '')); + } + + $params = $this->preExcludeFields($params); + $domain = DomainModel::normalizeDomain((string)($params['domain'] ?? '')); + $params['domain'] = $domain; + + if ($this->dataLimit && $this->dataLimitFieldAutoFill) { + $params[$this->dataLimitField] = $this->auth->id; + } + + $result = false; + Db::startTrans(); + try { + if ($this->modelValidate) { + $name = str_replace('\\model\\', '\\validate\\', get_class($this->model)); + $validate = $this->modelSceneValidate ? $name . '.add' : $name; + $this->model->validateFailException()->validate($validate, $params); + } + + if (DomainModel::where('domain', $domain)->find()) { + throw new ValidateException(__('Domain already exists')); + } + + $cloudflare = new CloudflareService(); + $zone = $cloudflare->createZone($domain); + + $params['full_url'] = 'https://' . $domain; + $params['zone_id'] = $zone['zone_id']; + $params['nameservers'] = $zone['name_servers']; + $params['zone_status'] = DomainModel::mapCloudflareZoneStatus($zone['status']); + $params['ns_status'] = 'pending'; + $params['dns_status'] = 'pending'; + $params['check_result'] = ''; + + $result = $this->model->allowField(true)->save($params); + Db::commit(); + } catch (ValidateException $e) { + Db::rollback(); + $this->error($e->getMessage()); + } catch (PDOException|Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + + if ($result === false) { + $this->error(__('No rows were inserted')); + } + + $this->success(__('Domain submitted, please check NS in detail page and update at registrar')); + } + + /** + * 域名详情 + */ + public function detail($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + if (!$this->checkDataLimit($row)) { + $this->error(__('You have no permission')); + } + + $data = $row->toArray(); + $data['nameservers_list'] = $row->getNameserversArray(); + $this->view->assign('row', $data); + return $this->view->fetch(); + } + + /** + * 检测域名状态 + */ + public function detect($ids = null) + { + if (!$this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + if (!$this->checkDataLimit($row)) { + $this->error(__('You have no permission')); + } + + try { + (new DomainDetectRateLimitService())->assertCanDetect((int)$this->auth->id, (int)$row['id']); + } catch (Exception $e) { + $this->error($e->getMessage()); + } + + try { + $cloudflare = new CloudflareService(); + $detect = $cloudflare->detectDomain($row->toArray()); + $row->save([ + 'zone_status' => $detect['zone_status'], + 'ns_status' => $detect['ns_status'], + 'dns_status' => $detect['dns_status'], + 'check_time' => time(), + 'check_result' => $detect['check_result'], + ]); + } catch (Exception $e) { + $this->error($e->getMessage()); + } + + $this->success(__('Detection completed'), null, $row->toArray()); + } + + /** + * 删除(需输入完整域名二次确认) + */ + public function del($ids = '') + { + if (!$this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + + $ids = $ids ?: $this->request->post('ids', ''); + $confirmDomain = trim((string)$this->request->post('confirm_domain', '')); + if ($ids === '' || $confirmDomain === '') { + $this->error(__('Please enter the full domain to confirm deletion')); + } + + $pk = $this->model->getPk(); + $list = $this->model->where($pk, 'in', $ids)->select(); + if (!$list || count($list) === 0) { + $this->error(__('No Results were found')); + } + + $count = 0; + Db::startTrans(); + try { + foreach ($list as $item) { + if (!$this->checkDataLimit($item)) { + throw new Exception(__('You have no permission')); + } + if (DomainModel::normalizeDomain($confirmDomain) !== DomainModel::normalizeDomain((string)$item['domain'])) { + throw new Exception(__('Domain confirmation does not match')); + } + + if (!empty($item['zone_id'])) { + $cloudflare = new CloudflareService(); + $cloudflare->deleteZone((string)$item['zone_id']); + } + + $count += $item->delete(); + } + Db::commit(); + } catch (PDOException|Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + + if ($count > 0) { + $this->success(); + } + $this->error(__('No rows were deleted')); + } + + /** + * @param DomainModel $row + */ + protected function checkDataLimit($row): bool + { + if (!$this->dataLimit) { + return true; + } + $adminIds = $this->getDataLimitAdminIds(); + if (!is_array($adminIds)) { + return true; + } + return in_array((int)$row[$this->dataLimitField], $adminIds, true); + } +} diff --git a/application/admin/lang/zh-cn/domain.php b/application/admin/lang/zh-cn/domain.php new file mode 100755 index 0000000..cbfe454 --- /dev/null +++ b/application/admin/lang/zh-cn/domain.php @@ -0,0 +1,36 @@ + '域名', + 'Full_url' => '完整链接', + 'Zone_status' => 'Zone状态', + 'Ns_status' => 'NameServer状态', + 'Dns_status' => 'DNS状态', + 'Nameservers' => 'NameServer', + 'Check_time' => '检测时间', + 'Check_result' => '最近检测结果', + 'Createtime' => '创建时间', + 'Zone pending' => '待激活', + 'Zone active' => '已激活', + 'Zone failed' => '异常', + 'NS pending' => '待验证', + 'NS verified' => '已验证', + 'NS failed' => '验证失败', + 'DNS pending' => '待创建', + 'DNS created' => '已创建', + 'DNS failed' => '异常', + 'Detect' => '检测', + 'Detection completed' => '检测完成', + 'Domain input tips title' => '填写说明', + 'Domain input tips line1' => '请输入根域名,比如:nihao.com,不要填写 www,且不支持二级子域名。', + 'Domain input tips line2' => '不支持中国域名注册商注册的域名,比如阿里云、腾讯云等注册商;也不支持中国域名后缀,包括 cn、com.cn、net.cn、org.cn 等。', + 'Domain guide title' => '接入指引', + 'Domain guide content' => '提交后,请在详情中查看 Cloudflare 实际分配的 NS,并到域名注册商处修改。', + 'Domain submitted, please check NS in detail page and update at registrar' => '域名已提交,请在详情中查看 Cloudflare 分配的 NS,并到注册商处修改', + 'Domain cannot be edited after creation' => '域名创建后不可编辑', + 'Domain already exists' => '该域名已存在', + 'Please enter the full domain to confirm deletion' => '请输入完整域名以确认删除', + 'Domain confirmation does not match' => '输入的域名与记录不一致,删除已取消', + 'Delete domain confirm prompt' => '请输入完整域名以确认删除', + 'Delete domain confirm mismatch' => '域名输入不一致,请重新确认', +]; diff --git a/application/admin/model/Domain.php b/application/admin/model/Domain.php new file mode 100755 index 0000000..e9147f1 --- /dev/null +++ b/application/admin/model/Domain.php @@ -0,0 +1,189 @@ + __('Zone pending'), + 'active' => __('Zone active'), + 'failed' => __('Zone failed'), + ]; + } + + public function getNsStatusList(): array + { + return [ + 'pending' => __('NS pending'), + 'verified' => __('NS verified'), + 'failed' => __('NS failed'), + ]; + } + + public function getDnsStatusList(): array + { + return [ + 'pending' => __('DNS pending'), + 'created' => __('DNS created'), + 'failed' => __('DNS failed'), + ]; + } + + public function getZoneStatusTextAttr($value, $data): string + { + $value = $value ?: ($data['zone_status'] ?? ''); + $list = $this->getZoneStatusList(); + return $list[$value] ?? ''; + } + + public function getNsStatusTextAttr($value, $data): string + { + $value = $value ?: ($data['ns_status'] ?? ''); + $list = $this->getNsStatusList(); + return $list[$value] ?? ''; + } + + public function getDnsStatusTextAttr($value, $data): string + { + $value = $value ?: ($data['dns_status'] ?? ''); + $list = $this->getDnsStatusList(); + return $list[$value] ?? ''; + } + + public function setNameserversAttr($value): string + { + if (is_array($value)) { + return json_encode(array_values($value), JSON_UNESCAPED_UNICODE); + } + return (string)$value; + } + + /** + * @return array + */ + public function getNameserversArray(): array + { + $value = $this->getAttr('nameservers'); + if (is_array($value)) { + return $value; + } + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } + return []; + } + + public static function normalizeDomain(string $domain): string + { + return strtolower(trim($domain)); + } + + public static function isValidRootDomain(string $domain): bool + { + $domain = self::normalizeDomain($domain); + if ($domain === '' || strlen($domain) > 253) { + return false; + } + if (strpos($domain, 'www.') === 0) { + return false; + } + if (!preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i', $domain)) { + return false; + } + if (self::isChinaSuffix($domain)) { + return false; + } + $label = self::getDomainLabelWithoutSuffix($domain); + if ($label === '' || strpos($label, '.') !== false) { + return false; + } + return true; + } + + public static function isChinaSuffix(string $domain): bool + { + $domain = self::normalizeDomain($domain); + foreach (self::CHINA_SUFFIX_BLACKLIST as $suffix) { + if ($domain === $suffix || self::endsWith($domain, '.' . $suffix)) { + return true; + } + } + return false; + } + + public static function getMatchedSuffix(string $domain): string + { + $domain = self::normalizeDomain($domain); + $suffixes = array_merge(self::CHINA_SUFFIX_BLACKLIST, ['co.uk', 'com', 'net', 'org', 'io', 'co', 'me', 'info', 'biz']); + usort($suffixes, function ($a, $b) { + return strlen($b) - strlen($a); + }); + foreach ($suffixes as $suffix) { + if ($domain === $suffix || self::endsWith($domain, '.' . $suffix)) { + return $suffix; + } + } + return ''; + } + + public static function getDomainLabelWithoutSuffix(string $domain): string + { + $domain = self::normalizeDomain($domain); + $suffix = self::getMatchedSuffix($domain); + if ($suffix === '') { + $pos = strrpos($domain, '.'); + return $pos === false ? $domain : substr($domain, 0, $pos); + } + $suffixWithDot = '.' . $suffix; + if (self::endsWith($domain, $suffixWithDot)) { + return substr($domain, 0, -strlen($suffixWithDot)); + } + return $domain; + } + + public static function mapCloudflareZoneStatus(string $status): string + { + if ($status === 'active') { + return 'active'; + } + if (in_array($status, ['pending', 'initializing'], true)) { + return 'pending'; + } + return 'failed'; + } + + private static function endsWith(string $haystack, string $needle): bool + { + if ($needle === '') { + return true; + } + return substr($haystack, -strlen($needle)) === $needle; + } +} diff --git a/application/admin/validate/Domain.php b/application/admin/validate/Domain.php new file mode 100755 index 0000000..2ad211c --- /dev/null +++ b/application/admin/validate/Domain.php @@ -0,0 +1,39 @@ + 'require|checkDomain', + ]; + + protected $message = [ + 'domain.require' => '请输入根域名', + ]; + + protected $scene = [ + 'add' => ['domain'], + ]; + + /** + * @param string $value + * @return bool|string + */ + protected function checkDomain($value) + { + $domain = DomainModel::normalizeDomain((string)$value); + if (!DomainModel::isValidRootDomain($domain)) { + return '请输入合法根域名,不要填写 www 或二级子域名,且不支持中国域名后缀'; + } + return true; + } +} diff --git a/application/admin/view/domain/add.html b/application/admin/view/domain/add.html new file mode 100755 index 0000000..a4e9faf --- /dev/null +++ b/application/admin/view/domain/add.html @@ -0,0 +1,29 @@ +
+ {:token()} + +
+

{:__('Domain input tips title')}

+

{:__('Domain input tips line1')}

+

{:__('Domain input tips line2')}

+
+ +
+

{:__('Domain guide title')}

+

{:__('Domain guide content')}

+
+ +
+ +
+ +
+
+ + +
diff --git a/application/admin/view/domain/detail.html b/application/admin/view/domain/detail.html new file mode 100755 index 0000000..2f3fe41 --- /dev/null +++ b/application/admin/view/domain/detail.html @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{:__('Domain')}{$row.domain|htmlentities}
{:__('Full_url')}{$row.full_url|htmlentities}
{:__('Zone_status')}{$row.zone_status_text|htmlentities}
{:__('Ns_status')}{$row.ns_status_text|htmlentities}
{:__('Dns_status')}{$row.dns_status_text|htmlentities}
{:__('Nameservers')} + {if !empty($row.nameservers_list)} +
    + {volist name="row.nameservers_list" id="ns"} +
  • {$ns|htmlentities}
  • + {/volist} +
+

{:__('Domain guide content')}

+ {else/} + {:__('None')} + {/if} +
{:__('Check_time')}{if $row.check_time}{$row.check_time|datetime}{else/}{:__('None')}{/if}
{:__('Check_result')}{$row.check_result|default=''|htmlentities}
{:__('Createtime')}{$row.createtime|datetime}
+ diff --git a/application/admin/view/domain/index.html b/application/admin/view/domain/index.html new file mode 100755 index 0000000..dc5843b --- /dev/null +++ b/application/admin/view/domain/index.html @@ -0,0 +1,20 @@ +
+ {:build_heading()} + +
+
+
+
+
+ {:build_toolbar('refresh,add')} +
+ +
+
+
+
+
+
diff --git a/application/common/service/CloudflareService.php b/application/common/service/CloudflareService.php new file mode 100755 index 0000000..51fa958 --- /dev/null +++ b/application/common/service/CloudflareService.php @@ -0,0 +1,331 @@ +apiToken = (string)($config['api_token'] ?? ''); + $this->accountId = (string)($config['account_id'] ?? ''); + $this->serverIp = trim((string)($config['server_ip'] ?? '')); + if ($this->apiToken === '' || $this->accountId === '') { + throw new Exception('Cloudflare API 凭证未配置,请在 .env 中设置 cloudflare.api_token 与 cloudflare.account_id'); + } + } + + /** + * 创建 Cloudflare Zone + * + * @param string $domain 根域名 + * @return array{zone_id: string, name_servers: array, status: string} + * @throws Exception + */ + public function createZone(string $domain): array + { + $body = [ + 'name' => $domain, + 'account' => ['id' => $this->accountId], + 'type' => 'full', + ]; + $response = $this->request('POST', '/zones', $body); + $result = $response['result'] ?? []; + return [ + 'zone_id' => (string)($result['id'] ?? ''), + 'name_servers' => (array)($result['name_servers'] ?? []), + 'status' => (string)($result['status'] ?? 'pending'), + ]; + } + + /** + * @throws Exception + */ + public function getZone(string $zoneId): array + { + $response = $this->request('GET', '/zones/' . $zoneId); + return (array)($response['result'] ?? []); + } + + /** + * @throws Exception + */ + public function deleteZone(string $zoneId): bool + { + $this->request('DELETE', '/zones/' . $zoneId); + return true; + } + + /** + * @throws Exception + */ + public function listDnsRecords(string $zoneId): array + { + $response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], ['per_page' => 100]); + return (array)($response['result'] ?? []); + } + + /** + * 聚合检测三项状态 + * Zone/NS 检测通过后,自动将根域名 A 记录指向 server_ip 并校验 + * + * @param array $row 域名记录 + * @return array{zone_status: string, ns_status: string, dns_status: string, check_result: string} + */ + public function detectDomain(array $row): array + { + $domain = strtolower(trim((string)($row['domain'] ?? ''))); + $zoneId = (string)($row['zone_id'] ?? ''); + $expectedNs = $this->decodeNameservers($row['nameservers'] ?? ''); + + $zoneStatus = 'failed'; + $nsStatus = 'pending'; + $dnsStatus = 'pending'; + $messages = []; + + try { + $zone = $this->getZone($zoneId); + $cfStatus = (string)($zone['status'] ?? ''); + if ($cfStatus === 'active') { + $zoneStatus = 'active'; + $messages[] = 'Zone:已激活'; + } elseif (in_array($cfStatus, ['pending', 'initializing'], true)) { + $zoneStatus = 'pending'; + $messages[] = 'Zone:待激活'; + } else { + $zoneStatus = 'failed'; + $messages[] = 'Zone:异常(' . $cfStatus . ')'; + } + } catch (\Throwable $e) { + $zoneStatus = 'failed'; + $messages[] = 'Zone:检测失败(' . $e->getMessage() . ')'; + } + + $currentNs = $this->getDomainNameservers($domain); + if ($currentNs === []) { + $nsStatus = 'pending'; + $messages[] = 'NS:待验证(未查询到NS记录)'; + } elseif ($this->compareNameservers($currentNs, $expectedNs)) { + $nsStatus = 'verified'; + $messages[] = 'NS:已验证'; + } else { + $nsStatus = 'pending'; + $messages[] = 'NS:待验证(当前NS与Cloudflare不一致)'; + } + + if ($zoneStatus !== 'active') { + $dnsStatus = 'pending'; + $messages[] = 'DNS:待创建(Zone未激活)'; + } elseif ($nsStatus !== 'verified') { + $dnsStatus = 'pending'; + $messages[] = 'DNS:待创建(NS未验证)'; + } elseif ($this->serverIp === '') { + $dnsStatus = 'failed'; + $messages[] = 'DNS:未配置server_ip'; + } elseif (!filter_var($this->serverIp, FILTER_VALIDATE_IP)) { + $dnsStatus = 'failed'; + $messages[] = 'DNS:server_ip格式无效'; + } else { + try { + if ($this->hasRootCnameConflict($zoneId, $domain)) { + $dnsStatus = 'failed'; + $messages[] = 'DNS:根域名存在CNAME记录,无法创建A记录'; + } else { + $this->upsertRootARecord($zoneId, $domain, $this->serverIp); + if ($this->verifyRootARecord($zoneId, $domain, $this->serverIp)) { + $dnsStatus = 'created'; + $messages[] = 'DNS:已创建(A=' . $this->serverIp . ')'; + } else { + $dnsStatus = 'pending'; + $messages[] = 'DNS:待创建(A记录未指向' . $this->serverIp . ')'; + } + } + } catch (\Throwable $e) { + $dnsStatus = 'failed'; + $messages[] = 'DNS:操作失败(' . $e->getMessage() . ')'; + } + } + + return [ + 'zone_status' => $zoneStatus, + 'ns_status' => $nsStatus, + 'dns_status' => $dnsStatus, + 'check_result' => implode('; ', $messages), + ]; + } + + /** + * @throws Exception + */ + private function listRootARecords(string $zoneId, string $domain): array + { + $response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [ + 'type' => 'A', + 'name' => $domain, + ]); + return (array)($response['result'] ?? []); + } + + /** + * @throws Exception + */ + private function hasRootCnameConflict(string $zoneId, string $domain): bool + { + $response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [ + 'type' => 'CNAME', + 'name' => $domain, + ]); + return count((array)($response['result'] ?? [])) > 0; + } + + /** + * @throws Exception + */ + private function upsertRootARecord(string $zoneId, string $domain, string $ip): void + { + $records = $this->listRootARecords($zoneId, $domain); + if ($records === []) { + $this->request('POST', '/zones/' . $zoneId . '/dns_records', [ + 'type' => 'A', + 'name' => $domain, + 'content' => $ip, + 'ttl' => 1, + 'proxied' => false, + ]); + return; + } + + foreach ($records as $record) { + $recordId = (string)($record['id'] ?? ''); + $content = (string)($record['content'] ?? ''); + if ($recordId === '' || $content === $ip) { + continue; + } + $this->request('PATCH', '/zones/' . $zoneId . '/dns_records/' . $recordId, [ + 'content' => $ip, + 'ttl' => 1, + 'proxied' => false, + ]); + } + } + + /** + * @throws Exception + */ + private function verifyRootARecord(string $zoneId, string $domain, string $ip): bool + { + $records = $this->listRootARecords($zoneId, $domain); + foreach ($records as $record) { + if ((string)($record['content'] ?? '') === $ip) { + return true; + } + } + return false; + } + + /** + * @throws Exception + */ + private function request(string $method, string $path, array $body = [], array $query = []): array + { + $url = self::API_BASE . $path; + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + + $options = [ + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $this->apiToken, + 'Content-Type: application/json', + ], + ]; + + $payload = $body !== [] ? json_encode($body, JSON_UNESCAPED_UNICODE) : ''; + + if (strtoupper($method) === 'GET') { + $result = Http::sendRequest($url, [], 'GET', $options); + } elseif (strtoupper($method) === 'DELETE') { + $result = Http::sendRequest($url, $payload, 'DELETE', $options); + } else { + $result = Http::sendRequest($url, $payload, strtoupper($method), $options); + } + + if (!$result['ret']) { + throw new Exception('Cloudflare API 请求失败: ' . ($result['msg'] ?? '未知错误')); + } + + $decoded = json_decode((string)$result['msg'], true); + if (!is_array($decoded)) { + throw new Exception('Cloudflare API 响应解析失败'); + } + + if (empty($decoded['success'])) { + $errors = $decoded['errors'] ?? []; + $message = 'Cloudflare API 错误'; + if (is_array($errors) && isset($errors[0]['message'])) { + $message = (string)$errors[0]['message']; + } + throw new Exception($message); + } + + return $decoded; + } + + private function decodeNameservers($nameservers): array + { + if (is_array($nameservers)) { + return $nameservers; + } + if (is_string($nameservers) && $nameservers !== '') { + $decoded = json_decode($nameservers, true); + return is_array($decoded) ? $decoded : []; + } + return []; + } + + private function getDomainNameservers(string $domain): array + { + $records = @dns_get_record($domain, DNS_NS); + if (!is_array($records)) { + return []; + } + $list = []; + foreach ($records as $record) { + if (!empty($record['target'])) { + $list[] = $this->normalizeNs((string)$record['target']); + } + } + return array_values(array_unique($list)); + } + + private function compareNameservers(array $current, array $expected): bool + { + if ($expected === []) { + return false; + } + $currentNorm = array_map([$this, 'normalizeNs'], $current); + $expectedNorm = array_map([$this, 'normalizeNs'], $expected); + sort($currentNorm); + sort($expectedNorm); + return $currentNorm === $expectedNorm; + } + + private function normalizeNs(string $ns): string + { + return rtrim(strtolower(trim($ns)), '.'); + } +} diff --git a/application/common/service/DomainDetectRateLimitService.php b/application/common/service/DomainDetectRateLimitService.php new file mode 100644 index 0000000..822da69 --- /dev/null +++ b/application/common/service/DomainDetectRateLimitService.php @@ -0,0 +1,67 @@ + $now) { + $wait = $cooldownUntil - $now; + throw new Exception(sprintf('检测过于频繁,请 %d 秒后再试', $wait)); + } + + $timestamps = Cache::get($attemptsKey); + if (!is_array($timestamps)) { + $timestamps = []; + } + + $timestamps = array_values(array_filter($timestamps, static function ($ts) use ($now): bool { + return is_int($ts) && ($now - $ts) < self::WINDOW_SECONDS; + })); + + if (count($timestamps) >= self::MAX_ATTEMPTS) { + Cache::set($cooldownKey, $now + self::COOLDOWN_SECONDS, self::COOLDOWN_SECONDS); + Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS); + throw new Exception(sprintf( + '一分钟最多检测 %d 次,请 %d 秒后再试', + self::MAX_ATTEMPTS, + self::COOLDOWN_SECONDS + )); + } + + $timestamps[] = $now; + Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS); + } +} diff --git a/application/extra/cloudflare.php b/application/extra/cloudflare.php new file mode 100755 index 0000000..367a245 --- /dev/null +++ b/application/extra/cloudflare.php @@ -0,0 +1,12 @@ + Env::get('cloudflare.api_token', ''), + 'account_id' => Env::get('cloudflare.account_id', ''), + 'server_ip' => Env::get('cloudflare.server_ip', ''), +]; diff --git a/application/extra/site.php b/application/extra/site.php index 3213ccb..4ed3f15 100755 --- a/application/extra/site.php +++ b/application/extra/site.php @@ -1,7 +1,7 @@ 'LINK S 多功能打粉平台', + 'name' => 'LINK-S 多功能打粉', 'beian' => '', 'cdnurl' => '', 'version' => '1.0.1', @@ -28,12 +28,6 @@ return array ( 'user' => 'User', 'example' => 'Example', ), - 'attachmentcategory' => - array ( - 'category1' => 'Category1', - 'category2' => 'Category2', - 'custom' => 'Custom', - ), 'mail_type' => '1', 'mail_smtp_host' => 'smtp.qq.com', 'mail_smtp_port' => '465', @@ -41,4 +35,10 @@ return array ( 'mail_smtp_pass' => '', 'mail_verify_type' => '2', 'mail_from' => '', + 'attachmentcategory' => + array ( + 'category1' => 'Category1', + 'category2' => 'Category2', + 'custom' => 'Custom', + ), ); diff --git a/deploy-domain-detect-rate-limit.sh b/deploy-domain-detect-rate-limit.sh new file mode 100644 index 0000000..294fbe2 --- /dev/null +++ b/deploy-domain-detect-rate-limit.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# 部署域名检测频率限制(需 root) +set -e +BASE="$(cd "$(dirname "$0")" && pwd)" +PATCH="$BASE/patches" + +cp -f "$PATCH/application/common/service/DomainDetectRateLimitService.php" "$BASE/application/common/service/" +cp -f "$PATCH/application/admin/controller/Domain.php" "$BASE/application/admin/controller/" +chown www:www \ + "$BASE/application/common/service/DomainDetectRateLimitService.php" \ + "$BASE/application/admin/controller/Domain.php" +php -l "$BASE/application/common/service/DomainDetectRateLimitService.php" +php -l "$BASE/application/admin/controller/Domain.php" +echo "检测频率限制部署完成" diff --git a/deploy-server-ip-dns.sh b/deploy-server-ip-dns.sh new file mode 100755 index 0000000..0f7bbab --- /dev/null +++ b/deploy-server-ip-dns.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# 部署 server_ip DNS 自动配置逻辑(需 root) +set -e +BASE="$(cd "$(dirname "$0")" && pwd)" +PATCH="$BASE/patches" + +cp -f "$PATCH/application/extra/cloudflare.php" "$BASE/application/extra/cloudflare.php" +cp -f "$PATCH/application/common/service/CloudflareService.php" "$BASE/application/common/service/CloudflareService.php" +chown www:www "$BASE/application/extra/cloudflare.php" "$BASE/application/common/service/CloudflareService.php" +php -l "$BASE/application/common/service/CloudflareService.php" +echo "部署完成。请确认 .env 中 cloudflare.server_ip 已填写。" diff --git a/patches/application/admin/controller/Domain.php b/patches/application/admin/controller/Domain.php new file mode 100644 index 0000000..5a6742f --- /dev/null +++ b/patches/application/admin/controller/Domain.php @@ -0,0 +1,247 @@ +model = new DomainModel(); + $this->view->assign('zoneStatusList', $this->model->getZoneStatusList()); + $this->view->assign('nsStatusList', $this->model->getNsStatusList()); + $this->view->assign('dnsStatusList', $this->model->getDnsStatusList()); + $this->assignconfig('zoneStatusList', $this->model->getZoneStatusList()); + $this->assignconfig('nsStatusList', $this->model->getNsStatusList()); + $this->assignconfig('dnsStatusList', $this->model->getDnsStatusList()); + } + + /** + * 禁止编辑 + */ + public function edit($ids = null) + { + $this->error(__('Domain cannot be edited after creation')); + } + + /** + * 禁止批量操作 + */ + public function multi($ids = '') + { + $this->error(__('Invalid parameters')); + } + + /** + * 添加域名并创建 Cloudflare Zone + */ + public function add() + { + if (false === $this->request->isPost()) { + return $this->view->fetch(); + } + + $params = $this->request->post('row/a', []); + if ($params === []) { + $this->error(__('Parameter %s can not be empty', '')); + } + + $params = $this->preExcludeFields($params); + $domain = DomainModel::normalizeDomain((string)($params['domain'] ?? '')); + $params['domain'] = $domain; + + if ($this->dataLimit && $this->dataLimitFieldAutoFill) { + $params[$this->dataLimitField] = $this->auth->id; + } + + $result = false; + Db::startTrans(); + try { + if ($this->modelValidate) { + $name = str_replace('\\model\\', '\\validate\\', get_class($this->model)); + $validate = $this->modelSceneValidate ? $name . '.add' : $name; + $this->model->validateFailException()->validate($validate, $params); + } + + if (DomainModel::where('domain', $domain)->find()) { + throw new ValidateException(__('Domain already exists')); + } + + $cloudflare = new CloudflareService(); + $zone = $cloudflare->createZone($domain); + + $params['full_url'] = 'https://' . $domain; + $params['zone_id'] = $zone['zone_id']; + $params['nameservers'] = $zone['name_servers']; + $params['zone_status'] = DomainModel::mapCloudflareZoneStatus($zone['status']); + $params['ns_status'] = 'pending'; + $params['dns_status'] = 'pending'; + $params['check_result'] = ''; + + $result = $this->model->allowField(true)->save($params); + Db::commit(); + } catch (ValidateException $e) { + Db::rollback(); + $this->error($e->getMessage()); + } catch (PDOException|Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + + if ($result === false) { + $this->error(__('No rows were inserted')); + } + + $this->success(__('Domain submitted, please check NS in detail page and update at registrar')); + } + + /** + * 域名详情 + */ + public function detail($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + if (!$this->checkDataLimit($row)) { + $this->error(__('You have no permission')); + } + + $data = $row->toArray(); + $data['nameservers_list'] = $row->getNameserversArray(); + $this->view->assign('row', $data); + return $this->view->fetch(); + } + + /** + * 检测域名状态 + */ + public function detect($ids = null) + { + if (!$this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + if (!$this->checkDataLimit($row)) { + $this->error(__('You have no permission')); + } + + try { + (new DomainDetectRateLimitService())->assertCanDetect((int)$this->auth->id, (int)$row['id']); + } catch (Exception $e) { + $this->error($e->getMessage()); + } + + try { + $cloudflare = new CloudflareService(); + $detect = $cloudflare->detectDomain($row->toArray()); + $row->save([ + 'zone_status' => $detect['zone_status'], + 'ns_status' => $detect['ns_status'], + 'dns_status' => $detect['dns_status'], + 'check_time' => time(), + 'check_result' => $detect['check_result'], + ]); + } catch (Exception $e) { + $this->error($e->getMessage()); + } + + $this->success(__('Detection completed'), null, $row->toArray()); + } + + /** + * 删除(需输入完整域名二次确认) + */ + public function del($ids = '') + { + if (!$this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + + $ids = $ids ?: $this->request->post('ids', ''); + $confirmDomain = trim((string)$this->request->post('confirm_domain', '')); + if ($ids === '' || $confirmDomain === '') { + $this->error(__('Please enter the full domain to confirm deletion')); + } + + $pk = $this->model->getPk(); + $list = $this->model->where($pk, 'in', $ids)->select(); + if (!$list || count($list) === 0) { + $this->error(__('No Results were found')); + } + + $count = 0; + Db::startTrans(); + try { + foreach ($list as $item) { + if (!$this->checkDataLimit($item)) { + throw new Exception(__('You have no permission')); + } + if (DomainModel::normalizeDomain($confirmDomain) !== DomainModel::normalizeDomain((string)$item['domain'])) { + throw new Exception(__('Domain confirmation does not match')); + } + + if (!empty($item['zone_id'])) { + $cloudflare = new CloudflareService(); + $cloudflare->deleteZone((string)$item['zone_id']); + } + + $count += $item->delete(); + } + Db::commit(); + } catch (PDOException|Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + + if ($count > 0) { + $this->success(); + } + $this->error(__('No rows were deleted')); + } + + /** + * @param DomainModel $row + */ + protected function checkDataLimit($row): bool + { + if (!$this->dataLimit) { + return true; + } + $adminIds = $this->getDataLimitAdminIds(); + if (!is_array($adminIds)) { + return true; + } + return in_array((int)$row[$this->dataLimitField], $adminIds, true); + } +} diff --git a/patches/application/common/service/CloudflareService.php b/patches/application/common/service/CloudflareService.php new file mode 100644 index 0000000..51fa958 --- /dev/null +++ b/patches/application/common/service/CloudflareService.php @@ -0,0 +1,331 @@ +apiToken = (string)($config['api_token'] ?? ''); + $this->accountId = (string)($config['account_id'] ?? ''); + $this->serverIp = trim((string)($config['server_ip'] ?? '')); + if ($this->apiToken === '' || $this->accountId === '') { + throw new Exception('Cloudflare API 凭证未配置,请在 .env 中设置 cloudflare.api_token 与 cloudflare.account_id'); + } + } + + /** + * 创建 Cloudflare Zone + * + * @param string $domain 根域名 + * @return array{zone_id: string, name_servers: array, status: string} + * @throws Exception + */ + public function createZone(string $domain): array + { + $body = [ + 'name' => $domain, + 'account' => ['id' => $this->accountId], + 'type' => 'full', + ]; + $response = $this->request('POST', '/zones', $body); + $result = $response['result'] ?? []; + return [ + 'zone_id' => (string)($result['id'] ?? ''), + 'name_servers' => (array)($result['name_servers'] ?? []), + 'status' => (string)($result['status'] ?? 'pending'), + ]; + } + + /** + * @throws Exception + */ + public function getZone(string $zoneId): array + { + $response = $this->request('GET', '/zones/' . $zoneId); + return (array)($response['result'] ?? []); + } + + /** + * @throws Exception + */ + public function deleteZone(string $zoneId): bool + { + $this->request('DELETE', '/zones/' . $zoneId); + return true; + } + + /** + * @throws Exception + */ + public function listDnsRecords(string $zoneId): array + { + $response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], ['per_page' => 100]); + return (array)($response['result'] ?? []); + } + + /** + * 聚合检测三项状态 + * Zone/NS 检测通过后,自动将根域名 A 记录指向 server_ip 并校验 + * + * @param array $row 域名记录 + * @return array{zone_status: string, ns_status: string, dns_status: string, check_result: string} + */ + public function detectDomain(array $row): array + { + $domain = strtolower(trim((string)($row['domain'] ?? ''))); + $zoneId = (string)($row['zone_id'] ?? ''); + $expectedNs = $this->decodeNameservers($row['nameservers'] ?? ''); + + $zoneStatus = 'failed'; + $nsStatus = 'pending'; + $dnsStatus = 'pending'; + $messages = []; + + try { + $zone = $this->getZone($zoneId); + $cfStatus = (string)($zone['status'] ?? ''); + if ($cfStatus === 'active') { + $zoneStatus = 'active'; + $messages[] = 'Zone:已激活'; + } elseif (in_array($cfStatus, ['pending', 'initializing'], true)) { + $zoneStatus = 'pending'; + $messages[] = 'Zone:待激活'; + } else { + $zoneStatus = 'failed'; + $messages[] = 'Zone:异常(' . $cfStatus . ')'; + } + } catch (\Throwable $e) { + $zoneStatus = 'failed'; + $messages[] = 'Zone:检测失败(' . $e->getMessage() . ')'; + } + + $currentNs = $this->getDomainNameservers($domain); + if ($currentNs === []) { + $nsStatus = 'pending'; + $messages[] = 'NS:待验证(未查询到NS记录)'; + } elseif ($this->compareNameservers($currentNs, $expectedNs)) { + $nsStatus = 'verified'; + $messages[] = 'NS:已验证'; + } else { + $nsStatus = 'pending'; + $messages[] = 'NS:待验证(当前NS与Cloudflare不一致)'; + } + + if ($zoneStatus !== 'active') { + $dnsStatus = 'pending'; + $messages[] = 'DNS:待创建(Zone未激活)'; + } elseif ($nsStatus !== 'verified') { + $dnsStatus = 'pending'; + $messages[] = 'DNS:待创建(NS未验证)'; + } elseif ($this->serverIp === '') { + $dnsStatus = 'failed'; + $messages[] = 'DNS:未配置server_ip'; + } elseif (!filter_var($this->serverIp, FILTER_VALIDATE_IP)) { + $dnsStatus = 'failed'; + $messages[] = 'DNS:server_ip格式无效'; + } else { + try { + if ($this->hasRootCnameConflict($zoneId, $domain)) { + $dnsStatus = 'failed'; + $messages[] = 'DNS:根域名存在CNAME记录,无法创建A记录'; + } else { + $this->upsertRootARecord($zoneId, $domain, $this->serverIp); + if ($this->verifyRootARecord($zoneId, $domain, $this->serverIp)) { + $dnsStatus = 'created'; + $messages[] = 'DNS:已创建(A=' . $this->serverIp . ')'; + } else { + $dnsStatus = 'pending'; + $messages[] = 'DNS:待创建(A记录未指向' . $this->serverIp . ')'; + } + } + } catch (\Throwable $e) { + $dnsStatus = 'failed'; + $messages[] = 'DNS:操作失败(' . $e->getMessage() . ')'; + } + } + + return [ + 'zone_status' => $zoneStatus, + 'ns_status' => $nsStatus, + 'dns_status' => $dnsStatus, + 'check_result' => implode('; ', $messages), + ]; + } + + /** + * @throws Exception + */ + private function listRootARecords(string $zoneId, string $domain): array + { + $response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [ + 'type' => 'A', + 'name' => $domain, + ]); + return (array)($response['result'] ?? []); + } + + /** + * @throws Exception + */ + private function hasRootCnameConflict(string $zoneId, string $domain): bool + { + $response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [ + 'type' => 'CNAME', + 'name' => $domain, + ]); + return count((array)($response['result'] ?? [])) > 0; + } + + /** + * @throws Exception + */ + private function upsertRootARecord(string $zoneId, string $domain, string $ip): void + { + $records = $this->listRootARecords($zoneId, $domain); + if ($records === []) { + $this->request('POST', '/zones/' . $zoneId . '/dns_records', [ + 'type' => 'A', + 'name' => $domain, + 'content' => $ip, + 'ttl' => 1, + 'proxied' => false, + ]); + return; + } + + foreach ($records as $record) { + $recordId = (string)($record['id'] ?? ''); + $content = (string)($record['content'] ?? ''); + if ($recordId === '' || $content === $ip) { + continue; + } + $this->request('PATCH', '/zones/' . $zoneId . '/dns_records/' . $recordId, [ + 'content' => $ip, + 'ttl' => 1, + 'proxied' => false, + ]); + } + } + + /** + * @throws Exception + */ + private function verifyRootARecord(string $zoneId, string $domain, string $ip): bool + { + $records = $this->listRootARecords($zoneId, $domain); + foreach ($records as $record) { + if ((string)($record['content'] ?? '') === $ip) { + return true; + } + } + return false; + } + + /** + * @throws Exception + */ + private function request(string $method, string $path, array $body = [], array $query = []): array + { + $url = self::API_BASE . $path; + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + + $options = [ + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $this->apiToken, + 'Content-Type: application/json', + ], + ]; + + $payload = $body !== [] ? json_encode($body, JSON_UNESCAPED_UNICODE) : ''; + + if (strtoupper($method) === 'GET') { + $result = Http::sendRequest($url, [], 'GET', $options); + } elseif (strtoupper($method) === 'DELETE') { + $result = Http::sendRequest($url, $payload, 'DELETE', $options); + } else { + $result = Http::sendRequest($url, $payload, strtoupper($method), $options); + } + + if (!$result['ret']) { + throw new Exception('Cloudflare API 请求失败: ' . ($result['msg'] ?? '未知错误')); + } + + $decoded = json_decode((string)$result['msg'], true); + if (!is_array($decoded)) { + throw new Exception('Cloudflare API 响应解析失败'); + } + + if (empty($decoded['success'])) { + $errors = $decoded['errors'] ?? []; + $message = 'Cloudflare API 错误'; + if (is_array($errors) && isset($errors[0]['message'])) { + $message = (string)$errors[0]['message']; + } + throw new Exception($message); + } + + return $decoded; + } + + private function decodeNameservers($nameservers): array + { + if (is_array($nameservers)) { + return $nameservers; + } + if (is_string($nameservers) && $nameservers !== '') { + $decoded = json_decode($nameservers, true); + return is_array($decoded) ? $decoded : []; + } + return []; + } + + private function getDomainNameservers(string $domain): array + { + $records = @dns_get_record($domain, DNS_NS); + if (!is_array($records)) { + return []; + } + $list = []; + foreach ($records as $record) { + if (!empty($record['target'])) { + $list[] = $this->normalizeNs((string)$record['target']); + } + } + return array_values(array_unique($list)); + } + + private function compareNameservers(array $current, array $expected): bool + { + if ($expected === []) { + return false; + } + $currentNorm = array_map([$this, 'normalizeNs'], $current); + $expectedNorm = array_map([$this, 'normalizeNs'], $expected); + sort($currentNorm); + sort($expectedNorm); + return $currentNorm === $expectedNorm; + } + + private function normalizeNs(string $ns): string + { + return rtrim(strtolower(trim($ns)), '.'); + } +} diff --git a/patches/application/common/service/DomainDetectRateLimitService.php b/patches/application/common/service/DomainDetectRateLimitService.php new file mode 100644 index 0000000..822da69 --- /dev/null +++ b/patches/application/common/service/DomainDetectRateLimitService.php @@ -0,0 +1,67 @@ + $now) { + $wait = $cooldownUntil - $now; + throw new Exception(sprintf('检测过于频繁,请 %d 秒后再试', $wait)); + } + + $timestamps = Cache::get($attemptsKey); + if (!is_array($timestamps)) { + $timestamps = []; + } + + $timestamps = array_values(array_filter($timestamps, static function ($ts) use ($now): bool { + return is_int($ts) && ($now - $ts) < self::WINDOW_SECONDS; + })); + + if (count($timestamps) >= self::MAX_ATTEMPTS) { + Cache::set($cooldownKey, $now + self::COOLDOWN_SECONDS, self::COOLDOWN_SECONDS); + Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS); + throw new Exception(sprintf( + '一分钟最多检测 %d 次,请 %d 秒后再试', + self::MAX_ATTEMPTS, + self::COOLDOWN_SECONDS + )); + } + + $timestamps[] = $now; + Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS); + } +} diff --git a/patches/application/extra/cloudflare.php b/patches/application/extra/cloudflare.php new file mode 100644 index 0000000..367a245 --- /dev/null +++ b/patches/application/extra/cloudflare.php @@ -0,0 +1,12 @@ + Env::get('cloudflare.api_token', ''), + 'account_id' => Env::get('cloudflare.account_id', ''), + 'server_ip' => Env::get('cloudflare.server_ip', ''), +]; diff --git a/public/assets/js/backend/domain.js b/public/assets/js/backend/domain.js new file mode 100755 index 0000000..5b31450 --- /dev/null +++ b/public/assets/js/backend/domain.js @@ -0,0 +1,132 @@ +define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + + var Controller = { + index: function () { + Table.api.init({ + extend: { + index_url: 'domain/index' + location.search, + add_url: 'domain/add', + edit_url: '', + del_url: '', + multi_url: '', + table: 'domain', + } + }); + + var table = $("#table"); + + table.bootstrapTable({ + url: $.fn.bootstrapTable.defaults.extend.index_url, + pk: 'id', + sortName: 'id', + sortOrder: 'desc', + fixedColumns: true, + fixedRightNumber: 1, + columns: [ + [ + {checkbox: true}, + {field: 'domain', title: __('Domain'), operate: 'LIKE'}, + {field: 'full_url', title: __('Full_url'), operate: false, formatter: Table.api.formatter.url}, + {field: 'zone_status', title: __('Zone_status'), searchList: Config.zoneStatusList, formatter: Table.api.formatter.status}, + {field: 'ns_status', title: __('Ns_status'), searchList: Config.nsStatusList, formatter: Table.api.formatter.status}, + {field: 'dns_status', title: __('Dns_status'), searchList: Config.dnsStatusList, formatter: Table.api.formatter.status}, + {field: 'check_time', title: __('Check_time'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true}, + {field: 'check_result', title: __('Check_result'), operate: 'LIKE', class: 'autocontent', formatter: Table.api.formatter.content}, + {field: 'createtime', title: __('Createtime'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true}, + { + field: 'operate', + title: __('Operate'), + table: table, + events: Table.api.events.operate, + buttons: [ + { + name: 'detail', + text: __('Detail'), + icon: 'fa fa-list', + classname: 'btn btn-info btn-xs btn-detail btn-dialog', + url: 'domain/detail' + }, + { + name: 'detect', + text: __('Detect'), + icon: 'fa fa-refresh', + classname: 'btn btn-success btn-xs btn-detect', + url: 'domain/detect', + refresh: true + }, + { + name: 'delconfirm', + text: __('Del'), + icon: 'fa fa-trash', + classname: 'btn btn-danger btn-xs btn-delconfirm' + } + ], + formatter: Table.api.formatter.operate + } + ] + ] + }); + + table.on('click', '.btn-detect', function (e) { + e.preventDefault(); + e.stopPropagation(); + var that = this; + var rowIndex = $(that).data('row-index'); + var row = Table.api.getrowbyindex(table, rowIndex); + if (!row) { + return false; + } + $(that).addClass('disabled'); + Fast.api.ajax({ + url: 'domain/detect/ids/' + row.id, + type: 'post' + }, function () { + table.bootstrapTable('refresh'); + return false; + }, function () { + $(that).removeClass('disabled'); + }); + }); + + table.on('click', '.btn-delconfirm', function (e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + var that = this; + var rowIndex = $(that).data('row-index'); + var row = Table.api.getrowbyindex(table, rowIndex); + if (!row) { + return false; + } + Layer.prompt({ + title: __('Delete domain confirm prompt'), + formType: 0, + value: '' + }, function (value, index) { + if ($.trim(value) !== row.domain) { + Toastr.error(__('Delete domain confirm mismatch')); + return false; + } + Layer.close(index); + Fast.api.ajax({ + url: 'domain/del', + data: {ids: row.id, confirm_domain: $.trim(value)} + }, function () { + table.bootstrapTable('refresh'); + }); + }); + }); + + Table.api.bindevent(table); + }, + add: function () { + Controller.api.bindevent(); + }, + api: { + bindevent: function () { + Form.api.bindevent($("form[role=form]")); + } + } + }; + return Controller; +});