管理员免登录直接评论:后台会话映射站主身份(免审核、不可被禁言)+ 评论作者字段对齐后端 user
- 公开 API 解析顺序:读者 cookie → 管理员 cookie(provider=admin 站主读者,名字取站点作者名) - 站主发评论跳过禁言与审核;后台禁言接口拒绝站主身份 - CommentItem 读 c.author 改为 c.user(后端实际字段),站主徽章按 provider 判断
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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) || '' : ''
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -126,15 +125,15 @@ function replyNameOf(r) {
|
||||
<li class="cm-item" :class="{ 'is-deleted': c.is_deleted, 'is-reply': depth > 0 }">
|
||||
<div class="cm-av" :class="{ 'is-owner': isOwner }">
|
||||
<img
|
||||
v-if="c.author && c.author.avatar_url"
|
||||
:src="c.author.avatar_url"
|
||||
v-if="c.user && c.user.avatar_url"
|
||||
:src="c.user.avatar_url"
|
||||
:alt="`${name} 的头像`"
|
||||
loading="lazy"
|
||||
>
|
||||
<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">
|
||||
<svg v-if="BRANDS[c.author.provider]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path :d="BRANDS[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.user.provider]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path :d="BRANDS[c.user.provider]" />
|
||||
</svg>
|
||||
</i>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user