Files
ONE/backend/internal/api/comments.go
T
Sakurasan f1e639e0ba 账户中心:站主资料可编辑 + 身份绑定 + passkey 登录
后台新增 /admin/account 一页,四块:公开资料、密码、第三方账号、Passkey。

-  schema:users 加 role(默认 reader),新表 user_identities、passkeys。
  user_identities 上双 UNIQUE —— (provider, extern_uid) 防一个外部账号顶两个身份,
  (user_id, provider) 防一站主绑两个同平台号,绑错也劫持不了。
  extern_uid 存平台稳定 ID,不存用户名(用户名可改)。
- 头像存 files 里的 key 而非 URL,换存储/CDN 不失效;单 key SetSetting 写入,
  避开 UpdateSettings 的整表替换会把它抹掉。站主名/简介从设置页挪到账户页,
  一个字段只留一个编辑入口。
- OAuth 绑定要求先有后台会话(绑定动作本身是提权路径);已绑的站主身份登录后
  直接发 one_session,读者身份仍发 one_reader。
- passkey 走 go-webauthn v0.15.0(最后一条吃 go 1.24 的版本线),可发现凭据登录。
  必须显式设 ONE_WEBAUTHN_ORIGINS 才启用,不配就安静关掉。
  签名计数只记克隆警告、不硬拦 —— 云同步 passkey 的计数本就不单调。
- 密码故意留在 ONE_ADMIN_PASSWORD,不做哈希入库:这是「解绑一切、删光 passkey
  也还能进门」的保底,比 env 明文更值得守。memos 那个 SSO 建号随机密码无重置
  入口的坑,从设计上绕开。

已知限制:会话仍是有状态无关的 HMAC cookie,删 passkey / 解绑不会让已发出的
7 天后台会话失效 —— 要修得加一张吊销表。
2026-09-30 01:08:55 +08:00

