码桶

发现社区成员的开源项目

geekssl /

wstats

公开
main
wstats/app/Controllers/SettingController.php
SettingController.php14.7 KB
<?php
/**
 * 系统设置控制器(仅系统管理员)。
 *
 * GET   /api/settings          读取全部设置(smtp_password 掩码回显)
 * PATCH /api/settings          保存设置(白名单键;密码留空/掩码则沿用旧值)
 * GET   /api/sdk-code          站点接入代码模板(任意登录用户;供 Sites 页渲染接入代码)
 * POST  /api/settings/email-test  发送测试邮件(body: {to})
 * GET   /api/settings/version  当前版本 + 远端在线更新检查
 * POST  /api/settings/update   一键升级到最新版本(下载地址由服务端决定,不接受客户端入参)
 * GET   /api/settings/ip-check 真实 IP 采集自检(列出各候选来源的实际取值,供管理员手动指定来源)
 */
declare(strict_types=1);

namespace Wstat\Controllers;

use Wstat\Http\Request;
use Wstat\Support\Auth;
use Wstat\Support\IpLocator;
use Wstat\Support\Mailer;
use Wstat\Support\Settings;
use Wstat\Support\Updater;
use Wstat\Support\Util;
use Wstat\Support\Verify;

class SettingController
{
    /** GET /api/settings */
    public function show(Request $req): void
    {
        Settings::requireAdmin($req);
        $all = Settings::all();
        if ((string) $all['smtp_password'] !== '') {
            $all['smtp_password'] = '******';
        }
        if ((string) $all['screen_password'] !== '') {
            $all['screen_password'] = '******';
        }
        // 附加运行时状态(非持久化设置):注册邮箱验证码依赖图形验证码 → 依赖 GD 扩展
        $all['captcha_ready'] = Verify::available();
        // 内置接入代码模板(前端「恢复默认」「预览」用;自定义内容存在 sdk_snippet)
        $all['sdk_snippet_default'] = Settings::SDK_SNIPPET_DEFAULT;
        $all['sdk_snippet_custom']  = Settings::sdkSnippetIsCustom();
        wstat_json($all);
    }

    /**
     * GET /api/sdk-code —— 「站点接入代码」模板。
     *
     * 刻意**不是** admin 接口:Sites 页的「查看接入代码」是所有站点成员(owner/editor/viewer)
     * 都要用的功能,而模板内容本身就是给访客网站粘贴的代码,不构成敏感信息。
     * 返回模板原文(含 {site_key} / {host} 占位符),由前端替换为具体站点信息。
     */
    public function sdkCode(Request $req): void
    {
        Auth::requireUser($req);
        wstat_json([
            'template' => Settings::sdkSnippetTemplate(),
            'custom'   => Settings::sdkSnippetIsCustom(),
            'default'  => Settings::SDK_SNIPPET_DEFAULT,
        ]);
    }

