feat: enhance security with IP banning, ownership checks, and SSRF protection
All checks were successful
Build Backend / build (push) Successful in 1m56s
Build Backend / build-docker (push) Successful in 1m15s

Add comprehensive security improvements across the application:

- **IP-based login protection**: Implement IP ban system tracking login failures, auto-banning after threshold exceeded
- **Ownership verification**: Add userID parameter to Delete/Update operations for posts and comments to prevent unauthorized modifications
- **SSRF protection**: Add URL and resolved host validation for image URLs in chat and OpenAI client
- **SQL injection prevention**: Add EscapeLikeWildcard utility to escape special characters in LIKE queries
- **HTTP security**: Configure server timeouts and add security headers middleware
- **Rate limiting**: Refactor to support configurable duration and per-endpoint rate limits for auth routes
- **Error handling**: Standardize error responses using HandleError and proper error types
This commit is contained in:
lafay
2026-04-30 12:26:25 +08:00
parent 67a5660952
commit b2b55ea52d
24 changed files with 517 additions and 80 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"time"
"with_you/internal/config"
"with_you/internal/grpc/runner"
@@ -72,8 +73,12 @@ func (a *App) Start(ctx context.Context) error {
)
a.Server = &http.Server{
Addr: addr,
Handler: a.Router.Engine(),
Addr: addr,
Handler: a.Router.Engine(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// 4. 启动 HTTP 服务器

View File

@@ -178,9 +178,9 @@ func (h *CommentHandler) Update(c *gin.Context) {
comment.Content = req.Content
err = h.commentService.Update(c.Request.Context(), comment)
err = h.commentService.Update(c.Request.Context(), userID, comment)
if err != nil {
response.InternalServerError(c, "failed to update comment")
response.HandleError(c, err, "failed to update comment")
return
}
@@ -208,9 +208,9 @@ func (h *CommentHandler) Delete(c *gin.Context) {
return
}
err = h.commentService.Delete(c.Request.Context(), id)
err = h.commentService.Delete(c.Request.Context(), userID, id)
if err != nil {
response.InternalServerError(c, "failed to delete comment")
response.HandleError(c, err, "failed to delete comment")
return
}

View File

@@ -56,7 +56,7 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID)
if err != nil {
response.InternalServerError(c, "failed to mark as read")
response.HandleError(c, err, "failed to mark as read")
return
}
@@ -109,7 +109,7 @@ func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID)
if err != nil {
response.InternalServerError(c, "failed to delete notification")
response.HandleError(c, err, "failed to delete notification")
return
}

View File

@@ -496,9 +496,9 @@ func (h *PostHandler) Delete(c *gin.Context) {
return
}
err = h.postService.Delete(c.Request.Context(), id)
err = h.postService.Delete(c.Request.Context(), userID, id)
if err != nil {
response.InternalServerError(c, "failed to delete post")
response.HandleError(c, err, "failed to delete post")
return
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"with_you/internal/dto"
"with_you/internal/middleware"
"with_you/internal/model"
"with_you/internal/pkg/response"
"with_you/internal/service"
@@ -153,10 +154,13 @@ func (h *UserHandler) Login(c *gin.Context) {
user, err := h.userService.Login(c.Request.Context(), account, req.Password)
if err != nil {
middleware.RecordLoginFailure(c.ClientIP())
response.HandleError(c, err, "failed to login")
return
}
middleware.ResetLoginFailures(c.ClientIP())
// 生成Token
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)

View File

@@ -75,8 +75,29 @@ func (rl *RateLimiter) isAllowed(key string) bool {
return true
}
func (rl *RateLimiter) count(key string) int {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
times := rl.requests[key]
var valid []time.Time
for _, t := range times {
if now.Sub(t) < rl.window {
valid = append(valid, t)
}
}
rl.requests[key] = valid
return len(valid)
}
func RateLimit(requestsPerMinute int) gin.HandlerFunc {
limiter := NewRateLimiter(requestsPerMinute, time.Minute)
return RateLimitWithDuration(requestsPerMinute, time.Minute)
}
func RateLimitWithDuration(limit int, window time.Duration) gin.HandlerFunc {
limiter := NewRateLimiter(limit, window)
return func(c *gin.Context) {
ip := c.ClientIP()
@@ -95,7 +116,11 @@ func RateLimit(requestsPerMinute int) gin.HandlerFunc {
}
func RateLimitWithKey(requestsPerMinute int, keyFunc func(c *gin.Context) string) gin.HandlerFunc {
limiter := NewRateLimiter(requestsPerMinute, time.Minute)
return RateLimitWithDurationAndKey(requestsPerMinute, time.Minute, keyFunc)
}
func RateLimitWithDurationAndKey(limit int, window time.Duration, keyFunc func(c *gin.Context) string) gin.HandlerFunc {
limiter := NewRateLimiter(limit, window)
return func(c *gin.Context) {
key := keyFunc(c)
@@ -115,3 +140,176 @@ func RateLimitWithKey(requestsPerMinute int, keyFunc func(c *gin.Context) string
c.Next()
}
}
// ============================================================
// IP 封禁与登录失败追踪
// ============================================================
const (
DefaultMaxLoginFailures = 10
DefaultBanDuration = 15 * time.Minute
DefaultFailureWindow = 15 * time.Minute
)
type ipBanEntry struct {
unbanAt time.Time
}
type loginFailureEntry struct {
count int
firstAt time.Time
lastAt time.Time
}
type IPBanStore struct {
mu sync.RWMutex
bans map[string]*ipBanEntry
failures map[string]*loginFailureEntry
maxFailures int
banDuration time.Duration
failWindow time.Duration
}
var defaultIPBanStore = NewIPBanStore(DefaultMaxLoginFailures, DefaultBanDuration, DefaultFailureWindow)
func NewIPBanStore(maxFailures int, banDuration, failWindow time.Duration) *IPBanStore {
store := &IPBanStore{
bans: make(map[string]*ipBanEntry),
failures: make(map[string]*loginFailureEntry),
maxFailures: maxFailures,
banDuration: banDuration,
failWindow: failWindow,
}
go store.cleanupLoop()
return store
}
func (s *IPBanStore) cleanupLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
s.cleanup()
}
}
func (s *IPBanStore) cleanup() {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for ip, entry := range s.bans {
if now.After(entry.unbanAt) {
delete(s.bans, ip)
}
}
for ip, entry := range s.failures {
if now.Sub(entry.firstAt) > s.failWindow && now.Sub(entry.lastAt) > s.failWindow {
delete(s.failures, ip)
}
}
}
func (s *IPBanStore) IsBanned(ip string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
entry, exists := s.bans[ip]
if !exists {
return false
}
return time.Now().Before(entry.unbanAt)
}
func (s *IPBanStore) RecordFailure(ip string) bool {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
entry, exists := s.failures[ip]
if !exists || now.Sub(entry.firstAt) > s.failWindow {
entry = &loginFailureEntry{
count: 1,
firstAt: now,
lastAt: now,
}
s.failures[ip] = entry
return false
}
entry.count++
entry.lastAt = now
if entry.count >= s.maxFailures {
s.bans[ip] = &ipBanEntry{
unbanAt: now.Add(s.banDuration),
}
delete(s.failures, ip)
return true
}
return false
}
func (s *IPBanStore) ResetFailures(ip string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.failures, ip)
}
func RecordLoginFailure(ip string) bool {
return defaultIPBanStore.RecordFailure(ip)
}
func ResetLoginFailures(ip string) {
defaultIPBanStore.ResetFailures(ip)
}
func IPBanMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if defaultIPBanStore.IsBanned(ip) {
c.JSON(http.StatusTooManyRequests, gin.H{
"code": 429,
"message": "your IP has been temporarily banned due to too many failed attempts, please try again later",
})
c.Abort()
return
}
c.Next()
}
}
// ============================================================
// 预配置的速率限制中间件
// ============================================================
func LoginRateLimit() gin.HandlerFunc {
return RateLimitWithDuration(10, time.Minute)
}
func RegisterRateLimit() gin.HandlerFunc {
return RateLimitWithDuration(5, time.Hour)
}
func PasswordResetRateLimit() gin.HandlerFunc {
return RateLimitWithDuration(3, time.Hour)
}
func SendCodeRateLimit() gin.HandlerFunc {
return RateLimitWithDuration(5, time.Hour)
}
func GlobalAuthRateLimit() gin.HandlerFunc {
return RateLimitWithDuration(30, time.Minute)
}

