评论阶段二:GitHub OAuth 登录 + 评论表/用户表 + 先审后显开关 + 禁言 + 后台评论管理页
- auth 包:读者会话(one_reader,与后台令牌互斥)+ GitHub OAuth 客户端;Verify 校验 HMAC 与 reader: 前缀 - 公开 API:auth 五端点、评论列表(顶层可见+自己待审、回复内嵌)、发表(登录/禁言/开关/500 字校验)、10 分钟编辑窗、软删墓碑 - 管理端:评论列表(待审/已通过/全部)、通过、软删、读者列表、禁言切换 - 前台:登录卡/禁言卡;后台:评论管理页 + 设置页审核开关
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
@@ -20,9 +21,12 @@ import (
|
||||
)
|
||||
|
||||
type API struct {
|
||||
Store *store.Store
|
||||
Cfg *config.Config
|
||||
Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享)
|
||||
Store *store.Store
|
||||
Cfg *config.Config
|
||||
Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享)
|
||||
// 评论区读者会话与 GitHub OAuth(main.go 装配)
|
||||
ReaderSessions *auth.ReaderSessions
|
||||
GH auth.GitHub
|
||||
}
|
||||
|
||||
func (a *API) Routes() http.Handler {
|
||||
@@ -34,6 +38,14 @@ func (a *API) Routes() http.Handler {
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true, "driver": a.Cfg.Driver})
|
||||
})
|
||||
// 读者登录与评论
|
||||
mux.HandleFunc("/api/auth/providers", a.authProviders)
|
||||
mux.HandleFunc("/api/auth/me", a.authMe)
|
||||
mux.HandleFunc("/api/auth/logout", a.authLogout)
|
||||
mux.HandleFunc("/api/auth/github/login", a.githubLogin)
|
||||
mux.HandleFunc("/api/auth/callback/github", a.githubCallback)
|
||||
mux.HandleFunc("/api/comments", a.comments)
|
||||
mux.HandleFunc("/api/comments/", a.commentSub)
|
||||
mux.HandleFunc("/api/site", a.site)
|
||||
mux.HandleFunc("/api/posts", a.listPosts)
|
||||
mux.HandleFunc("/api/posts/", a.getPost)
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
// 读者登录与评论的公开接口。登录走 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
|
||||
}
|
||||
Reference in New Issue
Block a user