前言
Typecho是一款很好的博客系统,通过 主题 和 插件 开发几乎可以随心所欲的定制自己的博客网站。但也仅限于博主编辑文章,读者阅读+评论这样的类博客网站,如果希望做更复杂的扩展,就未必能很好的实现了。
本文即将介绍的前台登录/注册功能就是一个这样的功能,虽然通过 主题 和 插件 也能实现,但二者必然紧耦合,撇脚不说,插件的通用性也是个问题。
好在登录/注册这个功能并不常用。为何这么说呢?因为我在开发 一起学笛子这个网站的登录/注册功能的时候发现了Typecho一个很初级的BUG。理论上,只要这个功能用得稍微多一点,BUG就应该很容易被发现。但事实上,这么多年过去了,到我这里才被发现,实属不该,下图是我的合并请求记录,说明原作者也是认可的。
由于一起学笛子这个网站未来的需求可能会越来越复杂,而简单的 主题 和 插件 开发迟早是会不够用的,因此,实现前台登录/注册这个功能我就直接修改源码了,大家如果有类似的需求,可以参考本文的思路自行实现。
1. 基本原理
用过Typecho的都知道,Typecho本身就有登录/注册功能,只不过其主要目的还是给博主自己用的,并不适合开放给读者,一方面,界面实在太丑、太简陋了,另一方面,登录完成后直接跳转到管理后台也很不合适。但是,我们可以依葫芦画瓢,实现我们自己的前台登录/注册功能。因此,在动手之前,先简单了解一下Typecho内部是如何实现的还是很有必要的。
以登录为例,登录功能一共涉及到两个核心文件,一个文件是admin/login.php
,核心代码如下:
<form action="<?php $options->loginAction(); ?>" method="post" name="login" role="form">
<p>
<label for="name" class="sr-only"><?php _e('用户名或邮箱'); ?></label>
<input type="text" id="name" name="name" value="<?php echo $rememberName; ?>" placeholder="<?php _e('用户名或邮箱'); ?>" class="text-l w-100" autofocus />
</p>
<p>
<label for="password" class="sr-only"><?php _e('密码'); ?></label>
<input type="password" id="password" name="password" class="text-l w-100" placeholder="<?php _e('密码'); ?>" required />
</p>
<p class="submit">
<button type="submit" class="btn btn-l w-100 primary"><?php _e('登录'); ?></button>
<input type="hidden" name="referer" value="<?php echo $request->filter('html')->get('referer'); ?>" />
</p>
<p>
<label for="remember">
<input<?php if (\Typecho\Cookie::get('__typecho_remember_remember')): ?> checked<?php endif; ?> type="checkbox" name="remember" class="checkbox" value="1" id="remember" /> <?php _e('下次自动登录'); ?>
</label>
</p>
</form>
这个文件主要存放的是和用户交互的表单界面,由于我们需要实现前台登录,所以这个文件肯定是不能再用了。我们只需要模仿着写一个自己的登录表单,然后将action
设置为<?php $options->loginAction(); ?>
(其本质就是一个类似于https://域名/index.php/action/login?_=xxxxxxxxxxx
的请求地址)即可。
而另一个文件是var/Widget/Login.php
,核心代码如下:
class Login extends Users implements ActionInterface
{
public function action()
{
// protect
$this->security->protect();
/** 如果已经登录 */
if ($this->user->hasLogin()) {
/** 直接返回 */
$this->response->redirect($this->options->index);
}
... ...
/** 跳转验证后地址 */
if (!empty($this->request->referer)) {
/** fix #952 & validate redirect url */
if (
0 === strpos($this->request->referer, $this->options->adminUrl)
|| 0 === strpos($this->request->referer, $this->options->siteUrl)
) {
$this->response->redirect($this->request->referer);
}
} elseif (!$this->user->pass('contributor', true)) {
/** 不允许普通用户直接跳转后台 */
$this->response->redirect($this->options->profileUrl);
}
$this->response->redirect($this->options->adminUrl);
}
}
这是一个实现了ActionInterface
接口的Login
类,里面只有一个action()
方法,用于处理登录逻辑,处理完成后,根据不同的条件跳转到不同的页面,其中referer
是在前面的表单中通过hidden
标签指定的,你可以通过它来自定义跳转的地址,不过我们前端登录是希望通过JS
异步请求接口,然后根据返回值来更新界面,并不希望后端重定向,因此我们需要把重定向改为返回值,这个referer
也用不上。
这两个文件的基本作用我们了解了,但Typecho是如何将表单提交地址https://域名/index.php/action/login
与Login
类关联起来的呢?这就涉及到了另一个文件:var/Widget/Action.php
,从中可以看到如下路由映射关系:
这样,二者就联系起来了。
2. 具体实现
做过 主题 和 插件 开发的朋友通过上面的分析应该不难想到,如果不希望修改源代码,完全是可以通过插件调用Helper::addAction(...)
和Helper::removeAction(...)
来实现自己的登录处理逻辑,然后在主题中实现前端表单页面的。
但我没有这么做,原因前面提到过,源码我迟早是要改的,所以,这次也不想兜大圈子,直接改源码最省事,下面看看我的实现代码吧!还是以登录为例,首先是自定义表单,核心代码如下:
<form action="<?php $this->options->loginAction(); ?>" method="post" name="login" role="form">
<div class="modal-header border-0">
<h1 class="modal-title fs-5" id="loginModalLabel"><?php _e('登录') ?></h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pb-0">
<div class="mb-3">
<input type="text" name="name" class="form-control" placeholder="<?php _e('用户名或邮箱'); ?>" autofocus />
</div>
<div class="mb-3">
<input type="password" name="password" class="form-control" placeholder="<?php _e('密码'); ?>" required />
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="remember" value="1" id="remember">
<label class="form-check-label" for="remember">
<?php _e('下次自动登录'); ?>
</label>
</div>
</div>
<div class="modal-footer border-0 justify-content-between">
<div class="d-flex align-items-center">
<button type="submit" class="btn btn-dark"><?php _e('登录'); ?></button>
<a href="#" class="ms-2 link-secondary link-offset-2 link-underline-opacity-0 link-underline-opacity-100-hover" data-bs-toggle="modal" data-bs-target="#forgetModal"><?php _e('忘记密码?'); ?></a>
</div>
<?php if ($this->options->allowRegister): ?>
<div class="d-flex align-items-center">
<span><?php _e('没有账号?') ?></span>
<a href="#" class="link-secondary link-offset-2 link-underline-opacity-0 link-underline-opacity-100-hover" data-bs-toggle="modal" data-bs-target="#registerModal"><?php _e('立即注册'); ?></a>
</div>
<?php endif; ?>
</div>
</form>
这是一个基于Bootstrap 5
实现的模态窗,效果如下图所示:
代码看似很多,但核心其实就是账号、密码,再加一个提交按钮,其它的都可以无视,而具体提交的JS
代码如下:
const loginForm = document.querySelector("#loginModal form");
formSubmit(loginForm, "frontLogin");
function formSubmit(form, doMethod) {
if (!form) {
return;
}
form.addEventListener("submit", (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const data = {};
for (const [key, value] of formData.entries()) {
data[key] = value;
}
data.do = doMethod;
axios
.post(form.action, data, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
.then(function (response) {
const data = response.data;
showToast(data.message, data.success ? "success" : "error");
if (data.success) {
window.location.reload();
}
})
.catch(function (error) {
console.log(error);
});
});
}
这里有三点需要说明一下:
- 为了方便,这里用到了
axios
库,你也可以用其它的,如JQuery
或原生的XMLHttpRequest
、fetch
等; - 除了表单参数之外,还为请求指定了一个
do
参数,值为frontLogin
,用于区分前台登录和后台登录; - 为了简单起见,这里登录成功后直接调用了
window.location.reload();
方法重新加载当前页面,事实上,你也可以通过JS
更新DOM
节点,用户体验会更好一些。
前端提交后,请求会经过路由最终到达Login
类的action()
方法,这时我们需要通过如下代码来处理前台登录逻辑:
class Login extends Users implements ActionInterface
{
/**
* 初始化函数
*
* @access public
* @return void
*/
public function action()
{
$this->on($this->request->is('do=frontLogin'))->frontLogin();
// protect
$this->security->protect();
...
}
function frontLogin()
{
/** 如果已经登录 */
if ($this->user->hasLogin()) {
echo json_encode([
'success' => true,
'message' => _t('您已经登录了')
]);
exit;
}
$expire = 30 * 24 * 3600;
$name = $this->request->get('name');
$password = $this->request->get('password');
if (empty($name) || empty($password)) {
echo json_encode([
'success' => false,
'message' => _t('账号或密码不能为空')
]);
exit;
}
$valid = $this->user->login($name, $password, false, $this->request->is('remember=1') ? $expire : 0);
if (!$valid) {
echo json_encode([
'success' => false,
'message' => _t('账号或密码错误')
]);
exit;
}
echo json_encode([
'success' => true,
'message' => _t('登录成功')
]);
exit;
}
}
这里也有几点需要说明的:
- 在
action()
的开始位置调用$this->on($this->request->is('do=frontLogin'))->frontLogin();
拦截请求,这里的do=frontLogin
就是我们表单提交时传入的参数,参数命中后,请求会交由frontLogin()
方法处理; frontLogin()
需要输出一个JSON
对象,而不是直接跳转,输出完成之后,需要执行exit
中断请求,否则代码会继续执行。
结语
通过上述几个步骤,我们自定义的前台登录功能就实现了。注册的思路也是一样的,原文件分别对应admin/register.php
和var/Widget/Register.php
两个文件,就不再赘述了,唯一不同的是,注册涉及到邮箱验证,更多细节可以参看typecho前台注册核心代码和typecho注册实现邮箱验证两篇文章。
评论2
老孙
来学习
老朱
相互学习😁