ApiDoc.php 5.4 KB
<?php

namespace Controller;

/**
 * API 接口文档页面
 * 需要通过密码访问,密码固定为 mailv1
 *
 * 路由:api-doc        (GET 展示页面 / POST 校验密码)
 *      api-doc/logout  (退出,清除登录态)
 *
 * @author:dc
 * @time 2026/10/23 15:30
 * Class ApiDoc
 * @package Controller
 */
class ApiDoc extends Base {

    /**
     * 访问密码(固定)
     */
    private const PASSWORD = 'mailv1';

    /**
     * 登录态 cookie 名称
     */
    private const COOKIE_NAME = 'mail_api_doc_token';

    /**
     * 登录态签名盐值
     * 说明:cookie 里存的是 hmac 值,不存明文密码
     */
    private const COOKIE_SALT = 'mail_api_doc_2026';

    /**
     * 登录态有效期,7 天
     */
    private const COOKIE_TTL = 604800;

    /**
     * 文档页面
     * GET  :未登录返回密码框,已登录返回文档
     * POST :校验密码,成功后写入登录态并直接返回文档
     *
     * @return string
     * @author:dc
     * @time 2026/10/23 15:30
     */
    public function index(){
        // 提交了密码(GET/POST 均可,逻辑一致)
        if(app()->requestHas('password')){
            $password = (string)app()->request('password');
            // 固定密码校验,使用 hash_equals 防时序攻击
            if(!hash_equals(self::PASSWORD,$password)){
                logs('api-doc 密码错误 IP:'.($_SERVER['REMOTE_ADDR']??''));
                return $this->loginHtml('密码不正确');
            }
            $this->setAuthCookie();
            return $this->docHtml();
        }

        if($this->isAuthed()){
            return $this->docHtml();
        }

        return $this->loginHtml('');
    }

    /**
     * 退出,清除登录态
     * 说明:框架在结束时统一输出 200,Location 跳转不会生效,所以直接返回密码页
     * @return string
     * @author:dc
     * @time 2026/10/23 15:30
     */
    public function logout(){
        setcookie(self::COOKIE_NAME,'',[
            'expires'   =>  time() - 3600,
            'path'      =>  '/',
            'httponly'  =>  true,
            'samesite'  =>  'Lax',
        ]);
        return $this->loginHtml('已退出');
    }

    /**
     * 是否已登录
     * @return bool
     * @author:dc
     * @time 2026/10/23 15:30
     */
    private function isAuthed():bool {
        return hash_equals($this->token(),(string)($_COOKIE[self::COOKIE_NAME]??''));
    }

    /**
     * 登录态令牌
     * @return string
     * @author:dc
     * @time 2026/10/23 15:30
     */
    private function token():string {
        return hash_hmac('sha256',self::PASSWORD,self::COOKIE_SALT);
    }

    /**
     * 写入登录态
     * @author:dc
     * @time 2026/10/23 15:30
     */
    private function setAuthCookie(){
        setcookie(self::COOKIE_NAME,$this->token(),[
            'expires'   =>  time() + self::COOKIE_TTL,
            'path'      =>  '/',
            'httponly'  =>  true,
            'samesite'  =>  'Lax',
            'secure'    =>  !empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off',
        ]);
    }

    /**
     * 文档页面 html
     * @return string
     * @author:dc
     * @time 2026/10/23 15:30
     */
    private function docHtml():string {
        $file = ROOT_PATH.'/views/api_doc.html';
        if(!is_file($file)){
            return $this->loginHtml('文档模版不存在:views/api_doc.html');
        }
        $html = file_get_contents($file);

        return str_replace(
            ['<!--{#host#}-->','<!--{#time#}-->'],
            [APP_HOST,app()->nowDateTime()],
            $html
        );
    }

    /**
     * 密码输入页面
     * @param string $error
     * @return string
     * @author:dc
     * @time 2026/10/23 15:30
     */
    private function loginHtml(string $error = ''):string {
        $errorHtml = $error ? '<p class="error">'.$error.'</p>' : '';

        return '<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>API 接口文档 · 访问验证</title>
<style>
    *{box-sizing:border-box;}
    body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;
        background:#eef3fa;font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;color:#243447;}
    .box{width:340px;padding:32px 28px;background:#fff;border-radius:12px;
        box-shadow:0 8px 30px rgba(31,63,107,.12);}
    h1{margin:0 0 6px;font-size:18px;font-weight:600;}
    .sub{margin:0 0 22px;color:#8a97a8;font-size:12px;}
    input{width:100%;height:40px;padding:0 12px;border:1px solid #d5dde8;border-radius:8px;
        font-size:14px;outline:none;transition:.15s;}
    input:focus{border-color:#4a7fd6;box-shadow:0 0 0 3px rgba(74,127,214,.12);}
    button{width:100%;height:40px;margin-top:14px;border:0;border-radius:8px;background:#2f6bd8;
        color:#fff;font-size:14px;cursor:pointer;transition:.15s;}
    button:hover{background:#255cc0;}
    .error{margin:12px 0 0;color:#d0342c;font-size:12px;}
</style>
</head>
<body>
<form class="box" method="post" action="/api-doc" autocomplete="off">
    <h1>API 接口文档</h1>
    <p class="sub">该页面需要密码才可访问</p>
    <input type="password" name="password" placeholder="请输入访问密码" autofocus>
    <button type="submit">进入</button>
    '.$errorHtml.'
</form>
</body>
</html>';
    }

}