阅读视图

发现新文章,点击刷新页面。

给 WordPress 添加一个 RSS 友链阅读器

前情提要

前不久在 jeffer 的一篇文章看到写了一个wp的rss阅读插件,有点小心动。其实早在去年就和 thyuu 交流过这个wp的友链rss功能,当时老哥很快搞定了,还分享了实现代码。当时对rss不是很感冒,基本就是在友链页面翻翻经常逛的那几个,想着也不是每个人都有这个就感觉有点没必要,而且可以直接去看友链的公共聚合之类的,就没弄。直到现在,用了就感觉,欸 好像还挺方便的。

实现

需求是这样的:在wp原生链接基础上,读取不同分类链接中的 link_rss 数据然后解析为自定义 stdClass 返回并储存到 wp_options 表中(方便后期排序等操作),通过不同的链接分类,可以读取不同分类下的rss数据集,通过设置链接显示状态(visible)来限制已订阅链接。

基本理念就是读取和解析xml文件,不过这大千世界,rss种类也很多,面对多种数据结构需要手动去兼容返回。刚开始直接就问了kimi给了一套方案,用php自带的simplexml扩展来解析数据,试了 能用,不过需要自己手动兼容rss类型,就相对比较麻烦。后来想起 thyuu 之前用的wp原生功能 fetch_feed 能自动解析,效果感觉比 curl 好使..

后面尝试了两种不同输出的效果,虽然大差不差但还是wp原生的用起来更稳定(貌似),后面把数据缓存到 wp_option 表了,并挂载更新到了原生链接操作hook(增删改均可同步更新对应分类),添加 wp_schedule 定时清理所有分类缓存任务。

效果

综合效果还是不错的,如图。

一些请求被分割,一些请求失败(底部)

问题

主要有两个问题,一个是rss 抓取时间过长,另一个是抓取成功率问题。

  • 抓取时效性
  • 抓取成功率

关于 rss 抓取时效方面,从拉数据到缓存50+的链接需要反应大概2分钟左右。我问了kimi很多解决方案,什么异步、分块、多线程等等,效果都不太理想。

抓取成功率方面,我试了下当链接超过一定量时就会出现抓取失败的情况,有些报错是数据没完全接收。针对这一情况,首先尝试了 curl_multi 多线程处理,基本没效果。然后尝试将rss链接集分块请求处理,效果不理想。

综上所述,目前还是用的默认 fetch_feed 做的分块请求处理。有没有大佬来指点一二,这种数据应该怎么处理以性能最大化?

闲聊

tmd我前几天手贱给笔记本换硅脂,中毒了去看什么硅脂视频,tmd就是一坨大便艹。换完直接屏幕闪屏,一旦请求gpu上来就开始闪,以为gpu核心出问题了,换核显结果一样。。网上搜了一圈发现可能是显示器线材问题,于是外接显示器最后发现是笔记本显示器的问题。。。这tmd百思不得其解

最要命的是,换了硅脂,笔记本发热降频???我tmd合着搁着绕圈子呢,何况之前根本不降频,温度比这更高高负载时间也比这更长,我干,到底什么原因。我现在就怕这核心被我搞了,但按理来说我拆装非常小心所以不应该啊,哎日了狗,黑神话也搁置了,全怪自己手贱啊,非要去换sb硅脂,这就叫没事找事!!

介绍一个多人划线标记功能

想法

灵感来源于某次逛公众号文章的时候,在文章中偶然看到了一个下划线,经过它时还会显示多少人划线标记。感觉这个功能其实对博文也挺方便的,因为都有评论系统,感觉可以通过评论用户信息做一个多人划线标记功能,自己留作标记的同时也方便其他浏览文章的博友发现和标记文章主要相关内容。