View File

@@ -0,0 +1,20 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("X-XSS-Protection", "0")
c.Header("Cross-Origin-Opener-Policy", "same-origin")
c.Header("Cross-Origin-Resource-Policy", "same-origin")
c.Next()
}
}

View File

@@ -0,0 +1,29 @@
package netutil
import (
"net"
"net/http"
"time"
)
func NewSafeHTTPClient(timeout time.Duration) *http.Client {
safeDialer := &SafeDialer{
Dialer: net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
},
}
transport := &http.Transport{
DialContext: safeDialer.DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &http.Client{
Timeout: timeout,
Transport: transport,
}
}

View File

@@ -0,0 +1,134 @@
package netutil
import (
"context"
"fmt"
"net"
"net/url"
"strings"
)
func isPrivateIP(ip net.IP) bool {
privateRanges := []struct {
network *net.IPNet
}{
{mustParseCIDR("10.0.0.0/8")},
{mustParseCIDR("172.16.0.0/12")},
{mustParseCIDR("192.168.0.0/16")},
{mustParseCIDR("127.0.0.0/8")},
{mustParseCIDR("169.254.0.0/16")},
{mustParseCIDR("0.0.0.0/8")},
{mustParseCIDR("100.64.0.0/10")},
{mustParseCIDR("198.18.0.0/15")},
{mustParseCIDR("::1/128")},
{mustParseCIDR("fc00::/7")},
{mustParseCIDR("fe80::/10")},
{mustParseCIDR("::ffff:127.0.0.0/104")},
}
for _, r := range privateRanges {
if r.network.Contains(ip) {
return true
}
}
return false
}
func mustParseCIDR(s string) *net.IPNet {
_, n, err := net.ParseCIDR(s)
if err != nil {
panic(err)
}
return n
}
type SafeDialer struct {
Dialer net.Dialer
}
func (d *SafeDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("netutil: invalid address %q: %w", addr, err)
}
resolver := net.Resolver{}
ips, err := resolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("netutil: DNS lookup failed for %q: %w", host, err)
}
for _, ipAddr := range ips {
if isPrivateIP(ipAddr.IP) {
return nil, fmt.Errorf("netutil: resolved IP %s of %q is a private/reserved address", ipAddr.IP, host)
}
}
var targetIPs []string
for _, ipAddr := range ips {
targetIPs = append(targetIPs, ipAddr.IP.String())
}
var lastErr error
for _, ipStr := range targetIPs {
conn, err := d.Dialer.DialContext(ctx, network, net.JoinHostPort(ipStr, port))
if err != nil {
lastErr = err
continue
}
remoteAddr, ok := conn.RemoteAddr().(*net.TCPAddr)
if ok && isPrivateIP(remoteAddr.IP) {
conn.Close()
return nil, fmt.Errorf("netutil: connected IP %s is a private/reserved address (DNS rebinding detected)", remoteAddr.IP)
}
return conn, nil
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("netutil: no reachable IP for %q", host)
}
func ValidateURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("netutil: invalid URL: %w", err)
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return fmt.Errorf("netutil: scheme %q is not allowed, only http and https are permitted", u.Scheme)
}
if u.Host == "" {
return fmt.Errorf("netutil: URL missing host")
}
hostname := u.Hostname()
if hostname == "" {
return fmt.Errorf("netutil: URL missing hostname")
}
if ip := net.ParseIP(hostname); ip != nil {
if isPrivateIP(ip) {
return fmt.Errorf("netutil: host IP %s is a private/reserved address", ip)
}
}
return nil
}
func CheckResolvedHost(hostname string) error {
ips, err := net.LookupIP(hostname)
if err != nil {
return fmt.Errorf("netutil: DNS lookup failed for %q: %w", hostname, err)
}
for _, ip := range ips {
if isPrivateIP(ip) {
return fmt.Errorf("netutil: resolved IP %s of %q is a private/reserved address", ip, hostname)
}
}
return nil
}

