管理员免登录直接评论:后台会话映射站主身份(免审核、不可被禁言)+ 评论作者字段对齐后端 user

- 公开 API 解析顺序:读者 cookie → 管理员 cookie(provider=admin 站主读者,名字取站点作者名)
- 站主发评论跳过禁言与审核;后台禁言接口拒绝站主身份
- CommentItem 读 c.author 改为 c.user(后端实际字段),站主徽章按 provider 判断
This commit is contained in:
Sakurasan
2026-09-28 00:43:25 +08:00
parent 0929f880e3
commit 1a2db5ddac
5 changed files with 80 additions and 37 deletions
+5
View File
@@ -876,6 +876,11 @@ func (a *API) adminReaderBan(w http.ResponseWriter, r *http.Request, id int64) {
httpx.BadRequest(w, "invalid body") httpx.BadRequest(w, "invalid body")
return 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 err := a.Store.SetReaderBanned(id, in.Banned); err != nil {
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w) httpx.NotFound(w)
+5
View File
@@ -27,6 +27,11 @@ type API struct {
// 评论区读者会话与 GitHub OAuth(main.go 装配) // 评论区读者会话与 GitHub OAuth(main.go 装配)
ReaderSessions *auth.ReaderSessions ReaderSessions *auth.ReaderSessions
GH auth.GitHub GH auth.GitHub
// AdminSessions 是后台管理员会话验证器(admin.Sessions 满足它)。
// 管理员登录后台后无需再走读者登录即可用站主身份评论。
AdminSessions interface {
Verify(token string) (string, error)
}
} }
func (a *API) Routes() http.Handler { func (a *API) Routes() http.Handler {
+52 -21
View File
@@ -30,17 +30,50 @@ const (
editWindow = 10 * time.Minute editWindow = 10 * time.Minute
) )
// readerID 从会话 cookie 解出读者 ID;匿名返回 false // readerID 从会话 cookie 解出读者 ID;匿名返回 false。
// 后台管理员已登录(one_session)时直接映射为站主读者身份——
// 站主发评论不必再走一遍 GitHub 登录。
func (a *API) readerID(r *http.Request) (int64, bool) { func (a *API) readerID(r *http.Request) (int64, bool) {
ck, err := r.Cookie(auth.ReaderCookie) rd, ok, err := a.resolveReader(r)
if err != nil { if err != nil || !ok {
return 0, false return 0, false
} }
id, err := a.ReaderSessions.Verify(ck.Value) return rd.ID, true
if err != nil { }
return 0, false
// 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) { 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) { func (a *API) authMe(w http.ResponseWriter, r *http.Request) {
var user any // 匿名时 {user: null},前端判空即「未登录」 var user any // 匿名时 {user: null},前端判空即「未登录」
if id, ok := a.readerID(r); ok { if reader, ok, err := a.resolveReader(r); err == nil && ok {
if reader, err := a.Store.GetReader(id); err == nil {
user = map[string]any{ user = map[string]any{
"id": reader.ID, "name": reader.Name, "handle": reader.Handle, "id": reader.ID, "name": reader.Name, "handle": reader.Handle,
"avatar_url": reader.AvatarURL, "url": reader.URL, "avatar_url": reader.AvatarURL, "url": reader.URL,
"provider": reader.Provider, "is_owner": false, "banned": reader.Banned, "provider": reader.Provider, "is_owner": reader.Provider == "admin", "banned": reader.Banned,
}
} }
} }
httpx.OK(w, map[string]any{"user": user}) httpx.OK(w, map[string]any{"user": user})
@@ -180,19 +211,19 @@ func (a *API) listComments(w http.ResponseWriter, r *http.Request) {
} }
// createComment 发表评论(含回复)。登录 + 未禁言 + 评论开关开着; // createComment 发表评论(含回复)。登录 + 未禁言 + 评论开关开着;
// 审核开关开着时新评论进「待审」。 // 审核开关开着时新评论进「待审」。站主身份(管理员会话)不受禁言与审核约束。
func (a *API) createComment(w http.ResponseWriter, r *http.Request) { func (a *API) createComment(w http.ResponseWriter, r *http.Request) {
readerID, ok := a.readerID(r) reader, ok, err := a.resolveReader(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录后才能评论")
return
}
reader, err := a.Store.GetReader(readerID)
if err != nil { if err != nil {
httpx.Error(w, http.StatusUnauthorized, "登录已过期,刷新页面重新登录") httpx.Error(w, http.StatusUnauthorized, "登录已过期,刷新页面重新登录")
return 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, "你已被禁言,暂时无法评论") httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论")
return return
} }
@@ -246,11 +277,11 @@ func (a *API) createComment(w http.ResponseWriter, r *http.Request) {
} }
} }
status := "visible" status := "visible"
if st.CommentsReview { if st.CommentsReview && !isOwner {
status = "pending" status = "pending"
} }
c, err := a.Store.CreateComment(model.Comment{ 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, BodyMd: body, BodyHTML: render.Markdown(body), Status: status,
}) })
if err != nil { if err != nil {
+4 -1
View File
@@ -63,6 +63,8 @@ func main() {
// 评论区的读者会话(cookie one_reader),30 天有效 // 评论区的读者会话(cookie one_reader),30 天有效
readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour) readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour)
// 后台管理员会话(cookie one_session),前台评论也用它识别站主身份
adminSessions := admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)
public := &api.API{ public := &api.API{
Store: st, Store: st,
@@ -70,8 +72,9 @@ func main() {
Blobs: blobs, Blobs: blobs,
ReaderSessions: readerSessions, ReaderSessions: readerSessions,
GH: auth.GitHub{ClientID: cfg.GitHubClientID, ClientSecret: cfg.GitHubClientSecret}, 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
adminAPI.Blobs = blobs adminAPI.Blobs = blobs
@@ -25,14 +25,14 @@ const props = defineProps({
const s = props.api.s const s = props.api.s
const EDIT_WINDOW_MS = 10 * 60 * 1000 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 relativeTime = computed(() => relativeDate(props.c.created_at))
const isOwner = computed(() => !!(props.c.author && props.c.author.is_owner)) const isOwner = computed(() => !!(props.c.user && props.c.user.provider === 'admin'))
const isMine = computed(() => !!(reader.user && props.c.author && reader.user.id === props.c.author.id)) const isMine = computed(() => !!(reader.user && props.c.user && reader.user.id === props.c.user.id))
// 外链:优先作者自填的 url,其次 GitHub 主页 // 外链:优先作者自填的 url,其次 GitHub 主页
const authorHref = computed(() => { const authorHref = computed(() => {
const a = props.c.author const a = props.c.user
if (!a) return '' if (!a) return ''
if (a.url) return a.url if (a.url) return a.url
if (a.provider === 'github' && a.handle) return `https://github.com/${a.handle}` if (a.provider === 'github' && a.handle) return `https://github.com/${a.handle}`
@@ -41,12 +41,11 @@ const authorHref = computed(() => {
const canEdit = computed(() => { const canEdit = computed(() => {
if (props.c.is_deleted) return false if (props.c.is_deleted) return false
if (isOwner.value) return true
if (!isMine.value) return false if (!isMine.value) return false
const t = new Date(props.c.created_at).getTime() const t = new Date(props.c.created_at).getTime()
return Number.isFinite(t) && Date.now() - t <= EDIT_WINDOW_MS 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 replying = computed(() => s.replyTo && s.replyTo.id === props.c.id)
const editing = computed(() => s.editing && s.editing.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) { function replyNameOf(r) {
if (!r.parent_id || r.parent_id === props.c.id) return '' if (!r.parent_id || r.parent_id === props.c.id) return ''
const t = (props.c.replies || []).find((x) => x.id === r.parent_id) 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) || '' : ''
} }
</script> </script>
@@ -126,15 +125,15 @@ function replyNameOf(r) {
<li class="cm-item" :class="{ 'is-deleted': c.is_deleted, 'is-reply': depth > 0 }"> <li class="cm-item" :class="{ 'is-deleted': c.is_deleted, 'is-reply': depth > 0 }">
<div class="cm-av" :class="{ 'is-owner': isOwner }"> <div class="cm-av" :class="{ 'is-owner': isOwner }">
<img <img
v-if="c.author && c.author.avatar_url" v-if="c.user && c.user.avatar_url"
:src="c.author.avatar_url" :src="c.user.avatar_url"
:alt="`${name} 的头像`" :alt="`${name} 的头像`"
loading="lazy" loading="lazy"
> >
<span v-else class="cm-av-fb">{{ name.slice(0, 1) }}</span> <span v-else class="cm-av-fb">{{ name.slice(0, 1) }}</span>
<i v-if="c.author && c.author.provider" class="cm-pv" :title="PROVIDER_LABEL[c.author.provider] || c.author.provider"> <i v-if="c.user && c.user.provider" class="cm-pv" :title="PROVIDER_LABEL[c.user.provider] || c.user.provider">
<svg v-if="BRANDS[c.author.provider]" viewBox="0 0 24 24" aria-hidden="true"> <svg v-if="BRANDS[c.user.provider]" viewBox="0 0 24 24" aria-hidden="true">
<path :d="BRANDS[c.author.provider]" /> <path :d="BRANDS[c.user.provider]" />
</svg> </svg>
</i> </i>
</div> </div>