430 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 读者登录与评论的公开接口。登录走 GitHub OAuth 整页跳转;
// 会话是 httpOnly cookie(one_reader),与后台会话(one_session)互不相通。
//
// 审核:设置里开了「先审后显」时,新评论 status=pending——
// 只有作者自己能在列表里看到(带「审核中」角标),站主通过后才公开。
package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strconv"
"strings"
"time"
"oneblog/internal/auth"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/ratelimit"
"oneblog/internal/render"
"oneblog/internal/store"
)
const (
readerCookie = "one_reader"
oauthStateCook = "one_oauth_state"
oauthBackCook = "one_oauth_back"
maxCommentLen = 500
editWindow = 10 * time.Minute
)
// readerID 从会话 cookie 解出读者 ID;匿名返回 false。
// 后台管理员已登录(one_session)时直接映射为站主读者身份——
// 站主发评论不必再走一遍 GitHub 登录。
func (a *API) readerID(r *http.Request) (int64, bool) {
rd, ok, err := a.resolveReader(r)
if err != nil || !ok {
return 0, false
}
return rd.ID, true
}
// resolveReader 解出当前访客的读者身份:读者会话优先,
// 其次是后台管理员会话(自动 upsert 一个 provider=admin 的站主读者)。
func (a *API) resolveReader(r *http.Request) (model.Reader, bool, error) {
if ck, err := r.Cookie(auth.ReaderCookie); err == nil && ck.Value != "" {
if id, verr := a.ReaderSessions.Verify(ck.Value); verr == nil {
rd, gerr := a.Store.GetReader(id)
if gerr == nil {
return rd, true, nil
}
}
}
if a.AdminSessions != nil {
if ck, err := r.Cookie("one_session"); err == nil && ck.Value != "" {
if _, verr := a.AdminSessions.Verify(ck.Value); verr == nil {
rd, oerr := a.ownerReader()
if oerr == nil {
return rd, true, nil
}
}
}
}
return model.Reader{}, false, nil
}
// ownerReader 取(或创建)站主评论身份:provider=admin,名字用站点作者名。
func (a *API) ownerReader() (model.Reader, error) {
name := "站主"
if st, err := a.Store.GetSettings(); err == nil && st.AuthorName != "" {
name = st.AuthorName
}
return a.Store.UpsertReader(model.Reader{
Provider: "admin", Handle: a.Cfg.AdminUser, Name: name,
})
}
func (a *API) authMe(w http.ResponseWriter, r *http.Request) {
var user any // 匿名时 {user: null},前端判空即「未登录」
if reader, ok, err := a.resolveReader(r); err == nil && ok {
user = map[string]any{
"id": reader.ID, "name": reader.Name, "handle": reader.Handle,
"avatar_url": reader.AvatarURL, "url": reader.URL,
"provider": reader.Provider, "is_owner": reader.Provider == "admin", "banned": reader.Banned,
}
}
httpx.OK(w, map[string]any{"user": user})
}
func (a *API) authLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: "", Path: "/", MaxAge: -1})
httpx.OK(w, map[string]any{"ok": true})
}
// githubLogin 跳转 GitHub 授权页。state / 回跳地址的处理抽到 startOAuth,
// 与「绑定」入口共用同一套跳转(bind 只是多打一个一次性 cookie)。
func (a *API) githubLogin(w http.ResponseWriter, r *http.Request) {
if !a.GH.Enabled() {
httpx.NotFound(w)
return
}
a.startOAuth(w, r, func(state string) string {
return a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state)
})
}
// githubCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。
func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) {
if !a.GH.Enabled() {
httpx.NotFound(w)
return
}
ip := ratelimit.SourceKey(r)
if a.authFails.Blocked(ip) {
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
return
}
ck, err := r.Cookie(oauthStateCook)
if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") {
a.authFails.Add(ip)
httpx.BadRequest(w, "state 不匹配,请重新登录")
return
}
gh, err := a.GH.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/github")
if err != nil {
a.authFails.Add(ip)
httpx.ServerError(w, err)
return
}
u, err := a.GH.FetchUser(r.Context(), gh)
if err != nil {
a.authFails.Add(ip)
httpx.ServerError(w, err)
return
}
if u.Login == "" {
a.authFails.Add(ip)
httpx.ServerError(w, errors.New("github 未返回用户名"))
return
}
name := u.Name
if name == "" {
name = u.Login
}
persona := model.Reader{
Provider: "github", Handle: u.Login, Name: name,
AvatarURL: u.AvatarURL, URL: u.HTMLURL,
}
// 稳定 id 才是绑定键;老接口没返回 id 时退化为按 handle 认人
extern := strconv.FormatInt(u.ID, 10)
if u.ID == 0 {
extern = ""
}
if back := a.afterIdentity(w, r, "github", extern, u.Login, persona); back != "" {
http.Redirect(w, r, back, http.StatusFound)
}
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// ---------- comments(评论的读取与发表) ----------
func (a *API) comments(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
a.listComments(w, r)
case http.MethodPost:
a.createComment(w, r)
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
}
}
func (a *API) listComments(w http.ResponseWriter, r *http.Request) {
postID := httpx.QueryInt(r, "post_id", 0)
if postID <= 0 {
httpx.BadRequest(w, "post_id required")
return
}
viewer, _ := a.readerID(r)
newest := httpx.QueryString(r, "sort") == "newest"
roots, err := a.Store.ListCommentsByPost(int64(postID), viewer, newest)
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{
"items": roots, "total": len(roots),
"page": 1, "size": len(roots),
})
}
// createComment 发表评论(含回复)。登录 + 未禁言 + 评论开关开着;
// 审核开关开着时新评论进「待审」。站主身份(管理员会话)不受禁言与审核约束。
func (a *API) createComment(w http.ResponseWriter, r *http.Request) {
reader, ok, err := a.resolveReader(r)
if err != nil {
httpx.Error(w, http.StatusUnauthorized, "登录已过期,刷新页面重新登录")
return
}
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录后才能评论")
return
}
isOwner := reader.Provider == "admin"
if reader.Banned && !isOwner {
httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论")
return
}
// 写入限速:每读者每分钟最多 maxComments 条(站主豁免,批量回复不该被卡)
var rateKey string
if !isOwner {
rateKey = strconv.FormatInt(reader.ID, 10)
if a.commentNew.Blocked(rateKey) {
httpx.Error(w, http.StatusTooManyRequests, "发得太快了,休息一分钟再试")
return
}
}
st, err := a.Store.GetSettings()
if err != nil {
httpx.ServerError(w, err)
return
}
if !st.CommentsEnabled {
httpx.Error(w, http.StatusForbidden, "评论未开放")
return
}
var in struct {
PostID int64 `json:"post_id"`
ParentID int64 `json:"parent_id"`
BodyMd string `json:"body_md"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
body := strings.TrimSpace(in.BodyMd)
if body == "" {
httpx.BadRequest(w, "评论内容不能为空")
return
}
if len([]rune(body)) > maxCommentLen {
httpx.BadRequest(w, "评论最多 500 字")
return
}
if _, err := a.Store.Get(in.PostID); err != nil {
httpx.BadRequest(w, "文章不存在")
return
}
var parent model.Comment
root := int64(0)
if in.ParentID > 0 {
p, err := a.Store.GetComment(in.ParentID)
if err != nil {
httpx.BadRequest(w, "回复的评论不存在")
return
}
if p.PostID != in.PostID {
httpx.BadRequest(w, "回复的评论不属于这篇文章")
return
}
parent = p
root = parent.RootID
if root == 0 {
root = parent.ID
}
}
status := "visible"
if st.CommentsReview && !isOwner {
status = "pending"
}
c, err := a.Store.CreateComment(model.Comment{
PostID: in.PostID, UserID: reader.ID, ParentID: in.ParentID, RootID: root,
BodyMd: body, BodyHTML: render.Markdown(body), Status: status,
})
if err != nil {
httpx.ServerError(w, err)
return
}
a.Hub.Broadcast(in.PostID)
if rateKey != "" {
a.commentNew.Add(rateKey) // 只计成功写入:空正文这类手滑不扣配额
}
httpx.Created(w, c)
}
// commentSub /api/comments/{id} 与 /api/comments/{root}/thread 的分发
func (a *API) commentSub(w http.ResponseWriter, r *http.Request) {
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/comments/"), "/")
if rest == "" {
httpx.NotFound(w)
return
}
// {root}/thread:楼内回复翻页(当前实现全量内嵌,这里兜底返回剩余)
if strings.HasSuffix(rest, "/thread") {
rootID, err := strconv.ParseInt(strings.TrimSuffix(rest, "/thread"), 10, 64)
if err != nil {
httpx.BadRequest(w, "bad root id")
return
}
a.commentThread(w, r, rootID)
return
}
id, err := strconv.ParseInt(rest, 10, 64)
if err != nil {
httpx.BadRequest(w, "bad comment id")
return
}
switch r.Method {
case http.MethodPut:
a.editComment(w, r, id)
case http.MethodDelete:
a.deleteComment(w, r, id)
default:
httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required")
}
}
func (a *API) commentThread(w http.ResponseWriter, r *http.Request, rootID int64) {
root, err := a.Store.GetComment(rootID)
if err != nil {
httpx.ServerError(w, err)
return
}
cursor := httpx.QueryInt(r, "cursor", 0)
items := []model.Comment{}
if cursor >= 0 && cursor < len(root.Replies) {
items = root.Replies[cursor:]
}
httpx.OK(w, map[string]any{"items": items, "cursor": "", "reply_count": root.ReplyCount})
}
// editComment 作者改自己的评论:10 分钟内有效,且未被禁言未删除
func (a *API) editComment(w http.ResponseWriter, r *http.Request, id int64) {
readerID, ok := a.readerID(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录已过期")
return
}
c, err := a.Store.GetComment(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
if c.UserID != readerID {
httpx.Error(w, http.StatusForbidden, "只能编辑自己的评论")
return
}
if c.IsDeleted {
httpx.NotFound(w)
return
}
if time.Since(mustParse(c.CreatedAt)) > editWindow {
httpx.Error(w, http.StatusForbidden, "超过可编辑时间")
return
}
var in struct {
BodyMd string `json:"body_md"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
body := strings.TrimSpace(in.BodyMd)
if body == "" {
httpx.BadRequest(w, "评论内容不能为空")
return
}
if len([]rune(body)) > maxCommentLen {
httpx.BadRequest(w, "评论最多 500 字")
return
}
if err := a.Store.UpdateCommentBody(id, body, render.Markdown(body)); err != nil {
httpx.ServerError(w, err)
return
}
updated, err := a.Store.GetComment(id)
if err != nil {
httpx.ServerError(w, err)
return
}
a.Hub.Broadcast(updated.PostID)
httpx.OK(w, updated)
}
// deleteComment 作者软删自己的评论(留壳保楼层)
func (a *API) deleteComment(w http.ResponseWriter, r *http.Request, id int64) {
readerID, ok := a.readerID(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录已过期")
return
}
c, err := a.Store.GetComment(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
if c.UserID != readerID {
httpx.Error(w, http.StatusForbidden, "只能删除自己的评论")
return
}
if err := a.Store.DeleteComment(id); err != nil {
httpx.ServerError(w, err)
return
}
a.Hub.Broadcast(c.PostID)
httpx.OK(w, map[string]any{"ok": true})
}
func mustParse(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}
}
return t
}