Files
ONE/backend/internal/api/comments.go
T
Sakurasan cfd6948987 评论阶段二:GitHub OAuth 登录 + 评论表/用户表 + 先审后显开关 + 禁言 + 后台评论管理页
- auth 包:读者会话(one_reader,与后台令牌互斥)+ GitHub OAuth 客户端;Verify 校验 HMAC 与 reader: 前缀
- 公开 API:auth 五端点、评论列表(顶层可见+自己待审、回复内嵌)、发表(登录/禁言/开关/500 字校验)、10 分钟编辑窗、软删墓碑
- 管理端:评论列表(待审/已通过/全部)、通过、软删、读者列表、禁言切换
- 前台:登录卡/禁言卡;后台:评论管理页 + 设置页审核开关
2026-09-27 23:41:40 +08:00

381 lines
10 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/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
}