众所周知我目前用的平台是 wordpress,所以该功能也是基于 php 环境下进行开发的,由于标记数据储存在本地而非数据库中,可以灵活应用于各类基于php框架的博客系统上(如typecho、zblog、emlog等)。唯一一点是使用了 wordpress 的文章 id 来区分不同文章页面(关于这点其实可以使用加密 location.pathname 发送到后端作为文章别名区分,后续版本可能以此更新 已更新(默认url别名),执行标记后请勿随意修改url:*切换请求标识符(postID)可能导致清空本地记录继而误删远程数据)若使用其他平台需要修改前端请求中的pid参数(现可选携带参数:postId 初始化,默认 location.pathname

功能简介

划线标记功能结构由文章—用户—标记三部分组成,通过wordpress文章id区分:同一篇文章下可以理论上可以存在不限量标记,但同一个用户(md5邮件区分)默认情况下仅能存在3个标记(可携带自定义参数初始化标记)。而同一个标记(内容)仅能在该文章中存在一次(若通过某种手段重复标记某段已存在于远程记录时,后端会返回标记已存在的错误信息),另若当前选中文本在当前段落中存在多个相同字符时,前端会阻止用户提交标记。

本着只要能把功能复现就行的想法,在持续一周左右的时间后正式开始测试,目前该功能已集成到2BLOG主题最新的 #v1.3.9.2 版本中,后续将对其进行持续迭代更新,下面讲下简单实现思路。

实现思路

前端直接用的 getSelection api 执行选中标记操作(之前本想做跨浏览器兼容,后面想想太麻烦,先实现可以用了再说,反正又不是上线的东西 后面可以慢慢迭代无所叼谓),用户信息存了本地cookie(刚开始做的内容储存,后来改为用户本地校验内容)。

后端用的东西基本和我之前那篇实现gpt的思路基本是一致的,都是本地储存。唯一只是多了部分用户校验,因为涉及到用户新增和删除操作问题(毕竟所有人在评论后都可以对文章进行标记或删除)暂时只能先这么弄(虽然目前配置了文章标记次数限制,不过并没有做黑白名单限制)。

WordPress 简单实现 chatGPT 文章摘要

WordPress 简单实现 chatGPT 文章摘要

灵感来源于之前在浏览 HEO 博文时候偶然看到文章前有一段 AI 摘要,第三人称以打字形式来简述文章内容还是蛮酷的~ 于是拟了个把这个功能集成到 2BLOG ...

2BROEAR 21/03/2024 | 3192 views.

用户校验

目前做了两个信息校验,

一个是最基本的邮件mail(明文验证,其实刚开始的方案是有md5来做,后来因为新增了显示标记用户 gravatar 头像的需求所以不得不放弃该方案,取而代之的只能是远程明文对比验证。但是,为了防止出现邮件明文暴露后造成伪造请求的情况,在执行部分敏感操作时必须携带储存在用户本地的 timestamp 时间戳(明文)与储存在远程服务器中的ts(加密,防止远程ts暴露后获取到对应的本地ts明文)进行校验,通过后再放行相关操作。

但ts交互验证有一个明显的坏处就是:如果标记浏览器与执行操作的浏览器环境不同(即本地cookie无相关ts记录)哪怕当前是用户本人操作也无法通过远程ts验证,目前应对这种情况目前做了用户操作提示与浏览器user-agent记录(可查看对比记录),暂无具体替代解决方案。

其他

这个功能目前处于测试阶段,使用过程中有任何bug欢迎反馈哟/doge。(*另附一些常用的初始化参数如下(所有参数值均为默认值!具体参数等内容可前往 github 查看)

// 携带参数初始化
new marker.init({
    static: {
        postId: window.location.pathname, // 页面标识符(可选,文章唯一ID)
        apiUrl: "mark.php", // 后端 mark.php 文件地址(可选,缺省同一目录)
        md5Url: "md5.js", // 用户 mid 初始化 md5 资源(可选,缺省同一目录)
        avatar: "//gravatar.cn/", // 用户头像 cdn(可选,默认 cravatar)
    },
    class: {
        blackList: ['chatGPT','article_index','ibox'], //'', 'chatGPT,article_index',
    },
    element: {
        effectsArea: document.querySelector('.content'), // 可选区域(可配合 blackList 使用)
        commentArea: document.querySelector('#vcomments textarea'), // 评论区域
        commentInfo: {
            userNick: document.querySelector('input[name=nick]'), // 评论用户
            userMail: document.querySelector('input[name=mail]'), // 评论邮箱
        }
    },
});

插件

现已集成插件到 github:

2Broear/marker: a local-storage(php) based original javascript api marking-off plugin. (github.com)

todos

一些預計添加或修復的待辦事項

  • ❓ 約束後端請求頻率,(新增延迟文件锁),修復并發請求失敗但返回已完成問題。问了一圈 gpt 没解决,暂时在前端做请求限制与延时。
    • ❌使用 localStorage 修复了授权用户操作失败(弱网、并发)后再次访问时自动更新本地/远程记录。bug:标记后立即刷新页面会导致本地记录(已记录)无法匹配远程(等待返回)被删除,后续刷新页面后导致远程记录(已返回)无法匹配本地记录(已删除)被删除。(可在切换评论系统后,通过读取本地记录仍可执行标记删除等操作)
    • ✅更新方案(v1.3.9.5):标记或删除后等待远程响应,返回后再写入(补全)或删除(更新)本地记录对应逻辑如下:
      • 新增标记:(未响应,新增本地记录)—>刷新页面(未响应,删除本地记录)—>返回数据(已响应,对比本地记录)—>远程记录中 未找到 本地记录—>新增本地记录
      • 删除标记:(未响应,删除本地记录)—>刷新页面(未响应,新增本地记录)—>返回数据(已响应,对比本地记录)—>本地记录中 不存在 远程记录—>删除本地记录
  • ✅ 修复本地、远程标记请求上限多步验证。
  • ✅ 新增更多自定义初始化携带参数配置。
  • ✅ 新增自定义标记文本注释功能。
  • ✅ 支持多人重复(未注释)标记,并显示(前三)标记用户头像。
  • ✅ 更新UI/UE,更好的适配暗黑模式
  • ✅ 支持 SSE 服务端 api 推流(递增显示标记➕动画)bug: 每次接收数据后输出时未即使更新data

如何构建一个受保护的网站

前段时间,我出于兴趣试着做了一个需要登录鉴权才能访问的个人网站,最终以 Docusaurus 为内容框架,Next.js 做中间件,Vercel 托管网站,再加上 Auth0 作为鉴权解决方案,实现了一个基本免费的方案。这里做一个分享。

基本的目标是发布一系列文字内容为中心的网页,并限制访问这些页面必须先鉴权同时确认身份信息在白名单上,防止被任意用户访问或黑客破解。

从这个角度看,鉴权的功能实际上是一个包装层,首先要解决的是内容生产的问题。由于我最近主要开发的网站,包括 Pulsar 官方网站开源小镇都是用 Docusaurus 生成的。所以这部分的选型,我就直接选定了 Docusaurus 框架,不再赘述。

实际上,“网页”也不是一个强需求,如果能很好的生成带水印或加密的 PDF 文件,也是一种解决方案。我于是调研了 PandocSphinx 这两种工具,但是它们都有自己的学习成本,并且生成 PDF 基本要先走一遭 Latex 的生态,实在是折腾不来,最终放弃。

选定 Docusaurus 以后,我就开始折腾起了鉴权的问题。

由于实在不想自己折腾一个服务器,加上之前一直是用 Vercel 来托管静态网站,我首先考虑在能够开放源码和内容的托管 Docusaurus 框架生成的网站的基础上,加上 client-side 的保护。

Attempt: Client-side 的鉴权方案

要做具体的鉴权方案,首先要考虑的是选择哪个鉴权软件或解决方案。由于 Vercel 的模板有 Auth0 的示例,加上 Auth0 确实上手非常快,所以我很快选定了 Auth0 作为解决方案。从结果来看,这个选择也是很合理的。

中间我还调研尝试过 Supabase 和 Logto 等解决方案。但是 Supabase 实在是太复杂了,开发者体验非常差;Logto 还没有供应商提供 SaaS 服务,我不太可能再去折腾一套部署,否则为什么不用 Apache Web Server 或者 Nginx 里面写点逻辑呢?

Auth0 client-side 的鉴权,结合 Docusaurus 基于 React 框架开发,可以找到 @auth0/auth0-react 集成库,对应的方案大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/theme/Root.tsx
import {Auth0Provider} from "@auth0/auth0-react";

export default function Root({children}): JSX.Element {
const history = useHistory();
const {siteConfig} = useDocusaurusContext();
const {BUILD, NEXT_PUBLIC_AUTH0_CLIENT_ID, NEXT_PUBLIC_AUTH0_DOMAIN} = siteConfig.customFields;
return (BUILD > 0) ? <>{children}</> : (
<Auth0Provider
clientId={`${NEXT_PUBLIC_AUTH0_CLIENT_ID}`}
domain={`${NEXT_PUBLIC_AUTH0_DOMAIN}`}
redirectUri={resolveRedirectUri()}
useRefreshTokens={true}
cacheLocation={"localstorage"}
onRedirectCallback={(appState?: AppState): void => {
if (appState) {
history.push(appState.location);
}
}}
>
<Authenticator children={children}/>
</Auth0Provider>
);
}

上面这段代码有很多细节,但是核心是用 Auth0Provider 组件把 Docusaurus 的页面元素全部包括进来。这样,浏览器访问对应页面的时候,得到的就会是一个自定义的 Authenticator 生成的页面,比如在这个尝试里,我把它做成一个带有 LOGO 和登录按钮的页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
export default function Authenticator({children}): JSX.Element {
const {isAuthenticated, error, user, loginWithRedirect} = useAuth0();

if (error) {
return <Errors code={500} message={error.message}/>;
}

if (!isAuthenticated) {
const location = useLocation();
return <>
<Head>
<meta property="og:description" content="Login page"/>
<meta charSet="utf-8"/>
<title>登录</title>
</Head>
<div className={styles.container}>
<main className={styles.main}>
<h1 className={styles.title}>
网站名称
</h1>

<p className={styles.description}>
<Button variant={'contained'} onClick={() => loginWithRedirect({
appState: {
location: location,
returnTo: location.pathname,
}
})}>
点击登录
</Button>
</p>
</main>
</div>
</>
}

if (isWhitelistUsers(user)) {
return <>{children}</>;
}

return <Errors code={403} message={`Unregistered user: ${JSON.stringify(user)}`}/>;
}

但是,这样的方法会有两个问题。

第一个问题相对好解决,记录在 easyops-cn/docusaurus-search-local#210 上。由于生成的页面内容 HTML 文件里全都是登录页,所以这里提到的本地搜索插件建立索引的时候就无法取得正确的内容。

对于 Algolia 这样的搜索专业解决方案来说,它会提供爬取 bypass 的途径,用户配置鉴权过程的 client secret 就可以正确获取页面内容。

对于依赖 HTML 文件内容的本地搜索插件来说,最终我选择的解决方案是上面代码里 BUILD 配置体现出来的两阶段构建。第一次先把 Auth0Provider 的逻辑给禁用掉,生成包含内容的 HTML 文件,以此继续生成正确的 search-index.json 索引文件。把这个文件拷贝出来,启用 Auth0Provider 再次生成网站,再用拷贝出来的索引文件覆盖这次生成的无效索引文件,就可以绕过这个问题。

第二个问题就不好解决了。在解决第一个问题的时候,我尝试了这样一个解决方案:

1
2
3
4
5
6
import useIsBrowser from "@docusaurus/useIsBrowser";

export default function Root({children}): JSX.Element {
const isBrowser = useIsBrowser();
return isBrowser ? (<Auth0Provider /* ... */> ... </Auth0Provider>) : <>{children}</>;
}

这样,因为 isBrowser 在构建阶段一定是 false 所以生成索引正常工作。实际访问的时候,isBrowser 变成 false 于是用户会被导向登录页。

但是,熟悉 React 的同学应该可以看到这里一个明显的问题。isBrowser 被设置成 true 要在 React 渲染逻辑加载完毕之后,所以用户会有一个明显的先能访问页面,然后被弹出去要求登录的体验。

更加要命的是第一次访问的时候其实已经把页面内容发送到浏览器了,随后弹出去的逻辑只是 JS 代码的逻辑。这样,只要知道从开发者工具里捞传输内容,甚至直接用 wget 或者 curl 等工具访问页面的用户,就可以得到页面的源代码,从而知道页面的内容。

即使不用 isBrowser 的方案,回到两阶段构建的方案,也会有类似的问题。

这一次,在用户鉴权之前,他只能看到登录页,没法接收到实际页面的内容。所以这个路径上是被很好的保护的。但是,我的好奇心让我不由得探究:如果页面上没有实际内容,那么用户鉴权之后到底是怎么看到实际内容的呢?

怀着这样的疑问,我在 docusaurus 的论坛上发起了一个讨论。最终,我发现了实际内容被编码到 JS 文件里,鉴权成功后时间内容会从 JS 文件里被调用加载上来。

这里就有一个问题了。JS 文件作为静态文件,也是被托管到 Vercel 的服务器上的,只要有路径,谁都可以访问。而且访问静态资源不会走 React 框架的逻辑,自然也就没什么鉴权的说法。实际上,我在未鉴权的情况下,直接访问 JS 文件是可行的。

那么问题就是用户能不能知道 JS 文件的路径是什么呢?Docusaurus 生成的 JS 文件,是十六进制的数字编号,并没有什么特别的规律。如果用户在正常情况下无法轻易破解 JS 文件路径,那么这个解决方案也是可行的。

可惜的是,我自己在知道框架工作逻辑的情况下,不到半个小时就破解了这个规律。实际上,就算只访问登录页,也可以看到引用了一个带 runtime-main 字样的 JS 文件。打开这个文件,找到固定特征位置的 content files 映射表,取出两个分段里相同的键对应的值 $a$b 再拼成 $a.$b.js 就是对应内容的 JS 文件。这样,随便写个爬虫就可以把页面内容全爬下来了。

虽然对于文本内容的读者来说,大部分是不会做也不知道可以做这样的事的,但是我强迫症上来了,就总想着找个更干净的解决方法。

有读者可能会问,既然如此,鉴权软件提供这种集成有什么用呢?实际上,鉴权(Authentication)和授权(authorization)是不一样的。鉴权主要是解析用户身份,获取对应的用户信息,也就是访客是谁。对于任何人都可以访问的页面来说,也有根据访客身份做定制化展示的需求。相反,授权是在取得用户信息之后,判断对应用户是否具有访问对应资源或执行对应操作的过程。因此,哪怕不做权限控制,只是支持用户登录获取用户信息,也是有价值的。

Vercel Edge Middleware

可以看到,只要试图在 client-side 解决问题,就一定解决不了访问静态资源无法保护的问题,因为访问静态资源的整个逻辑根本不走用户定义的 JS 代码,而是由服务器完成的。实际上,用户访问网页,也是服务器把对应的静态资源返回给浏览器,浏览器加载对应逻辑渲染得到的。

要想拦截这个过程,只能在 server-side 处理入站请求的时候动手脚,这也是 Docusaurus 的主要维护者 @slober 在上面讨论中的建议。由于我不想自己维护服务器,而且已经多次将网站托管在 Vercel 平台上,这个生态相对比较熟悉,于是我先尝试寻找 Vercel 平台提供的解决方案。

幸运的是,Vercel 提供了 Edge Middleware 的方案来支持这类需求。

Edge Middleware location within Vercel infrastructure.

上图展示了 Edge Middleware 在 Vercel 平台上的位置:浏览器的请求首先会经过 Edge Middleware 处理,再被转发到实际托管资源的服务器进程上。

这样,我就可以在最前面拦截所有针对内容的请求,重定向到鉴权页面,在鉴权返回以后根据用户信息做权限验证,只放过在白名单上的用户。

不过,实际实现的时候又遇到了很多问题。

第一个问题是 Edge Middleware 对框架的差别待遇。

上面 Edge Middleware 的文档页上说明了,针对 Next.js 框架和其他框架托管的网站,能够使用的 Edge Middleware 的功能是不一样的。简单来说,只有在 Next.js 框架下才能使用 Next.js 的 Request/Response 等 next/server 包里的定义。如果强行在 Docusaurus 里使用,会直接报错无法渲染。

这样一样,Auth0 针对 Edge Middleware 的集成 @auth0/nextjs-auth0 就不能用了,因为它依赖了 Next.js 定义的 Request/Response 接口。

有读者可能会说,这样的话 fallback 到 auth0.js 手写一个集成不就好了吗?

一方面,手写一个这样的集成还是很麻烦的,尤其是写对 session cache 那一大坨逻辑,以及手动解析各种 wire protocol 传输的数据,非常枯燥。我是终端用户,我的目的是拿好趁手的工具尽快解决我的问题,而不是跑偏去做一个我的依赖项,除非实在是无路可走。

另一方面,即使想要这么做,Edge Middleware 在 Docusaurus 框架的集成上还是有奇怪的问题

这一度让我考虑完全切换到 Next.js 框架下,随便找个内容渲染框架把内容丢上去算逑。于是我就遇到了第二个问题。

第二个问题是 Auth0 的 Next.js 集成默认 bypass 了对 Next.js 静态资源的鉴权要求。

或许这里有一些前端的哲学,但是我的需求确实被阻碍了。

有些开发者可能会说,静态资源就是可以被随意访问的,如果你的资源需要被保护,那可以用一个后端服务保护,或者丢到数据库里要求带着账号密码访问。但是我的一个基本诉求就是尽可能不要自己维护一个服务器。如果说这话的人能帮我像在某电商公司那样给我提供网关服务和数据库服务,接我的需求帮我解决,那我自然很乐得按照这种分工方案来实施。

我在 nextjs-auth0 的仓库里记了一个报告,但是看起来维护者不是很能理解我的需求。所幸最后我也没用 Next.js 的解决方案,静态文件路径不在被硬编码 bypass 的 /_next 路径下,也就没了这个问题。

Docusaurus + Next.js 的最终方案

出于以上问题的考虑,加上 Next.js 原生的内容渲染框架实在是没有跟 Docusaurus 能打的选择,最终我选择的是 Docusaurus 框架生成静态网站,然后用 Next.js 框架托管这些静态文件的方案。这样,就能跟 Next.js 生态的其他插件尤其是 @auth0/nextjs-auth0 集成上了。

实际项目结构说来也简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
.
├── LICENSE
├── README.md
├── gateway
│   ├── README.md
│   ├── middleware.ts
│   ├── next-env.d.ts
│   ├── next.config.js
│   ├── package.json
│   ├── pages
│   ├── public
│   └── tsconfig.json
├── package.json
├── source
│   ├── README.md
│   ├── babel.config.js
│   ├── build
│   ├── docs
│   ├── docusaurus.config.js
│   ├── package.json
│   ├── sidebars.js
│   ├── src
│   ├── static
│   └── tsconfig.json
├── tools
│   ├── build.sh
│   ├── check-build.sh
│   ├── devel.sh
│   └── env.sh
├── vercel.json
└── yarn.lock

核心构建逻辑如下:

1
2
3
4
5
6
7
8
9
10
pushd ./source
yarn build
popd

rm -rf ./gateway/public
cp -va ./source/build ./gateway/public

pushd ./gateway
yarn build
popd

其中 Docusaurus 框架管理的 source 目录按照正常 Docusaurus 网站的方式开发。Next.js 框架管理的 gateway 目录,需要提供 Auth0 集成的两个文件。

第一个是创建 pages/api/auth/[auth0].js 文件以支持 Auth0 的鉴权流程的一系列 API 接口,默认会生成以下端点:

  • /api/auth/login
  • /api/auth/logout
  • /api/auth/callback
  • /api/auth/me
  • /api/auth/401

为了处理用户鉴权成功但是权限验证失败的情况,我加了一个 /api/auth/403 端点,最终代码如下:

1
2
3
4
5
6
7
8
9
10
11
import { handleAuth } from '@auth0/nextjs-auth0';

export default handleAuth({
403: (req, res) => {
const user = JSON.parse((req.query.user as string));
res.status(403).json({
error: 'forbidden',
...user,
})
}
});

第二个是创建 middleware.ts 文件定义中间件处理逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import {getSession, withMiddlewareAuthRequired} from "@auth0/nextjs-auth0/edge"
import {NextResponse} from "next/server"
import {Claims} from "@auth0/nextjs-auth0"
import path from "path-browserify";

export default withMiddlewareAuthRequired(async function middleware(req) {
const res = NextResponse.next()
const {pathname, origin} = req.nextUrl

const session = await getSession(req, res)
if (!session?.user) {
return NextResponse.rewrite(new URL('/api/auth/401', origin), {status: 401})
}

const user = session.user
if (isWhitelistUser(user)) {
if (path.extname(pathname)) return
if (pathname.startsWith('/api')) return

// WHAT: specially handle / or /docs case.
//
// WHY: Next.js will intercept these urls and result in a 404 page.
// We should append an index.html segment to succeed in the first Docusaurus pages loading.
// Then the Docusaurus SPA will take over the control flow.
return NextResponse.redirect(new URL(path.resolve(pathname, 'index.html'), origin))
}

const forbidden = new URL(`/api/auth/403?user=${encodeURIComponent(JSON.stringify(user))}`, origin)
return NextResponse.rewrite(forbidden)
});

除了检验鉴权后用户的访问权限,还有就是处理 Docusaurus 生成的页面,变成 Next.js 的静态资源以后,首次加载如果不是直接访问 HTML 文件,会因为误入 Next.js 框架的解析逻辑而 404 的问题。

具体配置 nextjs-auth0 集成的方法,可以参考官方文档示例。虽然有上面提到的静态文件被无情 bypass 的设计问题,但是总的来说这个集成的文档体验是相当好的。

最终效果,是当你访问任何受保护链接(即除了几个鉴权相关 API 端点以外的链接,包括静态文件)的时候,都会被重定向到 Auth0 提供的鉴权登录页,成功登录后会根据你是否在用户白名单上,分别导向实际页面内容,或者 403 页面。出于内容保护的考虑,这里就不插入对应的动态图演示了。

最终费用是 $0 开销。

应用框架是开源的,无需付费。Vercel 的免费计划对于个人网站来说完全够用,Auth0 的免费额度也是没什么问题的,主要限制在只支持两个内置集成,我实际也只需要 GitHub 和 Google 这两个,甚至只要 GitHub 也行,这样就没有付费的理由了。

Bonus: Logout Button

大家司空见惯的登出按钮其实也是需要开发的。否则一旦成功登录,除非你知道登出的 API 接口是啥,Session 记录会让你在清理缓存之前永远也无法退出。

Docusaurus 最适合做登出按钮的位置就是导航栏右上角,但是 Docusaurus 的导航栏是框架内定义的,预定义的类型里没有点击按钮回调的类型,只有点击图标跳转页面的能力。

一开始,我做了一个点击登出图标后跳转到登出页面,在登出页面里做了登出按钮,但是这个体验就很别扭。所幸我发现了 Docusaurus 上游针对自定义导航栏项目的没有记录在文档的支持,做了一个流畅的登出按钮。

相关代码如下。

第一步,需要定义好登出按钮的组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// @site/src/components/LogoutButtonNavbarItem
import React from "react"
import clsx from "clsx"
import NavbarNavLink from '@theme/NavbarItem/NavbarNavLink'
import useSWR from 'swr'

const fetcher = url => fetch(url).then(r => {
if (r.ok) return r.json();
throw {status: r.status}
})

export default function LogoutButtonNavbarItem(props: {
mobile?: boolean;
position,
}): JSX.Element | null {
const {data} = useSWR('/api/auth/me', fetcher, {
shouldRetryOnError: (error) => {
// specially handle that Docusaurus returns a 404 page
return error.status != 404 && !`${error}`.startsWith('SyntaxError')
}
})

if (!data) return <></>

if (props.mobile) {
return <li className="menu__list-item">
<NavbarNavLink
onClick={() => {
window.location.href = "/api/auth/logout";
}}
className={clsx('menu__link', 'header-logout-link')}
aria-label={'logout button'}
/>
</li>
}

return <NavbarNavLink
onClick={() => {
window.location.href = "/api/auth/logout";
}}
className={clsx('navbar__item navbar__link', 'header-logout-link')}
aria-label={'logout button'}
/>
}

内容没有太多好说,为了在展示上跟点击图标有一样的样式效果,内容基本是抄的内置 DefaultNavbarItem 组件的逻辑。功能上,通过访问 /api/auth/me 端点来确认是否是用户登录状态。

第二步,将组件挂载到导航栏项目登记表里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// @site/src/theme/NavBarItem/ComponentTypes.tsx
import DefaultNavbarItem from '@theme/NavbarItem/DefaultNavbarItem';
import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem';
import LocaleDropdownNavbarItem from '@theme/NavbarItem/LocaleDropdownNavbarItem';
import SearchNavbarItem from '@theme/NavbarItem/SearchNavbarItem';
import HtmlNavbarItem from '@theme/NavbarItem/HtmlNavbarItem';
import DocNavbarItem from '@theme/NavbarItem/DocNavbarItem';
import DocSidebarNavbarItem from '@theme/NavbarItem/DocSidebarNavbarItem';
import DocsVersionNavbarItem from '@theme/NavbarItem/DocsVersionNavbarItem';
import DocsVersionDropdownNavbarItem from '@theme/NavbarItem/DocsVersionDropdownNavbarItem';

import LogoutButtonNavbarItem from '@site/src/components/LogoutButtonNavbarItem';

import type {ComponentTypesObject} from '@theme/NavbarItem/ComponentTypes';

const ComponentTypes: ComponentTypesObject = {
default: DefaultNavbarItem,
localeDropdown: LocaleDropdownNavbarItem,
search: SearchNavbarItem,
dropdown: DropdownNavbarItem,
html: HtmlNavbarItem,
doc: DocNavbarItem,
docSidebar: DocSidebarNavbarItem,
docsVersion: DocsVersionNavbarItem,
docsVersionDropdown: DocsVersionDropdownNavbarItem,

'custom-logout-button': LogoutButtonNavbarItem,
};

export default ComponentTypes

这里,src/theme 是个特殊路径,会覆盖 Docusaurus 框架对应路径的主题组件,对应功能文件在此

最后,将注册名为 custom-logout-button 的导航栏组件添加到站点配置文件里,注意自定义导航栏组件注册名必须以 custom- 开头:

1
2
3
4
5
6
7
8
9
10
// docusaurus.config.js
const config = {
themeConfig: ({
navbar: {
items: [
{type: 'custom-logout-button', position: 'right'},
]
}
}),
}

这里唯一没有提供源码的是关联到样式类型 header-logout-link 上的登出按钮样式,这个可以在网上找到自己喜欢的 SVG 图片用一下的方式添加到 src/css/custom.css 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.header-logout-link:hover {
opacity: 0.6;
}

.header-logout-link::before {
content: '';
width: 24px;
height: 24px;
display: flex;
background: url("data:image/svg+xml...") no-repeat;
}

[data-theme='dark'] .header-logout-link::before {
filter: invert(100%);
background: url("data:image/svg+xml...") no-repeat;
}

Bonus: SVG Editor

编辑 LOGO 和按钮 SVG 文件的时候,深深感受到了一个好的 SVG Editor 的价值。别的也不多说了,上连接:

❌