- internal/hub:按文章 ID 的进程内发布/订阅,信号不携带内容(未审核评论不外泄) - GET /api/comments/stream?post_id=N:SSE 长连 + 25s 心跳 + X-Accel-Buffering off - 广播点:发评论、作者编辑/删除、后台审核通过、后台删除(与公开端共享同一 hub) - 前端 EventSource 静默刷新(不亮加载态、不干扰正在输入/编辑的状态) - requestLog 的 statusRecorder 补 Flush 透传——包装器没实现 Flusher 时流式端点整体不可用
433 lines
12 KiB
Go
433 lines
12 KiB
Go
// 读者登录与评论的公开接口。登录走 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
|
||
}
|