feat: enhance security with IP banning, ownership checks, and SSRF protection
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:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/grpc/runner"
|
||||
@@ -74,6 +75,10 @@ func (a *App) Start(ctx context.Context) error {
|
||||
a.Server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: a.Router.Engine(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// 4. 启动 HTTP 服务器
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
20
internal/middleware/security_headers.go
Normal file
20
internal/middleware/security_headers.go
Normal 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()
|
||||
}
|
||||
}
|
||||
29
internal/pkg/netutil/safeclient.go
Normal file
29
internal/pkg/netutil/safeclient.go
Normal 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,
|
||||
}
|
||||
}
|
||||
134
internal/pkg/netutil/ssrf.go
Normal file
134
internal/pkg/netutil/ssrf.go
Normal 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
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"with_you/internal/pkg/netutil"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
@@ -109,9 +111,7 @@ func NewClient(cfg Config) Client {
|
||||
}
|
||||
return &clientImpl{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(timeout) * time.Second,
|
||||
},
|
||||
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 {
|
||||
|
||||
11
internal/pkg/utils/like.go
Normal file
11
internal/pkg/utils/like.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package utils
|
||||
|
||||
import "strings"
|
||||
|
||||
func EscapeLikeWildcard(s string) string {
|
||||
return strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
"%", `\%`,
|
||||
"_", `\_`,
|
||||
).Replace(s)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("图片必须使用本站上传接口生成的资源地址")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user