用 Supabase 实现博客免登录留言

最近整理博客时,文章已经能正常阅读,评论区却一直没有恢复。打开页面后,控制台会出现 supabaseUrl is required:代码里启用了 SupaComments,连接地址和密钥却还是空的。
我重新检查了原来的 Supabase 项目。它已经暂停很久,控制台无法直接恢复。这次没有迁移旧评论,而是新建项目,让今后的评论重新开始。
最终保留了一个简单的使用流程:填写昵称和评论即可提交,联系方式可选且不公开;评论提交后直接展示,作者可以在同一个浏览器里删除自己的评论。这篇文章记录实现方式,也方便以后维护时查找。

现在的评论区由什么组成

页面仍保留 SupaComments 这个组件名,但实际接入已经调整。当前组件使用 React 编写,直接调用 Supabase Data API,不再加载原来的 Svelte 打包评论脚本,也不需要单独部署一个 SupaComments 网站。
博客负责表单、列表和浏览器里的删除凭证;Supabase 负责保存评论,并在数据库中执行访问规则。
文章页面 → Comments.js:检查评论配置 → SupaComments.js:表单、列表、删除按钮 → lib/comments.js:发送 REST 请求 → Supabase Data API / PostgREST → PostgreSQL:字段权限、RLS 与数据约束
这里没有新增博客服务端评论接口。浏览器使用公开的 publishable key 访问 Supabase,能够做什么由数据库权限决定。公开密钥可以进入前端;secret key 和旧版 service_role key 不能这样使用。Supabase API keys
对应实现可以从这次提交查看:评论功能实现,54bcf4b。

评论怎样关联文章

原来的脚本按网页路径关联评论。博客近期改过文章地址,如果继续把 URL 当作归属依据,以后修改地址还需要处理评论迁移。
现在使用文章的 Notion 页面 ID,保存在评论表的 post_id 中。它与文章标题和网站地址分开:只要仍是同一篇 Notion 页面,改标题或改地址不会改变评论的归属。重新创建一篇页面会得到新的 ID,不会自动继承旧页面的评论。
评论表的结构如下:
字段
用途
访客能否读取
id
评论 UUID,由浏览器生成
可以
post_id
文章的 Notion 页面 ID
可以
name
昵称,最多 80 个字符
可以
email
可选联系方式,最多 254 个字符
不可以
comment
正文,最多 5000 个字符
可以
created_at
数据库生成的提交时间
可以
show
是否公开,默认 true
可以
deletion_hash
删除凭证的 SHA-256 哈希
不可以
email 是保留下来的字段名,界面实际允许填写邮箱或其他联系方式。昵称、联系方式和正文的长度限制同时存在于前端和数据库中;昵称和正文还需要满足非空条件。
公开列表按文章 ID 筛选,只读取 show = true 的记录,按时间倒序排列。每次取 20 条,后续通过“加载更多”继续读取。数据库为公开评论的文章 ID 和时间建立了索引。

联系方式不公开,不能只靠界面隐藏

旧脚本查询评论时没有指定字段,会把整行数据取回来。即使页面没有显示邮箱,只要网络响应包含它,访客仍然能够读取。
这次分成两层处理。客户端明确列出公开字段:
const PUBLIC_FIELDS = 'id,post_id,name,comment,created_at,show'
数据库先撤销默认表权限,再只开放需要的列:
alter table public.comments enable row level security; revoke all on public.comments from public, anon, authenticated; grant select (id, post_id, name, comment, created_at, show) on public.comments to anon; grant insert (id, post_id, name, email, comment, deletion_hash) on public.comments to anon; grant delete on public.comments to anon;
这段是权限配置的节选,必须配合完整建表脚本中的 RLS 策略使用。最后一条 DELETE 授权只允许发起删除操作,并不允许删除任意评论;具体记录还需要通过下面的凭证校验。
行级安全控制“哪些记录可以访问”,列权限控制“哪些字段可以读取”。仅有 RLS 不会自动隐藏邮箱;列权限受限后,客户端也不能再使用 SELECT *。Supabase 列级权限文档
因此,访客可以提交联系方式,但不能通过公开 API 把它取回来。联系方式仍然保存在数据库中,项目管理员能够查看;这里的“不公开”并不表示它没有被保存。

不登录,怎样删除自己的评论

昵称可以重复,也可以被别人填写,邮箱同样不能证明是谁发了评论。因此删除不能根据昵称或联系方式判断。
当前方案为每一条评论生成一个随机删除凭证。浏览器保存原始凭证,数据库只保存它的哈希值。
生成过程使用浏览器的 Web Crypto API:
const token = Array.from( crypto.getRandomValues(new Uint8Array(32)), n => n.toString(16).padStart(2, '0') ).join('') const hash = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(token) ) const deletionHash = Array.from( new Uint8Array(hash), n => n.toString(16).padStart(2, '0') ).join('')
这里的随机数据是 32 字节,也就是 256 位。转换后,原始凭证和 SHA-256 哈希都用 64 位十六进制字符串表示。两者长度相同,但用途不同:原始凭证用于证明删除权限,哈希用于数据库比较。
提交顺序也有意义:先把评论 ID 与凭证保存到 localStorage,再发送评论。如果浏览器不允许保存,就提示读者开启存储,并停止提交。这样不会在明知无法保留删除凭证的情况下发布评论。
localStorage 的键包含 Supabase 项目地址和文章 ID,值是“评论 ID → 删除凭证”的映射。页面重新加载时读取这份映射,有有效凭证的评论才显示删除按钮。
但按钮只是界面提示,真正的授权发生在数据库。

