管理员免登录直接评论:后台会话映射站主身份(免审核、不可被禁言)+ 评论作者字段对齐后端 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")
|
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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user