设置页可配字数/编辑窗/分页与伪静态后缀;公开帖/板/用户链接走规范路径并 301。 Co-authored-by: Cursor <cursoragent@cursor.com>
944 lines
26 KiB
Go
944 lines
26 KiB
Go
package web
|
||
|
||
import (
|
||
"fmt"
|
||
"html"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"git.iioio.com/freefire/jiang13-forum/models"
|
||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||
"git.iioio.com/freefire/jiang13-forum/services"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// PostPageData 帖详情
|
||
type PostPageData struct {
|
||
PageChrome
|
||
PostID uint
|
||
PostHref string
|
||
PostPath string
|
||
PostTitle string
|
||
AuthorName string
|
||
AuthorID uint
|
||
AuthorHref string
|
||
BoardID uint
|
||
BoardHref string
|
||
BoardName string
|
||
Pinned bool // 展示用:全局或版内置顶
|
||
GlobalPinned bool
|
||
BoardPinned bool
|
||
Featured bool
|
||
EditLocked bool
|
||
PostTypeLabel string
|
||
CreatedLabel string
|
||
ViewCount int
|
||
LikeCount int
|
||
Liked bool
|
||
Favorited bool
|
||
BodyHTML string
|
||
CommentCount int
|
||
Comments []CommentView
|
||
CommentsLocked bool
|
||
CanEdit bool
|
||
CanViewRevisions bool
|
||
CanReportPost bool
|
||
IsAdmin bool
|
||
Status string
|
||
StatusLabel string
|
||
ShowModerationBanner bool
|
||
IsQuestion bool
|
||
QuestionResolved bool
|
||
CanToggleQuestion bool
|
||
Bounty *postBountyView
|
||
Lottery *postLotteryView
|
||
Poll *postPollView
|
||
}
|
||
|
||
type postBountyView struct {
|
||
Points int
|
||
Status string
|
||
StatusLabel string
|
||
CanRefund bool
|
||
RefundBlockReason string
|
||
AwardedCommentID uint
|
||
}
|
||
|
||
type postLotteryWinnerView struct {
|
||
UserID uint
|
||
Name string
|
||
FloorHint string
|
||
}
|
||
|
||
type postLotteryView struct {
|
||
WinnerCount int
|
||
Status string
|
||
StatusLabel string
|
||
ParticipantCount int
|
||
CanDraw bool
|
||
Winners []postLotteryWinnerView
|
||
}
|
||
|
||
type postPollOptionView struct {
|
||
ID uint
|
||
Text string
|
||
VoteCount int
|
||
Percent int
|
||
Selected bool
|
||
}
|
||
|
||
type postPollView struct {
|
||
Multi bool
|
||
MaxChoices int
|
||
Closed bool
|
||
EndsLabel string
|
||
TotalVotes int
|
||
ShowResults bool
|
||
HasVoted bool
|
||
CanVote bool
|
||
CanClose bool
|
||
Options []postPollOptionView
|
||
}
|
||
|
||
// CommentView 评论
|
||
type CommentView struct {
|
||
ID uint
|
||
Floor int
|
||
AuthorName string
|
||
AuthorID uint
|
||
CreatedLabel string
|
||
Content string
|
||
ContentHidden bool
|
||
ReplyToID uint
|
||
ReplyToFloor int
|
||
ReplyToAuthor string
|
||
LikeCount int
|
||
Liked bool
|
||
IsPrivate bool
|
||
CanReport bool
|
||
CanEdit bool
|
||
CanDelete bool
|
||
Status string
|
||
StatusLabel string
|
||
CanAwardBounty bool
|
||
IsBountyAward bool
|
||
}
|
||
|
||
// PostView GET /post/:id
|
||
func (d Deps) PostView(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
|
||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||
if err != nil || id == 0 {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
match := d.permalink().MatchPostPath(c.Request.URL.Path)
|
||
if d.redirectIfNotCanonical(c, match) {
|
||
return
|
||
}
|
||
post, err := d.Post.FindByID(uint(id))
|
||
if err != nil || !services.CanViewPost(post, ctx.UserID(), ctx.IsAdmin()) {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
if post.Status == models.ContentStatusPublished {
|
||
d.Post.RecordView(uint(id))
|
||
}
|
||
|
||
hasReplied := ctx.UserID() > 0 && d.Comment.HasUserReplied(uint(id), ctx.UserID())
|
||
body := services.ApplyPostContentGates(post.Content, post, ctx.UserID(), ctx.IsAdmin(), hasReplied)
|
||
|
||
canAwardBounty := post.PostType == models.PostTypeBounty &&
|
||
post.BountyStatus == models.BountyStatusOpen &&
|
||
(ctx.IsAdmin() || post.UserID == ctx.UserID())
|
||
|
||
comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil)
|
||
cv := make([]CommentView, 0, len(comments))
|
||
for _, cm := range comments {
|
||
an := strings.TrimSpace(cm.User.Nickname)
|
||
if an == "" {
|
||
an = cm.User.Username
|
||
}
|
||
view := CommentView{
|
||
ID: cm.ID, Floor: cm.Floor, AuthorName: an, AuthorID: cm.UserID,
|
||
CreatedLabel: formatTime(cm.CreatedAt),
|
||
Content: cm.Content, ContentHidden: cm.ContentHidden,
|
||
LikeCount: cm.LikeCount, Liked: cm.Liked, IsPrivate: cm.IsPrivate,
|
||
CanReport: ctx.IsSigned() && (cm.UserID == 0 || cm.UserID != ctx.UserID()),
|
||
CanEdit: !cm.ContentHidden && d.Comment.CanUserEditComment(&cm, ctx.UserID(), ctx.IsAdmin()),
|
||
CanDelete: !cm.ContentHidden && d.Comment.CanUserDeleteComment(&cm, ctx.UserID(), ctx.IsAdmin()),
|
||
Status: cm.Status,
|
||
CanAwardBounty: canAwardBounty && !cm.ContentHidden &&
|
||
cm.Status == models.ContentStatusPublished && cm.UserID != post.UserID && cm.UserID > 0,
|
||
IsBountyAward: post.BountyCommentID > 0 && cm.ID == post.BountyCommentID,
|
||
}
|
||
if cm.Status == models.ContentStatusPending || cm.Status == models.ContentStatusRejected {
|
||
if ctx.IsAdmin() || (cm.UserID > 0 && cm.UserID == ctx.UserID()) {
|
||
view.StatusLabel = contentStatusLabel(cm.Status)
|
||
}
|
||
}
|
||
if cm.ReplyTarget != nil {
|
||
view.ReplyToID = cm.ReplyTarget.ID
|
||
view.ReplyToFloor = cm.ReplyTarget.Floor
|
||
rn := strings.TrimSpace(cm.ReplyTarget.User.Nickname)
|
||
if rn == "" {
|
||
rn = cm.ReplyTarget.User.Username
|
||
}
|
||
if rn == "" {
|
||
rn = cm.ReplyTarget.GuestNick
|
||
}
|
||
view.ReplyToAuthor = rn
|
||
} else if cm.ReplyTo != nil {
|
||
view.ReplyToID = *cm.ReplyTo
|
||
}
|
||
cv = append(cv, view)
|
||
}
|
||
|
||
author := strings.TrimSpace(post.User.Nickname)
|
||
if author == "" {
|
||
author = post.User.Username
|
||
}
|
||
boardName := ""
|
||
if post.Board.ID > 0 {
|
||
boardName = post.Board.Name
|
||
}
|
||
|
||
chrome := d.chrome(ctx, post.Title+" · "+d.Settings.SiteBranding().Name, "", "post/body")
|
||
chrome.ActiveBoard = post.BoardID
|
||
|
||
statusLabel := ""
|
||
showBanner := false
|
||
if post.Status == models.ContentStatusPending || post.Status == models.ContentStatusRejected {
|
||
if ctx.IsAdmin() || post.UserID == ctx.UserID() {
|
||
statusLabel = contentStatusLabel(post.Status)
|
||
showBanner = true
|
||
}
|
||
}
|
||
|
||
var pollView *postPollView
|
||
if post.PostType == models.PostTypePoll {
|
||
if pv, err := services.GetPollView(post.ID, ctx.UserID()); err == nil && pv != nil {
|
||
pollView = mapPollView(pv, ctx.IsSigned(), ctx.IsAdmin() || post.UserID == ctx.UserID())
|
||
}
|
||
}
|
||
|
||
var bountyView *postBountyView
|
||
if post.PostType == models.PostTypeBounty {
|
||
isOperator := ctx.IsAdmin() || post.UserID == ctx.UserID()
|
||
canRefund, blockReason := services.CanRefundBounty(post, ctx.IsAdmin())
|
||
bv := &postBountyView{
|
||
Points: post.BountyPoints,
|
||
Status: post.BountyStatus,
|
||
StatusLabel: bountyStatusLabel(post.BountyStatus),
|
||
CanRefund: isOperator && canRefund,
|
||
AwardedCommentID: post.BountyCommentID,
|
||
}
|
||
if isOperator && !canRefund {
|
||
bv.RefundBlockReason = blockReason
|
||
}
|
||
bountyView = bv
|
||
}
|
||
|
||
var lotteryView *postLotteryView
|
||
if post.PostType == models.PostTypeLottery {
|
||
if lv, err := services.GetPostLotteryView(post); err == nil && lv != nil {
|
||
isOperator := ctx.IsAdmin() || post.UserID == ctx.UserID()
|
||
winners := make([]postLotteryWinnerView, 0, len(lv.Winners))
|
||
for _, w := range lv.Winners {
|
||
name := strings.TrimSpace(w.Nickname)
|
||
if name == "" {
|
||
name = w.Username
|
||
}
|
||
winners = append(winners, postLotteryWinnerView{UserID: w.UserID, Name: name})
|
||
}
|
||
lotteryView = &postLotteryView{
|
||
WinnerCount: lv.WinnerCount,
|
||
Status: lv.Status,
|
||
StatusLabel: lotteryStatusLabel(lv.Status),
|
||
ParticipantCount: lv.ParticipantCount,
|
||
CanDraw: isOperator && lv.Status != models.PostLotteryStatusDrawn,
|
||
Winners: winners,
|
||
}
|
||
}
|
||
}
|
||
|
||
ctx.HTML(http.StatusOK, "post", PostPageData{
|
||
PageChrome: chrome, PostID: post.ID,
|
||
PostHref: d.postHref(post.ID),
|
||
PostPath: url.QueryEscape(d.postHref(post.ID)),
|
||
PostTitle: post.Title, AuthorName: author, AuthorID: post.UserID,
|
||
AuthorHref: d.userHref(post.UserID),
|
||
BoardID: post.BoardID, BoardHref: d.boardHref(post.BoardID), BoardName: boardName,
|
||
Pinned: post.Pinned || post.BoardPinned, GlobalPinned: post.Pinned, BoardPinned: post.BoardPinned,
|
||
Featured: post.Featured, EditLocked: post.EditLocked,
|
||
PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt),
|
||
ViewCount: post.ViewCount, LikeCount: post.LikeCount,
|
||
Liked: d.Post.IsLiked(ctx.UserID(), post.ID), Favorited: d.Post.IsFavorited(ctx.UserID(), post.ID),
|
||
BodyHTML: body, CommentCount: len(cv), Comments: cv,
|
||
CommentsLocked: post.CommentsLocked,
|
||
CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()),
|
||
CanViewRevisions: canViewPostRevisions(post, ctx.UserID(), ctx.IsAdmin()),
|
||
CanReportPost: ctx.IsSigned() && post.UserID != ctx.UserID(),
|
||
IsAdmin: ctx.IsAdmin(),
|
||
Status: post.Status,
|
||
StatusLabel: statusLabel,
|
||
ShowModerationBanner: showBanner,
|
||
IsQuestion: post.PostType == models.PostTypeQuestion,
|
||
QuestionResolved: post.QuestionResolved,
|
||
CanToggleQuestion: post.PostType == models.PostTypeQuestion && (ctx.IsAdmin() || post.UserID == ctx.UserID()),
|
||
Bounty: bountyView,
|
||
Lottery: lotteryView,
|
||
Poll: pollView,
|
||
})
|
||
}
|
||
|
||
func mapPollView(pv *services.PollView, loggedIn, canClose bool) *postPollView {
|
||
hasVoted := len(pv.MyOptionIDs) > 0
|
||
selected := make(map[uint]bool, len(pv.MyOptionIDs))
|
||
for _, id := range pv.MyOptionIDs {
|
||
selected[id] = true
|
||
}
|
||
showResults := pv.Closed || hasVoted
|
||
opts := make([]postPollOptionView, 0, len(pv.Options))
|
||
for _, o := range pv.Options {
|
||
opts = append(opts, postPollOptionView{
|
||
ID: o.ID, Text: o.Text, VoteCount: o.VoteCount, Percent: o.Percent, Selected: selected[o.ID],
|
||
})
|
||
}
|
||
ends := ""
|
||
if pv.EndsAt != nil {
|
||
ends = formatTime(*pv.EndsAt)
|
||
}
|
||
return &postPollView{
|
||
Multi: pv.Multi, MaxChoices: pv.MaxChoices, Closed: pv.Closed,
|
||
EndsLabel: ends, TotalVotes: pv.TotalVotes,
|
||
ShowResults: showResults, HasVoted: hasVoted,
|
||
CanVote: loggedIn && !pv.Closed && !hasVoted,
|
||
CanClose: canClose && !pv.Closed,
|
||
Options: opts,
|
||
}
|
||
}
|
||
|
||
func contentStatusLabel(status string) string {
|
||
switch status {
|
||
case models.ContentStatusPending:
|
||
return "审核中"
|
||
case models.ContentStatusRejected:
|
||
return "已拒绝"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func lotteryStatusLabel(status string) string {
|
||
switch status {
|
||
case models.PostLotteryStatusDrawn:
|
||
return "已开奖"
|
||
case models.PostLotteryStatusOpen:
|
||
return "待开奖"
|
||
default:
|
||
return status
|
||
}
|
||
}
|
||
|
||
func bountyStatusLabel(status string) string {
|
||
switch status {
|
||
case models.BountyStatusOpen:
|
||
return "进行中"
|
||
case models.BountyStatusAwarded:
|
||
return "已采纳"
|
||
case models.BountyStatusRefunded:
|
||
return "已退回"
|
||
default:
|
||
return status
|
||
}
|
||
}
|
||
|
||
func postTypeLabel(t string) string {
|
||
switch t {
|
||
case models.PostTypeQuestion:
|
||
return "问答"
|
||
case models.PostTypePoll:
|
||
return "投票"
|
||
case models.PostTypeBounty:
|
||
return "悬赏"
|
||
case models.PostTypeLottery:
|
||
return "抽奖"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// PostComment POST 评论
|
||
func (d Deps) PostComment(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
if !ctx.CheckCSRF() {
|
||
ctx.SetFlash("无效请求,请重试")
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
id, err := parsePostID(c, d)
|
||
if err != nil {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
content := strings.TrimSpace(c.PostForm("content"))
|
||
if content == "" {
|
||
ctx.SetFlash("评论不能为空")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
|
||
return
|
||
}
|
||
var replyTo *uint
|
||
if raw := strings.TrimSpace(c.PostForm("reply_to")); raw != "" {
|
||
rid, err := strconv.ParseUint(raw, 10, 64)
|
||
if err == nil && rid > 0 {
|
||
v := uint(rid)
|
||
replyTo = &v
|
||
}
|
||
}
|
||
isPrivate := c.PostForm("is_private") == "1" || c.PostForm("is_private") == "on"
|
||
if d.Limiter != nil && !d.Limiter.Allow("comment", fmt.Sprintf("%d", ctx.UserID())) {
|
||
ctx.SetFlash("操作过于频繁,请稍后再试")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
|
||
return
|
||
}
|
||
safe := "<p>" + html.EscapeString(content) + "</p>"
|
||
cm, err := d.Comment.Create(services.CommentCreateInput{
|
||
PostID: id, UserID: ctx.UserID(), Content: safe,
|
||
ReplyTo: replyTo, IsPrivate: isPrivate,
|
||
})
|
||
if err != nil {
|
||
ctx.SetFlash(err.Error())
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
|
||
return
|
||
}
|
||
if d.Notify != nil && cm != nil {
|
||
switch cm.Status {
|
||
case models.ContentStatusPublished:
|
||
d.Notify.AsyncNotifyCommentPublished(cm)
|
||
d.Notify.AsyncNotifyCommentMentions(cm)
|
||
case models.ContentStatusPending:
|
||
ctx.SetFlash("评论已提交,审核通过后公开显示")
|
||
d.Notify.AsyncNotifyPendingComment(cm)
|
||
}
|
||
}
|
||
anchor := "#comments"
|
||
if cm != nil && cm.Floor > 0 {
|
||
anchor = fmt.Sprintf("#floor-%d", cm.Floor)
|
||
}
|
||
ctx.Redirect(fmt.Sprintf("/post/%d%s", id, anchor))
|
||
}
|
||
|
||
// PostCommentLike 评论点赞切换(PRG)
|
||
func (d Deps) PostCommentLike(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
if !ctx.CheckCSRF() {
|
||
ctx.SetFlash("无效请求")
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
postID, err := parsePostID(c, d)
|
||
if err != nil {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||
if err != nil || cid == 0 {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
cm, err := d.Comment.GetByID(uint(cid))
|
||
if err != nil || cm.PostID != postID {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
_, _, _ = d.Comment.ToggleLike(ctx.UserID(), uint(cid))
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#floor-%d", postID, cm.Floor))
|
||
}
|
||
|
||
// PostUnlock POST /post/:id/unlock — 积分解锁(JSON,供详情页渐进增强)
|
||
func (d Deps) PostUnlock(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
if !ctx.CheckCSRF() {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求,请重试"})
|
||
return
|
||
}
|
||
id, err := parsePostID(c, d)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||
return
|
||
}
|
||
var req struct {
|
||
BlockKey string `json:"block_key" form:"block_key"`
|
||
}
|
||
_ = c.ShouldBindJSON(&req)
|
||
if req.BlockKey == "" {
|
||
req.BlockKey = strings.TrimSpace(c.PostForm("block_key"))
|
||
}
|
||
if req.BlockKey == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 block_key"})
|
||
return
|
||
}
|
||
res, err := services.UnlockPointsBlock(ctx.UserID(), id, req.BlockKey)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"message": "解锁成功",
|
||
"unlock": res,
|
||
})
|
||
}
|
||
|
||
// PostLike 赞
|
||
func (d Deps) PostLike(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
if !ctx.CheckCSRF() {
|
||
ctx.SetFlash("无效请求")
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
id, err := parsePostID(c, d)
|
||
if err != nil {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
_, _ = d.Post.ToggleLike(ctx.UserID(), id)
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
}
|
||
|
||
// PostFavorite 收藏
|
||
func (d Deps) PostFavorite(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
if !ctx.CheckCSRF() {
|
||
ctx.SetFlash("无效请求")
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
id, err := parsePostID(c, d)
|
||
if err != nil {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
_, _ = d.Post.ToggleFavorite(ctx.UserID(), id)
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
}
|
||
|
||
// PostPollVote 投票提交
|
||
func (d Deps) PostPollVote(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
id, err := parsePostID(c, d)
|
||
if err != nil || id == 0 {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
if !ctx.CheckCSRF() {
|
||
ctx.SetFlash("无效请求,请重试")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
return
|
||
}
|
||
raw := c.PostFormArray("option_ids")
|
||
if len(raw) == 0 {
|
||
if one := strings.TrimSpace(c.PostForm("option_id")); one != "" {
|
||
raw = []string{one}
|
||
}
|
||
}
|
||
oids := make([]uint, 0, len(raw))
|
||
for _, s := range raw {
|
||
v, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64)
|
||
if err == nil && v > 0 {
|
||
oids = append(oids, uint(v))
|
||
}
|
||
}
|
||
if err := services.VotePoll(id, ctx.UserID(), oids); err != nil {
|
||
ctx.SetFlash(err.Error())
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
return
|
||
}
|
||
ctx.SetFlash("投票成功")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
}
|
||
|
||
// PostPollClose 结束投票
|
||
func (d Deps) PostPollClose(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
id, err := parsePostID(c, d)
|
||
if err != nil || id == 0 {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
if !ctx.CheckCSRF() {
|
||
ctx.SetFlash("无效请求,请重试")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
return
|
||
}
|
||
post, err := d.Post.FindByID(id)
|
||
if err != nil {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
if err := services.ClosePoll(id, ctx.UserID(), ctx.IsAdmin(), post.UserID); err != nil {
|
||
ctx.SetFlash(err.Error())
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
return
|
||
}
|
||
ctx.SetFlash("投票已结束")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
}
|
||
|
||
// PostQuestionResolvePost 标记问答帖已解决 / 未解决
|
||
func (d Deps) PostQuestionResolvePost(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
id, err := parsePostID(c, d)
|
||
if err != nil || id == 0 {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
if !ctx.CheckCSRF() {
|
||
ctx.SetFlash("无效请求,请重试")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
return
|
||
}
|
||
resolved := c.PostForm("resolved") == "1" || c.PostForm("resolved") == "on"
|
||
if err := d.Post.SetQuestionResolved(ctx.UserID(), id, ctx.IsAdmin(), resolved); err != nil {
|
||
ctx.SetFlash(err.Error())
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
return
|
||
}
|
||
if resolved {
|
||
ctx.SetFlash("已标为已解决")
|
||
} else {
|
||
ctx.SetFlash("已标为未解决")
|
||
}
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
}
|
||
|
||
// PostBountyAwardPost 采纳评论并发放悬赏
|
||
func (d Deps) PostBountyAwardPost(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
id, err := parsePostID(c, d)
|
||
if err != nil || id == 0 {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
if !ctx.CheckCSRF() {
|
||
ctx.SetFlash("无效请求,请重试")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
return
|
||
}
|
||
cid, _ := strconv.ParseUint(c.PostForm("comment_id"), 10, 64)
|
||
if err := services.AwardBounty(id, ctx.UserID(), ctx.IsAdmin(), uint(cid)); err != nil {
|
||
ctx.SetFlash(err.Error())
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
|
||
return
|
||
}
|
||
ctx.SetFlash("已采纳并发放悬赏")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
|
||
}
|
||
|
||
// PostBountyRefundPost 取消悬赏并退回积分
|
||
func (d Deps) PostBountyRefundPost(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
id, err := parsePostID(c, d)
|
||
if err != nil || id == 0 {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
if !ctx.CheckCSRF() {
|
||
ctx.SetFlash("无效请求,请重试")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
return
|
||
}
|
||
if err := services.RefundBounty(id, ctx.UserID(), ctx.IsAdmin()); err != nil {
|
||
ctx.SetFlash(err.Error())
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
return
|
||
}
|
||
ctx.SetFlash("悬赏已退回")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
}
|
||
|
||
// PostLotteryDrawPost 抽奖帖开奖
|
||
func (d Deps) PostLotteryDrawPost(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
id, err := parsePostID(c, d)
|
||
if err != nil || id == 0 {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
if !ctx.CheckCSRF() {
|
||
ctx.SetFlash("无效请求,请重试")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
return
|
||
}
|
||
if _, err := services.DrawPostLottery(id, ctx.UserID(), ctx.IsAdmin()); err != nil {
|
||
ctx.SetFlash(err.Error())
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
return
|
||
}
|
||
ctx.SetFlash("开奖完成")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||
}
|
||
|
||
type commentEditData struct {
|
||
PageChrome
|
||
PostID uint
|
||
CommentID uint
|
||
Floor int
|
||
Content string
|
||
}
|
||
|
||
// CommentEditGet 编辑评论页
|
||
func (d Deps) CommentEditGet(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
postID, cm, errMsg := d.loadEditableComment(ctx, c)
|
||
if errMsg != "" {
|
||
ctx.SetFlash(errMsg)
|
||
if postID > 0 {
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID))
|
||
return
|
||
}
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
chrome := d.chrome(ctx, "编辑评论 · "+d.Settings.SiteBranding().Name, "", "")
|
||
ctx.HTML(http.StatusOK, "post/comment_edit", commentEditData{
|
||
PageChrome: chrome,
|
||
PostID: postID,
|
||
CommentID: cm.ID,
|
||
Floor: cm.Floor,
|
||
Content: commentHTMLToPlain(cm.Content),
|
||
})
|
||
}
|
||
|
||
// CommentEditPost 保存评论编辑
|
||
func (d Deps) CommentEditPost(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
postID, cm, errMsg := d.loadEditableComment(ctx, c)
|
||
if errMsg != "" {
|
||
ctx.SetFlash(errMsg)
|
||
if postID > 0 {
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID))
|
||
return
|
||
}
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
if !ctx.CheckCSRF() {
|
||
d.renderCommentEdit(ctx, "无效请求,请重试", postID, cm, c.PostForm("content"))
|
||
return
|
||
}
|
||
plain := strings.TrimSpace(c.PostForm("content"))
|
||
if plain == "" {
|
||
d.renderCommentEdit(ctx, "评论不能为空", postID, cm, plain)
|
||
return
|
||
}
|
||
safe := "<p>" + html.EscapeString(plain) + "</p>"
|
||
_, enteredPending, err := d.Comment.Update(ctx.UserID(), cm.ID, ctx.IsAdmin(), ctx.SkipsModeration(), safe)
|
||
if err != nil {
|
||
d.renderCommentEdit(ctx, err.Error(), postID, cm, plain)
|
||
return
|
||
}
|
||
if enteredPending {
|
||
ctx.SetFlash("评论已更新,审核通过后公开显示")
|
||
} else {
|
||
ctx.SetFlash("评论已更新")
|
||
}
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#floor-%d", postID, cm.Floor))
|
||
}
|
||
|
||
// CommentDeletePost 软删评论
|
||
func (d Deps) CommentDeletePost(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
postID, err := parsePostID(c, d)
|
||
if err != nil || postID == 0 {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||
if err != nil || cid == 0 {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
if !ctx.CheckCSRF() {
|
||
ctx.SetFlash("无效请求,请重试")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID))
|
||
return
|
||
}
|
||
cm, err := d.Comment.GetByID(uint(cid))
|
||
if err != nil || cm.PostID != postID {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
if !d.Comment.CanUserDeleteComment(cm, ctx.UserID(), ctx.IsAdmin()) {
|
||
ctx.SetFlash(services.ErrPermissionDenied.Error())
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID))
|
||
return
|
||
}
|
||
if err := d.Comment.Delete(ctx.UserID(), cm.ID, ctx.IsAdmin()); err != nil {
|
||
ctx.SetFlash(err.Error())
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#floor-%d", postID, cm.Floor))
|
||
return
|
||
}
|
||
ctx.SetFlash("评论已删除")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID))
|
||
}
|
||
|
||
func (d Deps) loadEditableComment(ctx *webctx.Context, c *gin.Context) (postID uint, cm *models.Comment, errMsg string) {
|
||
pid, err := parsePostID(c, d)
|
||
if err != nil || pid == 0 {
|
||
return 0, nil, "帖子不存在"
|
||
}
|
||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||
if err != nil || cid == 0 {
|
||
return pid, nil, "评论不存在"
|
||
}
|
||
cm, err = d.Comment.GetByID(uint(cid))
|
||
if err != nil || cm.PostID != pid {
|
||
return pid, nil, "评论不存在"
|
||
}
|
||
if !d.Comment.CanUserEditComment(cm, ctx.UserID(), ctx.IsAdmin()) {
|
||
return pid, cm, "无权编辑该评论或已超过可编辑时限"
|
||
}
|
||
return pid, cm, ""
|
||
}
|
||
|
||
func (d Deps) renderCommentEdit(ctx *webctx.Context, errMsg string, postID uint, cm *models.Comment, content string) {
|
||
chrome := d.chrome(ctx, "编辑评论 · "+d.Settings.SiteBranding().Name, "", "")
|
||
chrome.Error = errMsg
|
||
ctx.HTML(http.StatusOK, "post/comment_edit", commentEditData{
|
||
PageChrome: chrome,
|
||
PostID: postID,
|
||
CommentID: cm.ID,
|
||
Floor: cm.Floor,
|
||
Content: content,
|
||
})
|
||
}
|
||
|
||
// commentHTMLToPlain 发评存的是转义后的 <p>…</p>,编辑时还原为纯文本
|
||
func commentHTMLToPlain(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
s = strings.TrimPrefix(s, "<p>")
|
||
s = strings.TrimSuffix(s, "</p>")
|
||
return html.UnescapeString(s)
|
||
}
|
||
|
||
type postRevisionRow struct {
|
||
ID uint
|
||
EditorName string
|
||
Title string
|
||
CreatedLabel string
|
||
}
|
||
|
||
type postRevisionsData struct {
|
||
PageChrome
|
||
PostID uint
|
||
PostTitle string
|
||
Revisions []postRevisionRow
|
||
}
|
||
|
||
type postRevisionDetailData struct {
|
||
PageChrome
|
||
PostID uint
|
||
PostTitle string
|
||
RevID uint
|
||
EditorName string
|
||
CreatedLabel string
|
||
Title string
|
||
Tags string
|
||
BodyHTML string
|
||
}
|
||
|
||
// PostRevisionsGet 帖子修订历史列表(作者或管理员)
|
||
func (d Deps) PostRevisionsGet(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
post, ok := d.loadRevisionPost(ctx, c)
|
||
if !ok {
|
||
return
|
||
}
|
||
revs, err := d.Post.ListRevisions(post.ID)
|
||
if err != nil {
|
||
ctx.SetFlash(err.Error())
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", post.ID))
|
||
return
|
||
}
|
||
rows := make([]postRevisionRow, 0, len(revs))
|
||
for _, r := range revs {
|
||
name := displayName(&r.Editor)
|
||
if name == "" {
|
||
name = fmt.Sprintf("用户 #%d", r.EditorID)
|
||
}
|
||
rows = append(rows, postRevisionRow{
|
||
ID: r.ID, EditorName: name, Title: r.Title, CreatedLabel: formatTime(r.CreatedAt),
|
||
})
|
||
}
|
||
chrome := d.chrome(ctx, "修订历史 · "+post.Title+" · "+d.Settings.SiteBranding().Name, "", "")
|
||
ctx.HTML(http.StatusOK, "post/revisions", postRevisionsData{
|
||
PageChrome: chrome, PostID: post.ID, PostTitle: post.Title, Revisions: rows,
|
||
})
|
||
}
|
||
|
||
// PostRevisionDetailGet 单条修订快照
|
||
func (d Deps) PostRevisionDetailGet(c *gin.Context) {
|
||
ctx := d.ctx(c)
|
||
post, ok := d.loadRevisionPost(ctx, c)
|
||
if !ok {
|
||
return
|
||
}
|
||
rid, err := strconv.ParseUint(c.Param("rid"), 10, 64)
|
||
if err != nil || rid == 0 {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
rev, err := d.Post.GetRevision(post.ID, uint(rid))
|
||
if err != nil {
|
||
d.render404(ctx)
|
||
return
|
||
}
|
||
name := displayName(&rev.Editor)
|
||
if name == "" {
|
||
name = fmt.Sprintf("用户 #%d", rev.EditorID)
|
||
}
|
||
chrome := d.chrome(ctx, "修订 #"+strconv.FormatUint(rid, 10)+" · "+post.Title+" · "+d.Settings.SiteBranding().Name, "", "")
|
||
ctx.HTML(http.StatusOK, "post/revision_detail", postRevisionDetailData{
|
||
PageChrome: chrome, PostID: post.ID, PostTitle: post.Title,
|
||
RevID: rev.ID, EditorName: name, CreatedLabel: formatTime(rev.CreatedAt),
|
||
Title: rev.Title, Tags: rev.Tags, BodyHTML: rev.Content,
|
||
})
|
||
}
|
||
|
||
func (d Deps) loadRevisionPost(ctx *webctx.Context, c *gin.Context) (*models.Post, bool) {
|
||
id, err := parsePostID(c, d)
|
||
if err != nil || id == 0 {
|
||
d.render404(ctx)
|
||
return nil, false
|
||
}
|
||
post, err := d.Post.FindByID(id)
|
||
if err != nil || !services.CanViewPost(post, ctx.UserID(), ctx.IsAdmin()) {
|
||
d.render404(ctx)
|
||
return nil, false
|
||
}
|
||
if !canViewPostRevisions(post, ctx.UserID(), ctx.IsAdmin()) {
|
||
ctx.SetFlash("无权查看编辑历史")
|
||
ctx.Redirect(fmt.Sprintf("/post/%d", post.ID))
|
||
return nil, false
|
||
}
|
||
return post, true
|
||
}
|
||
|
||
func canViewPostRevisions(post *models.Post, userID uint, isAdmin bool) bool {
|
||
if post == nil || userID == 0 {
|
||
return false
|
||
}
|
||
return isAdmin || post.UserID == userID
|
||
}
|
||
|
||
func parsePostID(c *gin.Context, d Deps) (uint, error) {
|
||
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
|
||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||
return uint(id), err
|
||
}
|