评论阶段二:GitHub OAuth 登录 + 评论表/用户表 + 先审后显开关 + 禁言 + 后台评论管理页
- auth 包:读者会话(one_reader,与后台令牌互斥)+ GitHub OAuth 客户端;Verify 校验 HMAC 与 reader: 前缀 - 公开 API:auth 五端点、评论列表(顶层可见+自己待审、回复内嵌)、发表(登录/禁言/开关/500 字校验)、10 分钟编辑窗、软删墓碑 - 管理端:评论列表(待审/已通过/全部)、通过、软删、读者列表、禁言切换 - 前台:登录卡/禁言卡;后台:评论管理页 + 设置页审核开关
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -72,6 +73,24 @@ func (a *API) Routes() http.Handler {
|
||||
mux.HandleFunc("/api/admin/files", a.guard(a.files))
|
||||
mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID))
|
||||
mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
|
||||
mux.HandleFunc("/api/admin/comments", a.guard(a.adminComments))
|
||||
mux.HandleFunc("/api/admin/comments/", a.guard(a.adminCommentByID))
|
||||
mux.HandleFunc("/api/admin/readers", a.guard(a.adminReaders))
|
||||
mux.HandleFunc("/api/admin/readers/", a.guard(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 路径形如 /api/admin/readers/{id}/ban
|
||||
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/readers/"), "/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) != 2 || parts[1] != "ban" {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(w, "bad reader id")
|
||||
return
|
||||
}
|
||||
a.adminReaderBan(w, r, id)
|
||||
}))
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -772,3 +791,98 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- comments(评论审核与管理) ----------
|
||||
|
||||
func (a *API) adminComments(w http.ResponseWriter, r *http.Request) {
|
||||
status := httpx.QueryString(r, "status")
|
||||
page := httpx.QueryInt(r, "page", 1)
|
||||
size := httpx.QueryInt(r, "size", 20)
|
||||
fp, err := a.Store.ListCommentsAdmin(status, page, size)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, fp)
|
||||
}
|
||||
|
||||
func (a *API) adminCommentByID(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/comments/"), "/")
|
||||
id, err := parseInt(rest)
|
||||
if err != nil {
|
||||
httpx.BadRequest(w, "bad comment id")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
// 审核动作:{"status": "visible" | "pending"}
|
||||
var in struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
if in.Status != "visible" && in.Status != "pending" {
|
||||
httpx.BadRequest(w, "status 只支持 visible / pending")
|
||||
return
|
||||
}
|
||||
if err := a.Store.SetCommentStatus(id, in.Status); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
case http.MethodDelete:
|
||||
// 后台删除同样走软删(墓碑保楼层)
|
||||
if err := a.Store.DeleteComment(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- readers(评论用户与禁言) ----------
|
||||
|
||||
func (a *API) adminReaders(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
readers, err := a.Store.ListReaders()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"readers": readers})
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) adminReaderBan(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
var in struct {
|
||||
Banned bool `json:"banned"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
if err := a.Store.SetReaderBanned(id, in.Banned); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true, "banned": in.Banned})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package auth 提供评论区两侧的基础设施:读者会话(cookie one_reader,
|
||||
// payload 带 reader: 前缀,与后台令牌不可互换)和 GitHub OAuth 客户端。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReaderCookie 是读者会话的 cookie 名(与后台的 one_session 区分开)
|
||||
const ReaderCookie = "one_reader"
|
||||
|
||||
// ReaderSessions 签发 / 校验读者会话令牌:
|
||||
// base64("reader:<readerID>:<expiryUnix>") + "." + HMAC-SHA256。
|
||||
type ReaderSessions struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewReaderSessions(secret string, ttl time.Duration) *ReaderSessions {
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * 24 * time.Hour
|
||||
}
|
||||
return &ReaderSessions{secret: []byte(secret), ttl: ttl}
|
||||
}
|
||||
|
||||
var ErrBadSession = errors.New("invalid reader session")
|
||||
|
||||
func (s *ReaderSessions) Issue(readerID int64) (string, time.Time) {
|
||||
exp := time.Now().Add(s.ttl)
|
||||
payload := readerPayload(readerID, exp)
|
||||
enc := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
return enc + "." + s.sign(payload), exp
|
||||
}
|
||||
|
||||
func (s *ReaderSessions) Verify(token string) (int64, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
// 必须以 reader: 开头——后台令牌(base64("admin:<exp>"))复制过来也无效
|
||||
if len(raw) < 10 || string(raw)[:7] != "reader:" {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
// 余下是 "<id>:<exp>"
|
||||
rest := string(raw)[7:]
|
||||
i := strings.IndexByte(rest, ':')
|
||||
if i <= 0 {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
id, err := strconv.ParseInt(rest[:i], 10, 64)
|
||||
if err != nil {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
expUnix, err := strconv.ParseInt(rest[i+1:], 10, 64)
|
||||
if err != nil {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
if time.Now().After(time.Unix(expUnix, 0)) {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
// 签名放在最后验证:payload 与 exp 都验过再比对 MAC
|
||||
if !hmac.Equal([]byte(s.sign(string(raw))), []byte(parts[1])) {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func readerPayload(id int64, exp time.Time) string {
|
||||
return "reader:" + strconv.FormatInt(id, 10) + ":" + strconv.FormatInt(exp.Unix(), 10)
|
||||
}
|
||||
|
||||
func (s *ReaderSessions) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// ---------- GitHub OAuth ----------
|
||||
|
||||
// GitHub 持有 OAuth 应用凭据。没配 = GitHub 登录不开放,
|
||||
// 前台登录卡自动不显示该入口。
|
||||
type GitHub struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
func (g GitHub) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
|
||||
|
||||
const (
|
||||
githubAuthURL = "https://github.com/login/oauth/authorize"
|
||||
githubTokenURL = "https://github.com/login/oauth/access_token"
|
||||
githubUserURL = "https://api.github.com/user"
|
||||
)
|
||||
|
||||
// LoginURL 生成 GitHub 授权页跳转地址
|
||||
func (g GitHub) LoginURL(redirectURI, state string) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", g.ClientID)
|
||||
v.Set("redirect_uri", redirectURI)
|
||||
v.Set("scope", "read:user")
|
||||
v.Set("state", state)
|
||||
return githubAuthURL + "?" + v.Encode()
|
||||
}
|
||||
|
||||
// GitHubUser 是 GitHub 用户接口里我们关心的字段
|
||||
type GitHubUser struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// Exchange 用授权码换 access token
|
||||
func (g GitHub) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", g.ClientID)
|
||||
v.Set("client_secret", g.ClientSecret)
|
||||
v.Set("code", code)
|
||||
v.Set("redirect_uri", redirectURI)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubTokenURL, strings.NewReader(v.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil || out.AccessToken == "" {
|
||||
return "", fmt.Errorf("github: token exchange failed")
|
||||
}
|
||||
return out.AccessToken, nil
|
||||
}
|
||||
|
||||
// FetchUser 拉取 GitHub 用户资料
|
||||
func (g GitHub) FetchUser(ctx context.Context, accessToken string) (GitHubUser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubUserURL, nil)
|
||||
if err != nil {
|
||||
return GitHubUser{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return GitHubUser{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var u GitHubUser
|
||||
if err := json.NewDecoder(res.Body).Decode(&u); err != nil || u.Login == "" {
|
||||
return GitHubUser{}, fmt.Errorf("github: fetch user failed")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -21,6 +21,10 @@ type Config struct {
|
||||
SiteURL string
|
||||
InsecureDev bool
|
||||
|
||||
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
|
||||
GitHubClientID string
|
||||
GitHubClientSecret string
|
||||
|
||||
// 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理):
|
||||
// S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com,
|
||||
// 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求
|
||||
@@ -28,12 +32,12 @@ type Config struct {
|
||||
// AccessKey / SecretAccessKey / Bucket = R2 凭据与桶名
|
||||
// 五项齐全 → 上传走 R2、直链走 PublicURL;缺任一项回落本地磁盘
|
||||
// (DataDir/uploads),并在启动日志提示一句。
|
||||
StorageDriver string // r2 | local
|
||||
S3Endpoint string // env: S3Api
|
||||
R2Bucket string
|
||||
R2AccessKey string
|
||||
R2SecretKey string
|
||||
UploadsPublicBase string // env: PublicURL
|
||||
StorageDriver string // r2 | local
|
||||
S3Endpoint string // env: S3Api
|
||||
R2Bucket string
|
||||
R2AccessKey string
|
||||
R2SecretKey string
|
||||
UploadsPublicBase string // env: PublicURL
|
||||
}
|
||||
|
||||
func getenv(k, def string) string {
|
||||
@@ -122,6 +126,10 @@ func Load() (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
|
||||
c.GitHubClientID = getenv("ONE_GITHUB_CLIENT_ID", "")
|
||||
c.GitHubClientSecret = getenv("ONE_GITHUB_CLIENT_SECRET", "")
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ type Post struct {
|
||||
// ContentLen 是正文字符数:列表接口不返回全文,但后台列表要显示字数。
|
||||
ContentLen int64 `json:"content_len"`
|
||||
Tags []string `json:"tags"`
|
||||
// Images 是短文配图(快发盒上传,≤4 张,Twitter 式网格展示),
|
||||
// 以 JSON 数组存在 posts.images 列里,正文 content_md 不含它们。
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
|
||||
type PostInput struct {
|
||||
@@ -42,6 +45,8 @@ type PostInput struct {
|
||||
PublishedAt string `json:"published_at"`
|
||||
Tags []string `json:"tags"`
|
||||
ReadingMinutes *int `json:"reading_minutes"`
|
||||
// Images 是短文配图 URL(快发盒上传),nil = 不修改、空数组 = 清空
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
@@ -123,6 +128,14 @@ type FilePage struct {
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// CommentPage 是后台评论管理的分页容器(含文章标题与发表者)。
|
||||
type CommentPage struct {
|
||||
Items []Comment `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// Dashboard is the snapshot rendered on /admin (homepage).
|
||||
type Dashboard struct {
|
||||
TotalPosts int `json:"total_posts"`
|
||||
@@ -161,6 +174,10 @@ type Settings struct {
|
||||
// SocialLinks 以 JSON 数组形式存在 settings KV 里(key: social_links),
|
||||
// 解析失败/为空时前台拿到空数组,区块自动隐藏。
|
||||
SocialLinks []SocialLink `json:"social_links"`
|
||||
// 评论开关(comments_enabled):关着时前台整个评论区不渲染。
|
||||
CommentsEnabled bool `json:"comments_enabled"`
|
||||
// 审核开关(comments_review):开着时新评论先进「待审」,站主通过后才公开。
|
||||
CommentsReview bool `json:"comments_review"`
|
||||
// LightSkinID is the front-end skin used when the client (or system)
|
||||
// prefers light. Valid values: paper / sage / rose.
|
||||
// Dark side is fixed to ink for now — kept implicit so we can add
|
||||
@@ -186,3 +203,38 @@ type Settings struct {
|
||||
// sanitizing it would only break the snippet.
|
||||
CustomJS string `json:"custom_js"`
|
||||
}
|
||||
|
||||
// Reader 是评论区的登录用户(GitHub OAuth)。Banned = 禁言中。
|
||||
type Reader struct {
|
||||
ID int64 `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Handle string `json:"handle"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
URL string `json:"url"`
|
||||
Banned bool `json:"banned"`
|
||||
// CommentCount 是累计评论数(后台用户列表展示用)
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Comment 是一条评论。回复扁平存储(parent_id/root_id),渲染时挂到 replies。
|
||||
// User 是发表者快照;is_deleted = 软删(留壳显示「已删除」,保住楼层上下文)。
|
||||
type Comment struct {
|
||||
ID int64 `json:"id"`
|
||||
PostID int64 `json:"post_id"`
|
||||
ParentID int64 `json:"parent_id"`
|
||||
RootID int64 `json:"root_id"`
|
||||
User *Reader `json:"user"`
|
||||
UserID int64 `json:"user_id"`
|
||||
BodyMd string `json:"body_md"`
|
||||
BodyHTML string `json:"body_html"`
|
||||
Status string `json:"status"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
EditedAt string `json:"edited_at"`
|
||||
Replies []Comment `json:"replies"`
|
||||
ReplyCount int `json:"reply_count"`
|
||||
// PostTitle 是文章标题(后台评论列表联表带出,仅管理接口填充)
|
||||
PostTitle string `json:"post_title"`
|
||||
}
|
||||
|
||||
+398
-18
@@ -96,6 +96,30 @@ func (s *Store) migrate() error {
|
||||
store TEXT NOT NULL DEFAULT 'local',
|
||||
created_at TEXT NOT NULL
|
||||
)`, ai),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS users (
|
||||
id %s,
|
||||
provider TEXT NOT NULL,
|
||||
handle TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
banned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(provider, handle)
|
||||
)`, ai),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS comments (
|
||||
id %s,
|
||||
post_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
parent_id INTEGER NOT NULL DEFAULT 0,
|
||||
root_id INTEGER NOT NULL DEFAULT 0,
|
||||
body_md TEXT NOT NULL DEFAULT '',
|
||||
body_html TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'visible',
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
edited_at TEXT NOT NULL DEFAULT ''
|
||||
)`, ai),
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
|
||||
@@ -109,6 +133,8 @@ func (s *Store) migrate() error {
|
||||
columnAdds := []string{
|
||||
`ALTER TABLE posts ADD COLUMN cover_url TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
|
||||
// 短文配图(快发盒上传,Twitter 式网格展示),JSON 数组
|
||||
`ALTER TABLE posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`,
|
||||
}
|
||||
for _, q := range columnAdds {
|
||||
if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") &&
|
||||
@@ -126,6 +152,9 @@ func (s *Store) migrate() error {
|
||||
{"idx_projects_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)`},
|
||||
{"idx_files_key", `CREATE UNIQUE INDEX IF NOT EXISTS idx_files_key ON files(key)`},
|
||||
{"idx_files_created", `CREATE INDEX IF NOT EXISTS idx_files_created ON files(created_at DESC)`},
|
||||
{"idx_comments_post", `CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id, created_at)`},
|
||||
{"idx_comments_user", `CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id)`},
|
||||
{"idx_comments_status", `CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status, created_at DESC)`},
|
||||
}
|
||||
for _, ix := range indexes {
|
||||
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
@@ -138,13 +167,15 @@ func (s *Store) migrate() error {
|
||||
|
||||
func (s *Store) seedSettings() error {
|
||||
defs := map[string]string{
|
||||
"site_title": "ONE · 一个博客",
|
||||
"site_desc": "长文与短文,同一种节奏。",
|
||||
"author_name": "ONE",
|
||||
"author_bio": "写点长的,也写点短的。",
|
||||
"footer_note": "© ONE · 一个博客",
|
||||
"icp": "",
|
||||
"posts_per_page": "10",
|
||||
"site_title": "ONE · 一个博客",
|
||||
"site_desc": "长文与短文,同一种节奏。",
|
||||
"author_name": "ONE",
|
||||
"author_bio": "写点长的,也写点短的。",
|
||||
"footer_note": "© ONE · 一个博客",
|
||||
"icp": "",
|
||||
"posts_per_page": "10",
|
||||
"comments_enabled": "1",
|
||||
"comments_review": "0",
|
||||
}
|
||||
for k, v := range defs {
|
||||
if s.db.Dialect == db.Postgres {
|
||||
@@ -207,6 +238,9 @@ func settingsFromMap(m map[string]string) model.Settings {
|
||||
st.CustomCSS = decodeCSSMap(m["custom_css"])
|
||||
st.CustomJS = m["custom_js"]
|
||||
st.SocialLinks = decodeSocialLinks(m["social_links"])
|
||||
// 开关类:'1' / 'true' 都算开,其余(含空)算关
|
||||
st.CommentsEnabled = m["comments_enabled"] == "1" || strings.EqualFold(m["comments_enabled"], "true")
|
||||
st.CommentsReview = m["comments_review"] == "1" || strings.EqualFold(m["comments_review"], "true")
|
||||
if n := atoi(m["posts_per_page"]); n > 0 {
|
||||
st.PostsPerPage = n
|
||||
}
|
||||
@@ -389,8 +423,11 @@ func (s *Store) UpdateSettings(st model.Settings) error {
|
||||
"custom_css": encodeCSSMap(st.CustomCSS),
|
||||
// 原样存:站主自己的代码,不做任何转义/清洗。
|
||||
"custom_js": st.CustomJS,
|
||||
// 空数组存空串:KV 里不留 "null"。
|
||||
// 社交 / 源码链接:JSON 数组,空数组存 "[]"。
|
||||
"social_links": encodeSocialLinks(st.SocialLinks),
|
||||
// 开关统一存 '1' / '0'。
|
||||
"comments_enabled": b2s(st.CommentsEnabled),
|
||||
"comments_review": b2s(st.CommentsReview),
|
||||
}
|
||||
for k, v := range sets {
|
||||
if s.db.Dialect == db.Postgres {
|
||||
@@ -440,21 +477,46 @@ func sanitizeOrder(o string) string {
|
||||
}
|
||||
|
||||
const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status,
|
||||
published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)`
|
||||
published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images`
|
||||
|
||||
// listCols 用于列表/时间线:不传 content_md(前端不用),
|
||||
// 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。
|
||||
const listCols = `id, kind, title, slug, summary, cover_url,
|
||||
'' AS content_md,
|
||||
CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html,
|
||||
status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)`
|
||||
status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images`
|
||||
|
||||
// images 列的 JSON 编解码(列存 '[]',Go 侧 []string;坏数据静默为空)。
|
||||
func decodeImages(s string) []string {
|
||||
out := []string{}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return out
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func encodeImages(imgs []string) string {
|
||||
if len(imgs) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
b, err := json.Marshal(imgs)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
|
||||
var p model.Post
|
||||
var imgs string
|
||||
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
|
||||
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
|
||||
&p.ReadingMinutes, &p.ContentLen)
|
||||
&p.ReadingMinutes, &p.ContentLen, &imgs)
|
||||
p.Tags = []string{}
|
||||
p.Images = decodeImages(imgs)
|
||||
return p, err
|
||||
}
|
||||
|
||||
@@ -627,9 +689,10 @@ func (s *Store) GetBySlug(slug string) (model.Post, error) {
|
||||
}
|
||||
|
||||
func scanPostInto(row *sql.Row, p *model.Post) error {
|
||||
var imgs string
|
||||
err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
|
||||
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
|
||||
&p.ReadingMinutes, &p.ContentLen)
|
||||
&p.ReadingMinutes, &p.ContentLen, &imgs)
|
||||
if err == sql.ErrNoRows {
|
||||
return ErrNotFound
|
||||
}
|
||||
@@ -637,6 +700,7 @@ func scanPostInto(row *sql.Row, p *model.Post) error {
|
||||
return err
|
||||
}
|
||||
p.Tags = []string{}
|
||||
p.Images = decodeImages(imgs)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -881,17 +945,17 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) {
|
||||
|
||||
var id int64
|
||||
q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,cover_url,content_md,content_html,status,
|
||||
published_at,created_at,updated_at,reading_minutes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
published_at,created_at,updated_at,reading_minutes,images)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
if s.db.Dialect == db.Postgres {
|
||||
err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
|
||||
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes).Scan(&id)
|
||||
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images)).Scan(&id)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
} else {
|
||||
res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
|
||||
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes)
|
||||
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images))
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
@@ -939,6 +1003,10 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
|
||||
// current value (empty string included), so any update round-trips with
|
||||
// whatever the user last saved.
|
||||
p.CoverURL = strings.TrimSpace(in.CoverURL)
|
||||
// Images 同理:nil = 不修改(快发盒只发新帖,后台编辑器全量回传)
|
||||
if in.Images != nil {
|
||||
p.Images = in.Images
|
||||
}
|
||||
p.UpdatedAt = now()
|
||||
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
|
||||
p.ReadingMinutes = *in.ReadingMinutes
|
||||
@@ -947,9 +1015,9 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,cover_url=?,content_md=?,
|
||||
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`),
|
||||
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=?,images=? WHERE id=?`),
|
||||
p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, p.Status,
|
||||
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil {
|
||||
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(p.Images), id); err != nil {
|
||||
return p, err
|
||||
}
|
||||
if in.Tags != nil {
|
||||
@@ -1508,3 +1576,315 @@ func (s *Store) DeleteFile(id int64) (model.File, error) {
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// b2s 布尔转 KV 开关值
|
||||
func b2s(b bool) string {
|
||||
if b {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
// ---------- readers(评论区的登录用户) ----------
|
||||
|
||||
// UpsertReader 按 (provider, handle) 找人:找到就更新资料,找不到就建档。
|
||||
// banned 是状态位,不随资料更新覆盖。
|
||||
func (s *Store) UpsertReader(r model.Reader) (model.Reader, error) {
|
||||
r.CreatedAt = now()
|
||||
q := s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,created_at)
|
||||
VALUES (?,?,?,?,?,0,?)
|
||||
ON CONFLICT (provider,handle) DO UPDATE SET
|
||||
name=excluded.name, avatar_url=excluded.avatar_url, url=excluded.url`)
|
||||
// SQLite 的 ON CONFLICT 语法 Postgres 也认(现代版);老库退化走下面分支
|
||||
if s.db.Dialect == db.Postgres {
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,created_at)
|
||||
VALUES (?,?,?,?,?,0,?) ON CONFLICT (provider,handle) DO UPDATE SET
|
||||
name=excluded.name, avatar_url=excluded.avatar_url, url=excluded.url`),
|
||||
r.Provider, r.Handle, r.Name, r.AvatarURL, r.URL, r.CreatedAt); err != nil {
|
||||
return model.Reader{}, err
|
||||
}
|
||||
return s.GetReaderByProviderHandle(r.Provider, r.Handle)
|
||||
}
|
||||
if _, err := s.db.Exec(q, r.Provider, r.Handle, r.Name, r.AvatarURL, r.URL, r.CreatedAt); err != nil {
|
||||
return model.Reader{}, err
|
||||
}
|
||||
return s.GetReaderByProviderHandle(r.Provider, r.Handle)
|
||||
}
|
||||
|
||||
func (s *Store) GetReader(id int64) (model.Reader, error) {
|
||||
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE id = ?`), id))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.Reader{}, ErrNotFound
|
||||
}
|
||||
return model.Reader{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetReaderByProviderHandle(provider, handle string) (model.Reader, error) {
|
||||
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE provider = ? AND handle = ?`), provider, handle))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.Reader{}, ErrNotFound
|
||||
}
|
||||
return model.Reader{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetReaderBanned(id int64, banned bool) error {
|
||||
res, err := s.db.Exec(s.db.Q(`UPDATE users SET banned = ? WHERE id = ?`), b2i(banned), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListReaders 后台的用户列表:带评论数,禁言中的排前面
|
||||
func (s *Store) ListReaders() ([]model.Reader, error) {
|
||||
rows, err := s.db.Query(s.db.Q(`SELECT u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.created_at,
|
||||
(SELECT COUNT(*) FROM comments c WHERE c.user_id = u.id AND c.is_deleted = 0) AS cnt
|
||||
FROM users u ORDER BY u.banned DESC, u.created_at DESC`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.Reader{}
|
||||
for rows.Next() {
|
||||
var r model.Reader
|
||||
var cnt int64
|
||||
if err := rows.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &r.Banned, &r.CreatedAt, &cnt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CommentCount = cnt
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func b2i(b bool) int64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func scanReader(sc interface{ Scan(...any) error }) (model.Reader, error) {
|
||||
var r model.Reader
|
||||
var banned int64
|
||||
err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.CreatedAt)
|
||||
r.Banned = banned == 1
|
||||
return r, err
|
||||
}
|
||||
|
||||
// ---------- comments ----------
|
||||
|
||||
const commentCols = `c.id, c.post_id, c.parent_id, c.root_id, c.user_id, c.body_md, c.body_html,
|
||||
c.status, c.is_deleted, c.created_at, c.edited_at,
|
||||
u.id, u.provider, u.handle, u.name, u.avatar_url, u.url, u.banned, u.created_at`
|
||||
|
||||
func scanComment(sc interface{ Scan(...any) error }) (model.Comment, error) {
|
||||
var c model.Comment
|
||||
var u model.Reader
|
||||
var banned int64
|
||||
err := sc.Scan(&c.ID, &c.PostID, &c.ParentID, &c.RootID, &c.UserID, &c.BodyMd, &c.BodyHTML,
|
||||
&c.Status, &c.IsDeleted, &c.CreatedAt, &c.EditedAt,
|
||||
&u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL, &banned, &u.CreatedAt)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.User = &model.Reader{ID: u.ID, Provider: u.Provider, Handle: u.Handle, Name: u.Name,
|
||||
AvatarURL: u.AvatarURL, URL: u.URL, Banned: banned == 1, CreatedAt: u.CreatedAt}
|
||||
c.Replies = []model.Comment{}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// CreateComment 新建评论;parent/root 归属与审核状态由调用方决定
|
||||
func (s *Store) CreateComment(c model.Comment) (model.Comment, error) {
|
||||
c.CreatedAt = now()
|
||||
q := s.db.Q(`INSERT INTO comments (post_id,user_id,parent_id,root_id,body_md,body_html,status,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)`)
|
||||
var id int64
|
||||
if s.db.Dialect == db.Postgres {
|
||||
err := s.db.QueryRow(q+` RETURNING id`, c.PostID, c.UserID, c.ParentID, c.RootID,
|
||||
c.BodyMd, c.BodyHTML, c.Status, c.CreatedAt).Scan(&id)
|
||||
if err != nil {
|
||||
return model.Comment{}, err
|
||||
}
|
||||
} else {
|
||||
res, err := s.db.Exec(q, c.PostID, c.UserID, c.ParentID, c.RootID, c.BodyMd, c.BodyHTML, c.Status, c.CreatedAt)
|
||||
if err != nil {
|
||||
return model.Comment{}, err
|
||||
}
|
||||
if id, err = res.LastInsertId(); err != nil {
|
||||
return model.Comment{}, err
|
||||
}
|
||||
}
|
||||
return s.GetComment(id)
|
||||
}
|
||||
|
||||
// GetComment 单条(含用户)
|
||||
func (s *Store) GetComment(id int64) (model.Comment, error) {
|
||||
c, err := scanComment(s.db.QueryRow(s.db.Q(`SELECT `+commentCols+` FROM comments c
|
||||
JOIN users u ON u.id = c.user_id WHERE c.id = ?`), id))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.Comment{}, ErrNotFound
|
||||
}
|
||||
return model.Comment{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListCommentsByPost 一篇文章的公开评论树:顶层可见 + 访客自己的待审;
|
||||
// 每条顶层内嵌全部可见回复。deleted 的行保留(墓碑:正文清空,楼层不塌)。
|
||||
func (s *Store) ListCommentsByPost(postID, viewerID int64, newestFirst bool) ([]model.Comment, error) {
|
||||
order := `ASC`
|
||||
if newestFirst {
|
||||
order = `DESC`
|
||||
}
|
||||
rows, err := s.db.Query(s.db.Q(`SELECT `+commentCols+` FROM comments c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.post_id = ? AND c.parent_id = 0
|
||||
AND (c.status = 'visible' OR (c.status = 'pending' AND c.user_id = ?))
|
||||
ORDER BY c.created_at `+order), postID, viewerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
roots := []model.Comment{}
|
||||
idx := map[int64]int{}
|
||||
for rows.Next() {
|
||||
c, err := scanComment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Replies = []model.Comment{}
|
||||
idx[c.ID] = len(roots)
|
||||
roots = append(roots, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 回复:可见的(+访客自己的待审),按时间正序挂在各自根下
|
||||
rows2, err := s.db.Query(s.db.Q(`SELECT `+commentCols+` FROM comments c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.post_id = ? AND c.parent_id <> 0
|
||||
AND (c.status = 'visible' OR (c.status = 'pending' AND c.user_id = ?))
|
||||
ORDER BY c.created_at ASC`), postID, viewerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows2.Close()
|
||||
for rows2.Next() {
|
||||
c, err := scanComment(rows2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if at, ok := idx[c.RootID]; ok {
|
||||
roots[at].Replies = append(roots[at].Replies, c)
|
||||
}
|
||||
}
|
||||
for i := range roots {
|
||||
roots[i].ReplyCount = len(roots[i].Replies)
|
||||
}
|
||||
return roots, rows2.Err()
|
||||
}
|
||||
|
||||
// ListCommentsAdmin 后台的评论列表(平铺,含用户与文章标题),status 过滤
|
||||
func (s *Store) ListCommentsAdmin(status string, page, size int) (model.CommentPage, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
where := ""
|
||||
var args []any
|
||||
switch status {
|
||||
case "pending", "visible":
|
||||
where = ` WHERE c.status = '` + status + `' AND c.is_deleted = 0`
|
||||
default:
|
||||
where = ` WHERE c.is_deleted = 0`
|
||||
}
|
||||
var total int
|
||||
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM comments c`+where), args...).Scan(&total); err != nil {
|
||||
return model.CommentPage{}, err
|
||||
}
|
||||
args = append(args, size, (page-1)*size)
|
||||
rows, err := s.db.Query(s.db.Q(`SELECT `+commentCols+`, COALESCE(p.title, '') FROM comments c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
JOIN posts p ON p.id = c.post_id`+where+`
|
||||
ORDER BY c.created_at DESC LIMIT ? OFFSET ?`), args...)
|
||||
if err != nil {
|
||||
return model.CommentPage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.Comment{}
|
||||
for rows.Next() {
|
||||
c, title, err := scanCommentAdmin(rows)
|
||||
if err != nil {
|
||||
return model.CommentPage{}, err
|
||||
}
|
||||
c.PostTitle = title
|
||||
out = append(out, c)
|
||||
}
|
||||
return model.CommentPage{Items: out, Total: total, Page: page, Size: size}, rows.Err()
|
||||
}
|
||||
|
||||
// CommentPage 后台评论管理的分页容器
|
||||
type modelCommentPageAlias = struct{}
|
||||
|
||||
func scanCommentAdmin(sc interface{ Scan(...any) error }) (model.Comment, string, error) {
|
||||
var c model.Comment
|
||||
var u model.Reader
|
||||
var title string
|
||||
var banned int64
|
||||
err := sc.Scan(&c.ID, &c.PostID, &c.ParentID, &c.RootID, &c.UserID, &c.BodyMd, &c.BodyHTML,
|
||||
&c.Status, &c.IsDeleted, &c.CreatedAt, &c.EditedAt,
|
||||
&u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL, &banned, &u.CreatedAt, &title)
|
||||
c.User = &model.Reader{ID: u.ID, Provider: u.Provider, Handle: u.Handle, Name: u.Name,
|
||||
AvatarURL: u.AvatarURL, URL: u.URL, Banned: banned == 1, CreatedAt: u.CreatedAt}
|
||||
return c, title, err
|
||||
}
|
||||
|
||||
// SetCommentStatus 审核通过 / 退回待审
|
||||
func (s *Store) SetCommentStatus(id int64, status string) error {
|
||||
res, err := s.db.Exec(s.db.Q(`UPDATE comments SET status = ? WHERE id = ?`), status, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteComment 软删:留壳(「该评论已删除」),正文清空
|
||||
func (s *Store) DeleteComment(id int64) error {
|
||||
res, err := s.db.Exec(s.db.Q(`UPDATE comments SET is_deleted = 1, body_md = '', body_html = '' WHERE id = ?`), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCommentBody 编辑后的正文回写
|
||||
func (s *Store) UpdateCommentBody(id int64, md, html string) error {
|
||||
res, err := s.db.Exec(s.db.Q(`UPDATE comments SET body_md = ?, body_html = ?, edited_at = ? WHERE id = ?`),
|
||||
md, html, now(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user