    /**
     * GET /api/settings/ip-check —— 「真实 IP 采集」自检。
     *
     * **取值来源完全由管理员手动指定**(见 `Util::IP_SOURCES`),本接口不做任何自动判定,
     * 只把事实摆出来:每个候选来源在**本次请求**里会取到什么值、当前生效的是哪一个、
     * 以及当前配置会出问题的两处(套了 CDN 却选 REMOTE_ADDR / 选了可被客户端伪造的头)。
     *
     * 返回的是本次请求的真实环境,所以「点一下检测」就等于用管理员自己的浏览器做一次采样 ——
     * 选一个能显示自己真实地址的来源即可,不需要理解任何代理链原理。
     */
    public function ipCheck(Request $req): void
    {
        Settings::requireAdmin($req);
        $t = Util::ipTrace();
        $ip = (string) $t['ip'];

        $geo   = $ip !== '' ? IpLocator::resolve($ip) : ['country' => '', 'province' => '', 'city' => ''];
        $geoOk = ($geo['country'] ?? '') !== '' || ($geo['province'] ?? '') !== '' || ($geo['city'] ?? '') !== '';
        $isPublic = $ip !== '' && Util::isPublicIp($ip);
        $isIpv6 = $ip !== '' && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;

        $advice = [];
        if ($t['fallback']) {
            $advice[] = '所选的 ' . $t['header'] . ' 在本次请求里没有合法 IP,已回落 REMOTE_ADDR。'
                . '若访客 IP 显示为 CDN / 反代节点地址,请改选其它来源。';
        }
        if ($t['source_key'] === 'remote_addr' && $t['remote_cdn'] !== '') {
            $advice[] = 'REMOTE_ADDR(' . $t['remote'] . ')属于 ' . $t['remote_cdn'] . ' 回源网段 → '
                . '当前记录的是 CDN 节点地址,不是访客地址。请改选 X-Forwarded-For;'
                . '若该头也被 CDN 覆盖为节点地址,请选「自定义头」并填 CF-Connecting-IP。';
        } elseif ($t['source_key'] === 'remote_addr' && $t['remote'] !== '') {
            $advice[] = 'REMOTE_ADDR 即 TCP 对端地址,客户端无法伪造 —— 这是最安全的取值方式,'
                . '适用于「源站直接对外、前面没有 CDN / 反向代理」的部署。';
        }
        if ($t['spoofable']) {
            $advice[] = '注意:' . $t['header'] . ' 由客户端可自行设置,只有在 CDN / 反向代理确实覆盖写入它时才可信;'
                . '源站若能被绕过 CDN 直接访问,任何人都能伪造该头来污染统计。';
        }
        if ($t['source_key'] === 'custom' && $t['header'] === '') {
            $advice[] = '已选择「自定义头」但头名称为空 → 等同于取值失败回落 REMOTE_ADDR,请填写头名称。';
        }
        if ($ip === '') {
            $advice[] = '本次请求没有可用的 IP(CLI 或异常网关),属于非 HTTP 上下文,可忽略。';
        } elseif (!$isPublic) {
            $advice[] = '判定出的地址是内网 / 保留地址:多为「管理员在本机访问面板」,或反向代理未透传真实 IP。';
        }
        if ($ip !== '' && $isPublic && !$geoOk) {
            // 按地址族分别给原因:v4 / v6 是两个库、两条独立的排查路径
            $advice[] = $isIpv6
                ? '该访客是 IPv6,但 IPv6 离线库没解析出地域:请确认 data/ip2region_v6.xdb 存在'
                    . '(可跑 php scripts/fetch-geo.php 补齐;v6 库缺失只影响 IPv6 访客,不影响 IPv4)。'
                : '未能解析出地域:请确认 data/ip2region.xdb 存在,且 collect.geo_driver 不是 none。';
        }

        wstat_json([
            'ip'             => $ip,
            'source'         => $t['source'],
            'source_key'     => $t['source_key'],
            'header'         => $t['header'],
            'header_raw'     => $t['raw'],
            'remote'         => $t['remote'],
            'remote_cdn'     => $t['remote_cdn'],
            'remote_private' => $t['remote_private'],
            'fallback'       => $t['fallback'],
            'spoofable'      => $t['spoofable'],
            'note'           => $t['note'],
            'candidates'     => $t['candidates'],
            'geo'            => $geo,
            'geo_ok'         => $geoOk,
            'is_public'      => $isPublic,
            'is_ipv6'        => $isIpv6,
            'advice'         => $advice,
            'warn'           => $t['warn'],
        ]);
    }

    /** PATCH /api/settings */
    public function update(Request $req): void
    {
        Settings::requireAdmin($req);

        $kv = [];
        // 开关:仅接受 0/1
        foreach (['registration_enabled', 'collect_enabled', 'alert_enabled', 'bot_filter_enabled', 'email_verify_enabled'] as $k) {
            if ($req->input($k) !== null) {
                $kv[$k] = (int) (bool) $req->input($k);
            }
        }
        // SMTP 文本项
        foreach (['smtp_host', 'smtp_username', 'smtp_from_name'] as $k) {
            if ($req->input($k) !== null) {
                $kv[$k] = trim((string) $req->input($k));
                if (mb_strlen($kv[$k]) > 190) {
                    wstat_err($k . ' 过长', 422);
                }
            }
        }
        // 端口
        if ($req->input('smtp_port') !== null) {
            $port = (int) $req->input('smtp_port');
            if ($port < 1 || $port > 65535) {
                wstat_err('SMTP 端口需在 1-65535', 422);
            }
            $kv['smtp_port'] = $port;
        }
        // 加密方式
        if ($req->input('smtp_secure') !== null) {
            $sec = strtolower(trim((string) $req->input('smtp_secure')));
            if (!in_array($sec, ['ssl', 'tls', 'none'], true)) {
                wstat_err('加密方式仅支持 ssl / tls / none', 422);
            }
            $kv['smtp_secure'] = $sec;
        }
        // 发件人地址
        if ($req->input('smtp_from_email') !== null) {
            $e = trim((string) $req->input('smtp_from_email'));
            if ($e !== '' && !Util::validEmail($e)) {
                wstat_err('发件人邮箱格式不正确', 422);
            }
            $kv['smtp_from_email'] = $e;
        }
        // 密码:留空或全掩码则沿用旧值
        if ($req->input('smtp_password') !== null) {
            $pwd = (string) $req->input('smtp_password');
            if (trim($pwd) !== '' && preg_match('/^\*+$/', trim($pwd)) !== 1) {
                $kv['smtp_password'] = trim($pwd);
            }
        }
        // 大屏访问密码:全掩码沿用旧值;其余(含空串)原样保存 —— 空串=关闭密码保护
        if ($req->input('screen_password') !== null) {
            $sp = trim((string) $req->input('screen_password'));
            if (preg_match('/^\*+$/', $sp) !== 1) {
                if (mb_strlen($sp) > 64) {
                    wstat_err('大屏访问密码过长(≤64 字符)', 422);
                }
                $kv['screen_password'] = $sp;
            }
        }
        // 站点接入代码模板:多行代码;空串=用内置默认(占位符 {site_key} / {host})
        if ($req->input('sdk_snippet') !== null) {
            $snip = trim((string) $req->input('sdk_snippet'));
            if (mb_strlen($snip) > Settings::SDK_SNIPPET_MAX) {
                wstat_err('统计代码过长(≤' . Settings::SDK_SNIPPET_MAX . ' 字符)', 422);
            }
            $kv['sdk_snippet'] = $snip;
        }
        // 外部代码(第三方统计 / 在线客服,注入本站页面 </head> 前):空串=不注入
        if ($req->input('inject_code') !== null) {
            $ic = trim((string) $req->input('inject_code'));
            if (mb_strlen($ic) > Settings::INJECT_CODE_MAX) {
                wstat_err('外部代码过长(≤' . Settings::INJECT_CODE_MAX . ' 字符)', 422);
            }
            $kv['inject_code'] = $ic;
        }

        // 访客 IP 取值来源:**手动指定**,程序不做任何自动判断(校验枚举 + 自定义头名)
        if ($req->input('ip_source') !== null) {
            $src = strtolower(trim((string) $req->input('ip_source')));
            if (!in_array($src, Util::IP_SOURCES, true)) {
                wstat_err('IP 取值来源仅支持 ' . implode(' / ', Util::IP_SOURCES), 422);
            }
            if ($src === 'custom' && Util::normalizeHeaderName((string) $req->input('ip_source_header')) === '') {
                wstat_err('选择「自定义头」时必须填写合法的请求头名称(仅字母、数字、连字符)', 422);
            }
            $kv['ip_source'] = $src;
        }
        if ($req->input('ip_source_header') !== null) {
            $hdr = trim((string) $req->input('ip_source_header'));
            if ($hdr !== '' && Util::normalizeHeaderName($hdr) === '') {
                wstat_err('请求头名称不合法(仅允许字母、数字、连字符,长度 ≤64)', 422);
            }
            $kv['ip_source_header'] = $hdr;
        }

        if (!$kv) {
            wstat_err('没有需要保存的设置', 422);
        }
        $n = Settings::set($kv);
        wstat_json(['saved' => $n]);
    }

