// 读者登录与评论的公开接口。登录走 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/render" "oneblog/internal/store" ) const ( readerCookie = "one_reader" oauthStateCook = "one_oauth_state" maxCommentLen = 500 editWindow = 10 * time.Minute ) // readerID 从会话 cookie 解出读者 ID;匿名返回 false func (a *API) readerID(r *http.Request) (int64, bool) { ck, err := r.Cookie(auth.ReaderCookie) if err != nil { return 0, false } id, err := a.ReaderSessions.Verify(ck.Value) if err != nil { return 0, false } return id, true } func (a *API) authProviders(w http.ResponseWriter, r *http.Request) { providers := []map[string]any{} if a.GH.Enabled() { providers = append(providers, map[string]any{ "id": "github", "label": "GitHub", "kind": "redirect", }) } httpx.OK(w, map[string]any{"providers": providers}) } func (a *API) authMe(w http.ResponseWriter, r *http.Request) { var user any // 匿名时 {user: null},前端判空即「未登录」 if id, ok := a.readerID(r); ok { if reader, err := a.Store.GetReader(id); err == nil { 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": false, "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 后必须带上同一个值。 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}) 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 } ck, err := r.Cookie(oauthStateCook) if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") { 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 { httpx.ServerError(w, err) return } u, err := a.GH.FetchUser(r.Context(), gh) if err != nil { 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 } token, _ := a.ReaderSessions.Issue(reader.ID) http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())}) http.Redirect(w, r, "/", 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) { readerID, ok := a.readerID(r) if !ok { httpx.Error(w, http.StatusUnauthorized, "登录后才能评论") return } reader, err := a.Store.GetReader(readerID) if err != nil { httpx.Error(w, http.StatusUnauthorized, "登录已过期,刷新页面重新登录") return } if reader.Banned { httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论") 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 { status = "pending" } c, err := a.Store.CreateComment(model.Comment{ PostID: in.PostID, UserID: readerID, ParentID: in.ParentID, RootID: root, BodyMd: body, BodyHTML: render.Markdown(body), Status: status, }) if err != nil { httpx.ServerError(w, err) return } 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 } 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 } 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 }