// 读者登录与评论的公开接口。登录走 GitHub OAuth 整页跳转; // 会话是 httpOnly cookie(one_reader),与后台会话(one_session)互不相通。 // // 审核:设置里开了「先审后显」时,新评论 status=pending—— // 只有作者自己能在列表里看到(带「审核中」角标),站主通过后才公开。 package api import ( "crypto/rand" "encoding/hex" "errors" "net/http" "net/url" "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 防 CSRF 存短命 cookie; // 授权完成回到 callback 后必须带上同一个值。 // 同时把发起登录的前台 origin 记下来(one_oauth_back), // callback 用它跳回去——开发时前端 3000 / 后端 8080 分离才不会落错站。 func (a *API) githubLogin(w http.ResponseWriter, r *http.Request) { if !a.GH.Enabled() { httpx.NotFound(w) return } state := randHex(16) http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/", HttpOnly: true, MaxAge: 600}) if ref := r.Referer(); ref != "" { if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" { http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: u.Scheme + "://" + u.Host, Path: "/", HttpOnly: true, MaxAge: 600}) } } http.Redirect(w, r, a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state), http.StatusFound) } // githubCallback 用 code 换身份:GitHub 用户 → upsert 读者 → 发会话 → // 回到首页。 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 } name := u.Name if name == "" { name = u.Login } reader, err := a.Store.UpsertReader(model.Reader{ Provider: "github", Handle: u.Login, Name: name, AvatarURL: u.AvatarURL, URL: u.HTMLURL, }) if err != nil { httpx.ServerError(w, err) return } http.Redirect(w, r, a.issueReaderCookie(w, r, reader.ID), 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 }