    /**
     * GET /api/settings/version —— 当前版本 + 远端更新检查。
     * 远端地址取 config.php 的 update_check_url(默认 https://wstats-update.kfw.cc/update.php,
     * 环境变量 WSTAT_UPDATE_URL 可覆盖);拉取失败不影响本接口返回当前版本。
     */
    public function version(Request $req): void
    {
        Settings::requireAdmin($req);
        $r = Updater::check();
        wstat_json([
            'version'        => $r['version'],
            'latest'         => $r['latest'],
            'has_update'     => $r['has_update'],
            'msg'            => $r['reason'],
            'reason'         => $r['reason'],
            'can_auto'       => $r['can_auto'],
            'requires_full'  => $r['requires_full'],
            'layout'         => $r['layout'],
            'check_url'      => $r['check_url'],   // 本机实际使用的更新服务地址(排错用)
            'changelog'      => $r['changelog'],
            'notes'          => $r['notes'],
            'released_at'    => $r['released_at'],
            'update_url'     => $r['update_url'],
            'update_size'    => $r['update_size'],
            'full_url'       => $r['full_url'],
            'full_size'      => $r['full_size'],
            'php_ok'         => $r['php_ok'],
            'update_enabled' => $r['update_enabled'],
            'backups'        => Updater::backups(),
        ]);
    }

    /**
     * POST /api/settings/update —— 一键升级到最新版本。
     *
     * 安全:**不接受客户端传入的下载地址**,一律由服务端重新执行 check() 取回官方地址,
     * 否则面板就成了「可写任意文件的下载器」。升级前的备份、校验、回滚见 Support\Updater。
     */
    public function applyUpdate(Request $req): void
    {
        Settings::requireAdmin($req);
        $chk = Updater::check();
        if (!$chk['ok']) {
            wstat_err($chk['reason'] !== '' ? $chk['reason'] : '无法连接更新服务', 502);
        }
        if (!$chk['has_update']) {
            wstat_err('已是最新版本(v' . $chk['version'] . ')', 422);
        }
        if (!$chk['can_auto']) {
            wstat_err($chk['reason'], 422);
        }
        try {
            $r = Updater::apply($chk['update_url'], $chk['update_sha256']);
        } catch (\Throwable $e) {
            wstat_err('升级失败:' . $e->getMessage(), 500);
        }
        wstat_json($r);
    }

    /** POST /api/settings/email-test */
    public function emailTest(Request $req): void
    {
        Settings::requireAdmin($req);
        $to = trim((string) $req->input('to', ''));
        if (!Util::validEmail($to)) {
            wstat_err('收件邮箱格式不正确', 422);
        }
        $host = trim(Settings::get('smtp_host'));
        $via = $host !== '' ? ('SMTP ' . $host) : 'PHP mail()';
        [$ok, $msg] = Mailer::send([$to], 'WebStats 测试邮件', '这是一封来自 WebStats 的测试邮件。'
            . "\n" . '发送方式: ' . $via
            . "\n" . '时间: ' . date('Y-m-d H:i:s') . "\n");
        wstat_json(['ok' => $ok, 'via' => $via, 'msg' => $msg]);
    }
}