PlatoPHP

路由与中间件

路由

默认 URL 是 /{ct}/{ac}plato::run() 解析路径,实例化 {controller_namespace}\ctl_{ct},再调用 {ac}()

namespace control;

use plato\http\resp;

class ctl_user
{
    public static array $actions = [
        'show' => [
            'methods' => ['GET'],
            'auth'    => 'none',
        ],
        'profile' => [
            'methods' => ['GET'],
            'auth'    => 'optional',
        ],
        'save' => [
            'methods' => ['POST', 'PUT'],
            'auth'    => 'required',
        ],
    ];

    public function show()
    {
        return resp::json(['id' => 7]);
    }
}

route.strict_actions = true 时控制器必须声明 $actionsbase_pathpath_suffix、默认 controller/action 和 method override 都由 route 配置段控制。

use plato\http\route;

$url = route::url('user', 'show', ['id' => 7]);
$name = route::name();       // user:show
$method = route::method();
$actions = route::actions(ctl_user::class);

route::actions() 返回控制器公开静态 $actions 的声明;不存在可用声明时返回 null。这是只读元数据 接口:控制器发现仍由应用负责,strict mode、HTTP method 绑定与方法是否可路由仍以 check_action() 的判断为准。

只声明 method 的两种简写同样接受:

public static array $actions = [
    'index' => ['GET'],
    'health' => 'GET',
];

两者都会归一化为 auth = optional,以保留原先全局鉴权回调的行为。新增声明应使用结构化写法, 明确写出鉴权策略。未知或空的 method,以及不完整的结构化声明,都会作为配置错误报 500。

鉴权模式

结构化声明里的两个键都是必填。auth 决定应用的 check_purview_handle 回调是否为该 action 执行:

auth 回调 返回 null plato::$auth
none 不调用 null
optional 配置了就调用 接受,action 照常执行 身份对象,或 null
required 必须配置 401,action 不执行 身份对象

optional 就是给「页面公开,但登录了要认得你」这类场景用的。在 optionalrequired 下, 回调都可以返回 reply(自定形态的 401、跳登录页)代替身份对象,该 reply 直接作为响应,action 不会执行。

required 的 action 在回调没找到人时,由框架渲染一个 401 作答,和 CSRF 校验失败作答 403 是同一 套路:这是访客的状态,不是代码写错。想换成别的应答,就从回调里返回自己的 reply

两者都走 resp::error(),未配置 error_handle 回调时未捕获异常的应答也走它:请求要 JSON 就回 JSON,否则回 text/plain。框架不渲染自己的 HTML 错误页——它没有你的模板可用。

只有两种情况算集成错误:回调返回了既非身份对象也非 reply 的值,以及 required 却根本没配置回 调——什么都没问过,也就无从判断来的是谁。这两类抛 plato\exception\auth_exception,报 500。

关闭 strict actions 且控制器没有声明 $actions 时,走反射回退,这些 action 一律需要鉴权:没有 声明过谁可以访问的 action,按最保守的方式处理。make:controller 生成的模板则用 optional,好让 新建的控制器在鉴权回调接上之前就能跑起来。框架读不懂的声明会直接拒绝并报 500 而不是 404,免得 看起来像路由配错了;$actions 本身不是 public static 数组时同样拒绝,并且会明说是属性的问题, 而不是赖到某个 action 头上。

CLI 与常驻服务入口不论 action 怎么声明都跳过鉴权,除非设置了 cli_auth

CORS 预检与 method 绑定

浏览器预检默认由 security.cors.preflight = true 自动处理。只有同时带 OriginAccess-Control-Request-MethodOPTIONS 才会被当作预检。路由先解析正常的 ct:ac,再用请求 头里的目标 method 校验 action 声明;不匹配时返回 405,并带上该 action 的 Allow 头。声明 GET 也会允许并公布 HEAD

目标 method 只在 route::http_methods() 这个封闭列表里匹配。CLI 不在其中:那是路由器给「没有 HTTP method 的入口」用的标记,check_action() 遇到它会跳过 method 绑定,csrf_verify() 又把它 算作安全 method。请求无论写在请求行还是预检头里都不能声称自己是它——只有在不对外提供 HTTP 服务的 进程里、由入口自己 assign 的路由才可以。不支持或格式错误的目标 method 仍作为预检候选进入校验并 返回 405,绝不会退回普通 OPTIONS action。

校验通过后,route middleware 包裹框架生成的空 204 响应。CSRF、鉴权、加密信封要求和 controller action 都不会执行。响应会公布目标 method,security.cors.max_age(默认 600 秒)告诉浏览器这个 答复可以缓存多久;origin 是否允许仍由 security.allow_origin 决定。

预检可以批准哪些请求头由 security.cors.allow_headers 决定。不配置时原样回显请求的列表,配置了 就按名单收窄:

// app/config/config.php
'security' => [
    'cors' => [
        'allow_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'],
    ],
],

匹配不分大小写。请求里出现名单外的头时,不是批准其中一部分,而是一个都不批准——批准一部分等于让 浏览器发出一个服务端从没同意过的请求。配置值不是由有效 header 名组成的数组时同样一个都不批准, 避免配置拼错反而放宽策略。预检本身仍然回 204,接下来拒绝发出真实请求的是浏览器,这个拒绝本来就 该发生在那里。

不带这两个预检请求头的 OPTIONS 仍是普通请求,必须由 action 声明允许。设置 security.cors.preflight = false 可让所有 OPTIONS 恢复这种旧行为。

中间件

中间件按 *ct:*ct:ac 三种模式配置,顺序与配置一致,同一个 callable 只执行一次。

// app/config/config.php
return [
    'middleware' => [
        '*' => ['middleware\\request_id'],
        'admin:*' => ['middleware\\admin_only'],
    ],
];
namespace middleware;

class request_id
{
    public function handle(callable $next)
    {
        return $next()->with_header('X-Request-Id', bin2hex(random_bytes(8)));
    }
}

类的 handle()__invoke()、闭包和普通 callable 都可使用。普通请求的 CSRF、鉴权与 action 在 管线内执行;自动预检时,中间件改为包裹框架的空 204 destination,不执行 action 与安全检查。