View File

@@ -16,6 +16,8 @@ import (
"strings"
"time"
"with_you/internal/pkg/netutil"
xdraw "golang.org/x/image/draw"
)
@@ -108,10 +110,8 @@ func NewClient(cfg Config) Client {
timeout = 30
}
return &clientImpl{
cfg: cfg,
httpClient: &http.Client{
Timeout: time.Duration(timeout) * time.Second,
},
cfg: cfg,
httpClient: netutil.NewSafeHTTPClient(time.Duration(timeout) * time.Second),
}
}
@@ -619,6 +619,9 @@ func (c *clientImpl) tryCompressImageForModeration(ctx context.Context, imageURL
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return imageURL
}
if err := netutil.ValidateURL(imageURL); err != nil {
return imageURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {

View File

@@ -0,0 +1,11 @@
package utils
import "strings"
func EscapeLikeWildcard(s string) string {
return strings.NewReplacer(
`\`, `\\`,
"%", `\%`,
"_", `\_`,
).Replace(s)
}

View File

@@ -3,6 +3,7 @@
import (
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/utils"
"gorm.io/gorm"
)
@@ -119,7 +120,7 @@ func (r *materialFileRepository) List(params dto.MaterialFileQueryParams) ([]*mo
query = query.Where("status = ?", params.Status)
}
if params.Keyword != "" {
keyword := "%" + params.Keyword + "%"
keyword := "%" + utils.EscapeLikeWildcard(params.Keyword) + "%"
query = query.Where("title LIKE ? OR description LIKE ?", keyword, keyword)
}
@@ -223,7 +224,7 @@ func (r *materialFileRepository) Search(keyword string, page, pageSize int) ([]*
query := r.db.Model(&model.MaterialFile{}).Where("status = ?", model.MaterialStatusActive)
if keyword != "" {
kw := "%" + keyword + "%"
kw := "%" + utils.EscapeLikeWildcard(keyword) + "%"
query = query.Where("title LIKE ? OR description LIKE ?", kw, kw)
}

View File

@@ -7,6 +7,7 @@ import (
"with_you/internal/model"
"with_you/internal/pkg/cursor"
"with_you/internal/pkg/utils"
"gorm.io/gorm"
)
@@ -510,10 +511,10 @@ func (r *postRepository) Search(keyword string, page, pageSize int) ([]*model.Po
// 搜索标题和内容
if keyword != "" {
if r.db.Dialector.Name() == "postgres" {
searchPattern := "%" + keyword + "%"
searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%"
query = query.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern)
} else {
searchPattern := "%" + keyword + "%"
searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%"
query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
}
}
@@ -877,10 +878,10 @@ func (r *postRepository) AdminList(query AdminPostListQuery) ([]*model.Post, int
// 关键词搜索(标题或内容)
if query.Keyword != "" {
if r.db.Dialector.Name() == "postgres" {
searchPattern := "%" + query.Keyword + "%"
searchPattern := "%" + utils.EscapeLikeWildcard(query.Keyword) + "%"
db = db.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern)
} else {
searchPattern := "%" + query.Keyword + "%"
searchPattern := "%" + utils.EscapeLikeWildcard(query.Keyword) + "%"
db = db.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
}
}
@@ -1125,10 +1126,10 @@ func (r *postRepository) SearchPostsByCursor(ctx context.Context, keyword string
// 搜索标题和内容
if keyword != "" {
if r.db.Dialector.Name() == "postgres" {
searchPattern := "%" + keyword + "%"
searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%"
query = query.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern)
} else {
searchPattern := "%" + keyword + "%"
searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%"
query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
}
}

View File

@@ -2,6 +2,7 @@
import (
"with_you/internal/model"
"with_you/internal/pkg/utils"
"gorm.io/gorm"
)
@@ -69,9 +70,10 @@ func (r *verificationRepository) List(page, pageSize int, status, keyword string
}
if keyword != "" {
kw := "%" + utils.EscapeLikeWildcard(keyword) + "%"
query = query.Joins("JOIN users ON users.id = verification_records.user_id").
Where("users.username LIKE ? OR users.nickname LIKE ? OR verification_records.real_name LIKE ?",
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
kw, kw, kw)
}
query.Count(&total)

View File

@@ -140,6 +140,7 @@ func New(
// setupRoutes 设置路由
func (r *Router) setupRoutes() {
// 中间件
r.engine.Use(middleware.SecurityHeaders())
r.engine.Use(middleware.CORS())
// 健康检查
@@ -150,15 +151,16 @@ func (r *Router) setupRoutes() {
// API v1
v1 := r.engine.Group("/api/v1")
{
// 认证路由(公开,需要速率限制)
// 认证路由(公开,按接口类型设置不同速率限制)
auth := v1.Group("/auth")
auth.Use(middleware.RateLimit(10)) // 每分钟最多10次请求
auth.Use(middleware.GlobalAuthRateLimit())
auth.Use(middleware.IPBanMiddleware())
{
auth.POST("/register", r.userHandler.Register)
auth.POST("/register/send-code", r.userHandler.SendRegisterCode)
auth.POST("/login", r.userHandler.Login)
auth.POST("/password/send-code", r.userHandler.SendPasswordResetCode)
auth.POST("/password/reset", r.userHandler.ResetPassword)
auth.POST("/register", middleware.RegisterRateLimit(), r.userHandler.Register)
auth.POST("/register/send-code", middleware.SendCodeRateLimit(), r.userHandler.SendRegisterCode)
auth.POST("/login", middleware.LoginRateLimit(), r.userHandler.Login)
auth.POST("/password/send-code", middleware.PasswordResetRateLimit(), r.userHandler.SendPasswordResetCode)
auth.POST("/password/reset", middleware.PasswordResetRateLimit(), r.userHandler.ResetPassword)
auth.POST("/logout", r.userHandler.Logout)
auth.POST("/refresh", r.userHandler.RefreshToken)
}

View File

@@ -6,6 +6,7 @@ import (
"log"
"strings"
apperrors "with_you/internal/errors"
"with_you/internal/cache"
"with_you/internal/dto"
"with_you/internal/model"
@@ -299,16 +300,22 @@ func (s *CommentService) GetReplies(ctx context.Context, parentID string) ([]*mo
}
// Update 更新评论
func (s *CommentService) Update(ctx context.Context, comment *model.Comment) error {
func (s *CommentService) Update(ctx context.Context, userID string, comment *model.Comment) error {
if comment.UserID != userID {
return apperrors.ErrForbidden
}
return s.commentRepo.Update(comment)
}
// Delete 删除评论
func (s *CommentService) Delete(ctx context.Context, id string) error {
func (s *CommentService) Delete(ctx context.Context, userID string, id string) error {
comment, err := s.commentRepo.GetByID(id)
if err != nil {
return err
}
if comment.UserID != userID {
return apperrors.ErrForbidden
}
if err := s.commentRepo.Delete(id); err != nil {
return err
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"with_you/internal/model"
"with_you/internal/pkg/netutil"
)
// ValidateChatMessageImageSegments 校验聊天消息中的图片段:禁止 data:/base64 内联,仅允许引用本站 S3 公开前缀下的 URL。
@@ -45,6 +46,12 @@ func (s *UploadService) ValidateChatMessageImageSegments(segments model.MessageS
if u.Host == "" {
return fmt.Errorf("图片地址无效")
}
if err := netutil.ValidateURL(urlStr); err != nil {
return fmt.Errorf("图片地址不安全: %w", err)
}
if err := netutil.CheckResolvedHost(u.Hostname()); err != nil {
return fmt.Errorf("图片地址不安全: %w", err)
}
if !strings.HasPrefix(urlStr, prefix) {
return fmt.Errorf("图片必须使用本站上传接口生成的资源地址")

View File

@@ -58,27 +58,21 @@ func (s *NotificationService) GetByUserID(ctx context.Context, userID string, pa
return s.notificationRepo.GetByUserID(userID, page, pageSize, unreadOnly)
}
// MarkAsRead 标记为已读
func (s *NotificationService) MarkAsRead(ctx context.Context, id string) error {
err := s.notificationRepo.MarkAsRead(id)
if err != nil {
return err
}
// 注意这里无法获取userID所以不在缓存中失效
// 调用方应该使用MarkAsReadWithUserID方法
return nil
}
// MarkAsReadWithUserID 标记为已读带用户ID用于缓存失效
// MarkAsReadWithUserID 标记为已读带用户ID验证所有权
func (s *NotificationService) MarkAsReadWithUserID(ctx context.Context, id, userID string) error {
err := s.notificationRepo.MarkAsRead(id)
notification, err := s.notificationRepo.GetByID(id)
if err != nil {
return err
}
if notification.UserID != userID {
return apperrors.ErrForbidden
}
err = s.notificationRepo.MarkAsRead(id)
if err != nil {
return err
}
// 失效未读数缓存
cache.InvalidateUnreadSystem(s.cache, userID)
return nil
@@ -97,11 +91,6 @@ func (s *NotificationService) MarkAllAsRead(ctx context.Context, userID string)
return nil
}
// Delete 删除通知
func (s *NotificationService) Delete(ctx context.Context, id string) error {
return s.notificationRepo.Delete(id)
}
// GetUnreadCount 获取未读数量(带缓存)
func (s *NotificationService) GetUnreadCount(ctx context.Context, userID string) (int64, error) {
cacheSettings := cache.GetSettings()

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
apperrors "with_you/internal/errors"
"with_you/internal/cache"
"with_you/internal/dto"
"with_you/internal/model"
@@ -30,7 +31,7 @@ type PostService interface {
GetByID(ctx context.Context, id string) (*model.Post, error)
Update(ctx context.Context, post *model.Post) error
UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error
Delete(ctx context.Context, id string) error
Delete(ctx context.Context, userID string, id string) error
// 帖子列表
List(ctx context.Context, page, pageSize int, userID string, includePending bool, channelID *string) ([]*model.Post, int64, error)
@@ -313,13 +314,20 @@ func (s *postServiceImpl) UpdateWithImages(ctx context.Context, post *model.Post
}
// Delete 删除帖子
func (s *postServiceImpl) Delete(ctx context.Context, id string) error {
err := s.postRepo.Delete(id)
func (s *postServiceImpl) Delete(ctx context.Context, userID string, id string) error {
post, err := s.postRepo.GetByID(id)
if err != nil {
return err
}
if post.UserID != userID {
return apperrors.ErrForbidden
}
err = s.postRepo.Delete(id)
if err != nil {
return err
}
// 失效帖子详情缓存和列表缓存
cache.InvalidatePostDetail(s.cache, id)
cache.InvalidatePostList(s.cache)

View File

@@ -115,7 +115,10 @@ func (s *scheduleSyncService) convertCourses(data *pb.ScheduleResultData) []*mod
for _, c := range data.Courses {
for _, st := range c.Schedule {
dayOfWeek := 0
fmt.Sscanf(st.Day, "%d", &dayOfWeek)
if _, err := fmt.Sscanf(st.Day, "%d", &dayOfWeek); err != nil {
zap.L().Warn("invalid day format in schedule sync", zap.String("day", st.Day), zap.Error(err))
dayOfWeek = 0
}
startSection := 1
endSection := 1

View File

@@ -1,7 +1,6 @@
package service
import (
"errors"
"net/url"
"strings"
@@ -119,7 +118,7 @@ func (s *stickerService) DeleteSticker(userID string, stickerID string) error {
return err
}
if sticker.UserID != userID {
return errors.New("sticker not found")
return apperrors.ErrForbidden
}
return s.stickerRepo.Delete(stickerID)

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"time"
apperrors "with_you/internal/errors"
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/cursor"
@@ -86,10 +87,10 @@ func (s *tradeService) GetByID(ctx context.Context, id string, currentUserID *st
func (s *tradeService) Update(ctx context.Context, userID string, id string, req dto.UpdateTradeItemRequest) error {
item, err := s.tradeRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("trade item not found")
return apperrors.ErrNotFound
}
if item.UserID != userID {
return fmt.Errorf("not authorized")
return apperrors.ErrForbidden
}
if req.TradeType != nil {
@@ -124,10 +125,10 @@ func (s *tradeService) Update(ctx context.Context, userID string, id string, req
func (s *tradeService) Delete(ctx context.Context, userID string, id string) error {
item, err := s.tradeRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("trade item not found")
return apperrors.ErrNotFound
}
if item.UserID != userID {
return fmt.Errorf("not authorized")
return apperrors.ErrForbidden
}
return s.tradeRepo.Delete(ctx, id)
}
@@ -135,10 +136,10 @@ func (s *tradeService) Delete(ctx context.Context, userID string, id string) err
func (s *tradeService) UpdateStatus(ctx context.Context, userID string, id string, status model.TradeItemStatus) error {
item, err := s.tradeRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("trade item not found")
return apperrors.ErrNotFound
}
if item.UserID != userID {
return fmt.Errorf("not authorized")
return apperrors.ErrForbidden
}
return s.tradeRepo.UpdateStatus(ctx, id, status)
}

View File

@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"strconv"
"strings"
"time"
@@ -591,19 +592,20 @@ func (s *userServiceImpl) IsBlockedBatch(ctx context.Context, blockerID string,
// GetFollowingList 获取关注列表(字符串参数版本)
func (s *userServiceImpl) GetFollowingList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) {
// 转换字符串参数为整数
pageInt := 1
pageSizeInt := 20
if page != "" {
_, err := fmt.Sscanf(page, "%d", &pageInt)
if err != nil {
if v, err := strconv.Atoi(page); err != nil || v < 1 {
pageInt = 1
} else {
pageInt = v
}
}
if pageSize != "" {
_, err := fmt.Sscanf(pageSize, "%d", &pageSizeInt)
if err != nil {
if v, err := strconv.Atoi(pageSize); err != nil || v < 1 {
pageSizeInt = 20
} else {
pageSizeInt = v
}
}
@@ -613,19 +615,20 @@ func (s *userServiceImpl) GetFollowingList(ctx context.Context, userID, page, pa
// GetFollowersList 获取粉丝列表(字符串参数版本)
func (s *userServiceImpl) GetFollowersList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) {
// 转换字符串参数为整数
pageInt := 1
pageSizeInt := 20
if page != "" {
_, err := fmt.Sscanf(page, "%d", &pageInt)
if err != nil {
if v, err := strconv.Atoi(page); err != nil || v < 1 {
pageInt = 1
} else {
pageInt = v
}
}
if pageSize != "" {
_, err := fmt.Sscanf(pageSize, "%d", &pageSizeInt)
if err != nil {
if v, err := strconv.Atoi(pageSize); err != nil || v < 1 {
pageSizeInt = 20
} else {
pageSizeInt = v
}
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
apperrors "with_you/internal/errors"
"with_you/internal/cache"
"with_you/internal/dto"
"with_you/internal/model"
@@ -128,7 +129,16 @@ func (s *VoteService) reviewVotePostAsync(postID, userID, title, content string,
}
var authorID uint
fmt.Sscanf(userID, "%d", &authorID)
if _, err := fmt.Sscanf(userID, "%d", &authorID); err != nil {
log.Printf("[WARN] Invalid userID format in vote moderation: %q, err: %v", userID, err)
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
log.Printf("[WARN] Failed to publish vote post %s after invalid userID: %v", postID, err)
} else {
cache.InvalidatePostDetail(s.cache, postID)
cache.InvalidatePostList(s.cache)
}
return
}
result := &hook.ModerationResult{}
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{
@@ -333,7 +343,7 @@ func (s *VoteService) UpdateVoteOption(ctx context.Context, postID, optionID, us
// 验证用户是否为帖子作者
if post.UserID != userID {
return errors.New("只有帖子作者可以更新投票选项")
return apperrors.ErrForbidden
}
// 调用voteRepo.UpdateOption