diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go index 1403e2d..4332296 100644 --- a/backend/internal/admin/api.go +++ b/backend/internal/admin/api.go @@ -876,6 +876,11 @@ func (a *API) adminReaderBan(w http.ResponseWriter, r *http.Request, id int64) { httpx.BadRequest(w, "invalid body") return } + // 站主身份(管理员会话映射出来的读者)不允许禁言 + if rd, err := a.Store.GetReader(id); err == nil && rd.Provider == "admin" { + httpx.BadRequest(w, "不能禁言站主身份") + return + } if err := a.Store.SetReaderBanned(id, in.Banned); err != nil { if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 1ea64a1..0b87f5d 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -27,6 +27,11 @@ type API struct { // 评论区读者会话与 GitHub OAuth(main.go 装配) ReaderSessions *auth.ReaderSessions GH auth.GitHub + // AdminSessions 是后台管理员会话验证器(admin.Sessions 满足它)。 + // 管理员登录后台后无需再走读者登录即可用站主身份评论。 + AdminSessions interface { + Verify(token string) (string, error) + } } func (a *API) Routes() http.Handler { diff --git a/backend/internal/api/comments.go b/backend/internal/api/comments.go index 01d1d1d..1921166 100644 --- a/backend/internal/api/comments.go +++ b/backend/internal/api/comments.go @@ -30,17 +30,50 @@ const ( editWindow = 10 * time.Minute ) -// readerID 从会话 cookie 解出读者 ID;匿名返回 false +// readerID 从会话 cookie 解出读者 ID;匿名返回 false。 +// 后台管理员已登录(one_session)时直接映射为站主读者身份—— +// 站主发评论不必再走一遍 GitHub 登录。 func (a *API) readerID(r *http.Request) (int64, bool) { - ck, err := r.Cookie(auth.ReaderCookie) - if err != nil { + rd, ok, err := a.resolveReader(r) + if err != nil || !ok { return 0, false } - id, err := a.ReaderSessions.Verify(ck.Value) - if err != nil { - 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 + } + } } - return id, true + 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) authProviders(w http.ResponseWriter, r *http.Request) { @@ -55,13 +88,11 @@ func (a *API) authProviders(w http.ResponseWriter, r *http.Request) { 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, - } + 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}) @@ -180,19 +211,19 @@ func (a *API) listComments(w http.ResponseWriter, r *http.Request) { } // 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) + reader, ok, err := a.resolveReader(r) if err != nil { httpx.Error(w, http.StatusUnauthorized, "登录已过期,刷新页面重新登录") return } - if reader.Banned { + if !ok { + httpx.Error(w, http.StatusUnauthorized, "登录后才能评论") + return + } + isOwner := reader.Provider == "admin" + if reader.Banned && !isOwner { httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论") return } @@ -246,11 +277,11 @@ func (a *API) createComment(w http.ResponseWriter, r *http.Request) { } } status := "visible" - if st.CommentsReview { + if st.CommentsReview && !isOwner { status = "pending" } c, err := a.Store.CreateComment(model.Comment{ - PostID: in.PostID, UserID: readerID, ParentID: in.ParentID, RootID: root, + PostID: in.PostID, UserID: reader.ID, ParentID: in.ParentID, RootID: root, BodyMd: body, BodyHTML: render.Markdown(body), Status: status, }) if err != nil { diff --git a/backend/main.go b/backend/main.go index 362b2f4..363187c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -63,6 +63,8 @@ func main() { // 评论区的读者会话(cookie one_reader),30 天有效 readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour) + // 后台管理员会话(cookie one_session),前台评论也用它识别站主身份 + adminSessions := admin.NewSessions(cfg.SessionSec, 7*24*time.Hour) public := &api.API{ Store: st, @@ -70,8 +72,9 @@ func main() { Blobs: blobs, ReaderSessions: readerSessions, GH: auth.GitHub{ClientID: cfg.GitHubClientID, ClientSecret: cfg.GitHubClientSecret}, + AdminSessions: adminSessions, } - adminAPI := admin.NewAPI(st, cfg, admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)) + adminAPI := admin.NewAPI(st, cfg, adminSessions) adminAPI.Blobs = blobs adminAPI.Blobs = blobs diff --git a/frontend/src/components/comments/CommentItem.vue b/frontend/src/components/comments/CommentItem.vue index 3bb4094..cab66fe 100644 --- a/frontend/src/components/comments/CommentItem.vue +++ b/frontend/src/components/comments/CommentItem.vue @@ -25,14 +25,14 @@ const props = defineProps({ const s = props.api.s const EDIT_WINDOW_MS = 10 * 60 * 1000 -const name = computed(() => (props.c.author && props.c.author.name) || '已注销用户') +const name = computed(() => (props.c.user && props.c.user.name) || '已注销用户') const relativeTime = computed(() => relativeDate(props.c.created_at)) -const isOwner = computed(() => !!(props.c.author && props.c.author.is_owner)) -const isMine = computed(() => !!(reader.user && props.c.author && reader.user.id === props.c.author.id)) +const isOwner = computed(() => !!(props.c.user && props.c.user.provider === 'admin')) +const isMine = computed(() => !!(reader.user && props.c.user && reader.user.id === props.c.user.id)) // 外链:优先作者自填的 url,其次 GitHub 主页 const authorHref = computed(() => { - const a = props.c.author + const a = props.c.user if (!a) return '' if (a.url) return a.url if (a.provider === 'github' && a.handle) return `https://github.com/${a.handle}` @@ -41,12 +41,11 @@ const authorHref = computed(() => { const canEdit = computed(() => { if (props.c.is_deleted) return false - if (isOwner.value) return true if (!isMine.value) return false const t = new Date(props.c.created_at).getTime() return Number.isFinite(t) && Date.now() - t <= EDIT_WINDOW_MS }) -const canDelete = computed(() => !props.c.is_deleted && (isMine.value || isOwner.value)) +const canDelete = computed(() => !props.c.is_deleted && isMine.value) const replying = computed(() => s.replyTo && s.replyTo.id === props.c.id) const editing = computed(() => s.editing && s.editing.id === props.c.id) @@ -118,7 +117,7 @@ const atInline = computed( function replyNameOf(r) { if (!r.parent_id || r.parent_id === props.c.id) return '' const t = (props.c.replies || []).find((x) => x.id === r.parent_id) - return t ? (t.author && t.author.name) || '' : '' + return t ? (t.user && t.user.name) || '' : '' } @@ -126,15 +125,15 @@ function replyNameOf(r) {