删除请求怎样通过数据库校验

用户点击删除后,页面先要求确认,再向这条评论发送 DELETE 请求。凭证放在 x-comment-token 请求头中,没有拼进 URL:
const query = new URLSearchParams({ id: `eq.${id}`, select: 'id' }) await fetch(`${endpoint}?${query}`, { method: 'DELETE', headers: { apikey: publishableKey, Prefer: 'return=representation', 'x-comment-token': token } })
这是请求结构示例,变量名用于说明含义。完整客户端还处理请求超时、HTTP 错误和删除结果。
PostgREST 会把请求头提供给数据库策略。RLS 取出凭证,计算 SHA-256,再和当前记录的 deletion_hash 比较:
create policy "Delete with author token" on public.comments for delete to anon using ( deletion_hash = encode(sha256(convert_to( coalesce( nullif(current_setting('request.headers', true), '')::jsonb ->> 'x-comment-token', '' ), 'UTF8' )), 'hex') );
请求头的读取方式来自 PostgREST 的事务上下文。PostgREST 请求上下文文档
没有凭证,或者凭证不匹配,都不会删除这条记录。客户端要求返回被删除的评论 ID,如果结果为空,会把它视为评论不存在或凭证被拒绝,不能提示删除成功。
成功后,界面移除评论,并清理本地对应凭证。当前实现是真正删除数据库记录,并非移入回收站。
严格来说,这套机制识别的是“持有删除凭证的人”,不是经过登录认证的自然人。凭证需要保管好,不应分享给别人;它的安全也依赖浏览器环境和网站没有被恶意脚本侵入。

部署时需要配置什么

这次新建了 Supabase 项目,启用 Data API,关闭“自动开放新表”,然后通过 SQL 只授予评论功能需要的权限。
仓库里有两份脚本:
  • comments-setup.sql:在空的新项目中执行一次,创建表、索引和权限策略。
博客读取下面两个公开环境变量:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
本地可以放在被 Git 忽略的环境配置文件中,线上放在 Vercel 的 Production 环境变量中。公开变量进入前端构建产物,因此修改后需要重新构建部署。这里不需要向浏览器提供数据库密码或服务端高权限密钥。
配置为空时,Comments.js 不加载 SupaComments 组件,避免再次出现缺少连接地址的错误。旧评论脚本仍留在仓库里,但当前组件不再使用它。

这次怎样验证

这次验证分了几个层次,避免只看到页面出现表单就判断完成。
首先是 Node 测试:共 24 项通过,其中 5 项是新增评论客户端测试。它们覆盖公开读取字段、提交字段限制、删除凭证的请求方式、错误凭证的客户端处理,以及 HTTP 失败不会被当成成功。
其次是在真实数据库中,以 anon 角色执行权限检查:公开读取成功,联系方式和凭证哈希读取失败,访客修改被拒绝,错误凭证无法删除,正确凭证可以删除。测试在事务中执行,最后回滚。
随后用公开密钥访问真实 REST API。使用一个不对应公开文章的临时文章 ID,验证评论提交后能够立即读取,错误凭证无法删除,正确凭证能够删除。临时记录随后清理。浏览器删除请求需要的跨域预检也检查了,API 允许 x-comment-token 请求头。
最后,代码推送到 main,Vercel 生产部署达到 Ready,并在 xinzhe.dev 的实际文章底部检查了评论表单和列表加载,没有出现评论相关控制台错误。这一轮的浏览器检查没有实际点击提交和删除按钮;真实写入和删除的证据来自 API 验证,不能把两种检查混为一谈。

当前限制与后续维护

为了保持留言方便,这次没有加入登录,也没有要求提交前审核。代价是删除权限跟着当前浏览器走:换设备、清除站点数据或结束无痕会话后,凭证可能丢失,需要联系博主处理。
目前还没有评论编辑、回复、验证码和请求限流。免登录且直接公开的接口仍可能收到垃圾评论,字段长度限制不等于完整的防滥用方案。文章 ID 也没有与 Notion 建立数据库外键,不能依靠它保证提交一定对应已有公开文章。
管理员可以在 Supabase 中把 show 改为 false 隐藏评论。当前公开列表和删除规则还受可见记录策略影响,被隐藏的评论需要由管理员处理。评论没有连接 Telegram 或邮件通知,填写联系方式也不会自动触发回复。
这次先恢复了可用的留言入口,也把删除权限和联系方式保护放进了数据库。以后如果需要跨设备管理、编辑评论或更强的防垃圾能力,可以再沿着这些边界扩展。
本文对应实现:Xinzhe.Blog,54bcf4b。
对于本文内容有任何疑问, 可与我联系.