feat(hide): 密码可见隐藏块,发帖双栏预览与门禁体验修复
增加密码解锁与游客签名 cookie;发帖页对齐 1440 并默认双栏预览;修复 locked 解析、按钮对比度与回复聚焦。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
157
backend/service/hide_password_cookie.go
Normal file
157
backend/service/hide_password_cookie.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
hidePwdCookiePrefix = "j13_hp_"
|
||||
hidePwdCookieMaxAge = 30 * 24 * 3600 // 30 天
|
||||
hidePwdCookieVersion = "1"
|
||||
)
|
||||
|
||||
// HidePasswordCookie 帖子密码隐藏块解锁 cookie(游客可用)
|
||||
type HidePasswordCookie struct {
|
||||
secret []byte
|
||||
secure bool
|
||||
}
|
||||
|
||||
func NewHidePasswordCookie(jwtSecret string, secure bool) *HidePasswordCookie {
|
||||
sum := sha256.Sum256([]byte("j13-hide-pwd-v1:" + jwtSecret))
|
||||
return &HidePasswordCookie{secret: sum[:], secure: secure}
|
||||
}
|
||||
|
||||
func (h *HidePasswordCookie) cookieName(postID uint) string {
|
||||
return hidePwdCookiePrefix + strconv.FormatUint(uint64(postID), 10)
|
||||
}
|
||||
|
||||
// ReadUnlocked 读取某帖已解锁的隐藏块下标
|
||||
func (h *HidePasswordCookie) ReadUnlocked(c *gin.Context, postID uint) map[int]bool {
|
||||
out := map[int]bool{}
|
||||
if h == nil || c == nil {
|
||||
return out
|
||||
}
|
||||
raw, err := c.Cookie(h.cookieName(postID))
|
||||
if err != nil || raw == "" {
|
||||
return out
|
||||
}
|
||||
idxs, ok := h.verify(postID, raw)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
for _, i := range idxs {
|
||||
out[i] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// WriteUnlocked 写入/合并已解锁下标
|
||||
func (h *HidePasswordCookie) WriteUnlocked(c *gin.Context, postID uint, idxs []int) {
|
||||
if h == nil || c == nil || len(idxs) == 0 {
|
||||
return
|
||||
}
|
||||
merged := h.ReadUnlocked(c, postID)
|
||||
for _, i := range idxs {
|
||||
merged[i] = true
|
||||
}
|
||||
list := make([]int, 0, len(merged))
|
||||
for i := range merged {
|
||||
list = append(list, i)
|
||||
}
|
||||
sort.Ints(list)
|
||||
val := h.sign(postID, list)
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: h.cookieName(postID),
|
||||
Value: val,
|
||||
Path: "/",
|
||||
MaxAge: hidePwdCookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: h.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HidePasswordCookie) sign(postID uint, idxs []int) string {
|
||||
payload := fmt.Sprintf("%s|%d|%s|%d",
|
||||
hidePwdCookieVersion,
|
||||
postID,
|
||||
joinInts(idxs),
|
||||
time.Now().Add(hidePwdCookieMaxAge*time.Second).Unix(),
|
||||
)
|
||||
mac := hmac.New(sha256.New, h.secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig
|
||||
}
|
||||
|
||||
func (h *HidePasswordCookie) verify(postID uint, raw string) ([]int, bool) {
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) != 2 {
|
||||
return nil, false
|
||||
}
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
payload := string(payloadBytes)
|
||||
mac := hmac.New(sha256.New, h.secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
expect := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expect), []byte(parts[1])) {
|
||||
return nil, false
|
||||
}
|
||||
fields := strings.Split(payload, "|")
|
||||
if len(fields) != 4 || fields[0] != hidePwdCookieVersion {
|
||||
return nil, false
|
||||
}
|
||||
pid, err := strconv.ParseUint(fields[1], 10, 64)
|
||||
if err != nil || uint(pid) != postID {
|
||||
return nil, false
|
||||
}
|
||||
exp, err := strconv.ParseInt(fields[3], 10, 64)
|
||||
if err != nil || time.Now().Unix() > exp {
|
||||
return nil, false
|
||||
}
|
||||
return parseInts(fields[2]), true
|
||||
}
|
||||
|
||||
func joinInts(idxs []int) string {
|
||||
if len(idxs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, n := range idxs {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(strconv.Itoa(n))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func parseInts(s string) []int {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -426,8 +426,9 @@ type UpdatePostInput struct {
|
||||
}
|
||||
|
||||
// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制)。
|
||||
// pwdUnlocked 为密码隐藏块已解锁下标(来自签名 cookie);可为 nil。
|
||||
// 已软删帖返回 tombstone(无正文/附件);已硬删或不存在返回 ErrPostNotFound。
|
||||
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*PostDetail, error) {
|
||||
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) (*PostDetail, error) {
|
||||
var post model.Post
|
||||
err := s.db.Preload("Board").Preload("User").First(&post, id).Error
|
||||
if err != nil {
|
||||
@@ -461,7 +462,7 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto
|
||||
post.ViewCount++
|
||||
|
||||
detail := buildPostDetail(&post)
|
||||
sanitized, fullyLocked, hint := s.sanitizePostContent(&post, viewerID, loadActor)
|
||||
sanitized, fullyLocked, hint := s.sanitizePostContent(&post, viewerID, loadActor, pwdUnlocked)
|
||||
detail.Content = sanitized
|
||||
detail.ContentLocked = fullyLocked
|
||||
detail.AccessHint = hint
|
||||
@@ -517,8 +518,8 @@ func buildPostDetail(post *model.Post) *PostDetail {
|
||||
}
|
||||
|
||||
// sanitizePostContent 按读者能力对正文 :::hide 块脱敏;fullyLocked 表示无可见正文。
|
||||
func (s *PostService) sanitizePostContent(post *model.Post, viewerID uint, loadActor func() *Actor) (content string, fullyLocked bool, hint string) {
|
||||
caps := s.buildHideViewerCaps(post, viewerID, loadActor)
|
||||
func (s *PostService) sanitizePostContent(post *model.Post, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) (content string, fullyLocked bool, hint string) {
|
||||
caps := s.buildHideViewerCaps(post, viewerID, loadActor, pwdUnlocked)
|
||||
sanitized, fully := markdown.SanitizeForViewer(post.Content, caps)
|
||||
if !fully {
|
||||
return sanitized, false, ""
|
||||
@@ -527,10 +528,9 @@ func (s *PostService) sanitizePostContent(post *model.Post, viewerID uint, loadA
|
||||
switch access {
|
||||
case model.ContentAccessPoints:
|
||||
return sanitized, true, "支付积分后可见隐藏内容"
|
||||
case model.ContentAccessPassword:
|
||||
return sanitized, true, "输入密码后可见隐藏内容"
|
||||
case model.ContentAccessMixed:
|
||||
if post.AccessPoints > 0 {
|
||||
return sanitized, true, "满足条件后可见隐藏内容"
|
||||
}
|
||||
return sanitized, true, "满足条件后可见隐藏内容"
|
||||
case model.ContentAccessReply:
|
||||
return sanitized, true, "回复本帖后可见隐藏内容"
|
||||
@@ -541,8 +541,8 @@ func (s *PostService) sanitizePostContent(post *model.Post, viewerID uint, loadA
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostService) buildHideViewerCaps(post *model.Post, viewerID uint, loadActor func() *Actor) markdown.ViewerCaps {
|
||||
caps := markdown.ViewerCaps{}
|
||||
func (s *PostService) buildHideViewerCaps(post *model.Post, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) markdown.ViewerCaps {
|
||||
caps := markdown.ViewerCaps{PasswordUnlocked: pwdUnlocked}
|
||||
if viewerID > 0 && post.UserID == viewerID {
|
||||
caps.Bypass = true
|
||||
caps.LoggedIn = true
|
||||
@@ -625,6 +625,26 @@ func (s *PostService) listAttachmentDTOs(postID, viewerID, authorID uint) ([]Pos
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnlockByPassword 校验密码,返回匹配的隐藏块下标(调用方写 cookie)
|
||||
func (s *PostService) UnlockByPassword(postID uint, password string) (matched []int, err error) {
|
||||
var post model.Post
|
||||
if err := s.db.Select("id", "content", "status").First(&post, postID).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
password = strings.TrimSpace(password)
|
||||
if password == "" {
|
||||
return nil, errors.New("请输入密码")
|
||||
}
|
||||
hit, err := markdown.MatchPasswordBlocks(post.Content, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(hit) == 0 {
|
||||
return nil, errors.New("密码错误")
|
||||
}
|
||||
return hit, nil
|
||||
}
|
||||
|
||||
// UnlockContent 积分解锁正文隐藏块(一次支付解锁本帖全部积分块)
|
||||
func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
|
||||
var post model.Post
|
||||
|
||||
@@ -1519,7 +1519,7 @@ func (s *PostService) loadManageablePost(actor *Actor, userID, postID uint) (*mo
|
||||
|
||||
func (s *PostService) detailAfterInteract(post *model.Post, viewerID uint) (*PostDetail, error) {
|
||||
detail := buildPostDetail(post)
|
||||
sanitized, fullyLocked, hint := s.sanitizePostContent(post, viewerID, nil)
|
||||
sanitized, fullyLocked, hint := s.sanitizePostContent(post, viewerID, nil, nil)
|
||||
detail.Content = sanitized
|
||||
detail.ContentLocked = fullyLocked
|
||||
detail.AccessHint = hint
|
||||
|
||||
@@ -62,24 +62,26 @@ func (r *RateLimiter) Allow(key string) bool {
|
||||
|
||||
// 速率限制类型常量
|
||||
const (
|
||||
RateLogin = "login"
|
||||
RateRegister = "register"
|
||||
RatePost = "post"
|
||||
RateComment = "comment"
|
||||
RateChat = "chat" // 群聊发消息
|
||||
RateUpload = "upload" // 帖子插图等上传
|
||||
RateInteract = "interact" // 投票/抽奖/解锁等互动
|
||||
RateLogin = "login"
|
||||
RateRegister = "register"
|
||||
RatePost = "post"
|
||||
RateComment = "comment"
|
||||
RateChat = "chat" // 群聊发消息
|
||||
RateUpload = "upload" // 帖子插图等上传
|
||||
RateInteract = "interact" // 投票/抽奖/解锁等互动
|
||||
RateHidePassword = "hide_password" // 密码隐藏块尝试
|
||||
)
|
||||
|
||||
// DefaultRateLimiter 创建默认速率限制器
|
||||
func DefaultRateLimiter() *RateLimiter {
|
||||
rl := NewRateLimiter()
|
||||
rl.SetLimit(RateLogin, 20) // 登录 20/分钟
|
||||
rl.SetLimit(RateRegister, 10) // 注册 10/分钟
|
||||
rl.SetLimit(RatePost, 10) // 发帖 10/分钟
|
||||
rl.SetLimit(RateComment, 30) // 评论 30/分钟
|
||||
rl.SetLimit(RateChat, 30) // 群聊消息 30/分钟
|
||||
rl.SetLimit(RateUpload, 20) // 图片上传 20/分钟
|
||||
rl.SetLimit(RateInteract, 40) // 互动 40/分钟
|
||||
rl.SetLimit(RateLogin, 20) // 登录 20/分钟
|
||||
rl.SetLimit(RateRegister, 10) // 注册 10/分钟
|
||||
rl.SetLimit(RatePost, 10) // 发帖 10/分钟
|
||||
rl.SetLimit(RateComment, 30) // 评论 30/分钟
|
||||
rl.SetLimit(RateChat, 30) // 群聊消息 30/分钟
|
||||
rl.SetLimit(RateUpload, 20) // 图片上传 20/分钟
|
||||
rl.SetLimit(RateInteract, 40) // 互动 40/分钟
|
||||
rl.SetLimit(RateHidePassword, 20) // 密码尝试 20/分钟(按 IP)
|
||||
return rl
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user