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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"with_you/internal/config"
|
"with_you/internal/config"
|
||||||
"with_you/internal/grpc/runner"
|
"with_you/internal/grpc/runner"
|
||||||
@@ -74,6 +75,10 @@ func (a *App) Start(ctx context.Context) error {
|
|||||||
a.Server = &http.Server{
|
a.Server = &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: a.Router.Engine(),
|
Handler: a.Router.Engine(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 60 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 启动 HTTP 服务器
|
// 4. 启动 HTTP 服务器
|
||||||
|
|||||||
@@ -178,9 +178,9 @@ func (h *CommentHandler) Update(c *gin.Context) {
|
|||||||
|
|
||||||
comment.Content = req.Content
|
comment.Content = req.Content
|
||||||
|
|
||||||
err = h.commentService.Update(c.Request.Context(), comment)
|
err = h.commentService.Update(c.Request.Context(), userID, comment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to update comment")
|
response.HandleError(c, err, "failed to update comment")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,9 +208,9 @@ func (h *CommentHandler) Delete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.commentService.Delete(c.Request.Context(), id)
|
err = h.commentService.Delete(c.Request.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to delete comment")
|
response.HandleError(c, err, "failed to delete comment")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
|
|||||||
|
|
||||||
err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID)
|
err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to mark as read")
|
response.HandleError(c, err, "failed to mark as read")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
|
|||||||
|
|
||||||
err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID)
|
err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to delete notification")
|
response.HandleError(c, err, "failed to delete notification")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -496,9 +496,9 @@ func (h *PostHandler) Delete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.postService.Delete(c.Request.Context(), id)
|
err = h.postService.Delete(c.Request.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to delete post")
|
response.HandleError(c, err, "failed to delete post")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
|
"with_you/internal/middleware"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"with_you/internal/service"
|
"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)
|
user, err := h.userService.Login(c.Request.Context(), account, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
middleware.RecordLoginFailure(c.ClientIP())
|
||||||
response.HandleError(c, err, "failed to login")
|
response.HandleError(c, err, "failed to login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
middleware.ResetLoginFailures(c.ClientIP())
|
||||||
|
|
||||||
// 生成Token
|
// 生成Token
|
||||||
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
||||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(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
|
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 {
|
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) {
|
return func(c *gin.Context) {
|
||||||
ip := c.ClientIP()
|
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 {
|
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) {
|
return func(c *gin.Context) {
|
||||||
key := keyFunc(c)
|
key := keyFunc(c)
|
||||||
@@ -115,3 +140,176 @@ func RateLimitWithKey(requestsPerMinute int, keyFunc func(c *gin.Context) string
|
|||||||
c.Next()
|
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"with_you/internal/pkg/netutil"
|
||||||
|
|
||||||
xdraw "golang.org/x/image/draw"
|
xdraw "golang.org/x/image/draw"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,9 +111,7 @@ func NewClient(cfg Config) Client {
|
|||||||
}
|
}
|
||||||
return &clientImpl{
|
return &clientImpl{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
httpClient: &http.Client{
|
httpClient: netutil.NewSafeHTTPClient(time.Duration(timeout) * time.Second),
|
||||||
Timeout: 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") {
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||||
return imageURL
|
return imageURL
|
||||||
}
|
}
|
||||||
|
if err := netutil.ValidateURL(imageURL); err != nil {
|
||||||
|
return imageURL
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
|
||||||
if err != 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 (
|
import (
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
|
"with_you/internal/pkg/utils"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -119,7 +120,7 @@ func (r *materialFileRepository) List(params dto.MaterialFileQueryParams) ([]*mo
|
|||||||
query = query.Where("status = ?", params.Status)
|
query = query.Where("status = ?", params.Status)
|
||||||
}
|
}
|
||||||
if params.Keyword != "" {
|
if params.Keyword != "" {
|
||||||
keyword := "%" + params.Keyword + "%"
|
keyword := "%" + utils.EscapeLikeWildcard(params.Keyword) + "%"
|
||||||
query = query.Where("title LIKE ? OR description LIKE ?", keyword, 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)
|
query := r.db.Model(&model.MaterialFile{}).Where("status = ?", model.MaterialStatusActive)
|
||||||
if keyword != "" {
|
if keyword != "" {
|
||||||
kw := "%" + keyword + "%"
|
kw := "%" + utils.EscapeLikeWildcard(keyword) + "%"
|
||||||
query = query.Where("title LIKE ? OR description LIKE ?", kw, kw)
|
query = query.Where("title LIKE ? OR description LIKE ?", kw, kw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/cursor"
|
"with_you/internal/pkg/cursor"
|
||||||
|
"with_you/internal/pkg/utils"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -510,10 +511,10 @@ func (r *postRepository) Search(keyword string, page, pageSize int) ([]*model.Po
|
|||||||
// 搜索标题和内容
|
// 搜索标题和内容
|
||||||
if keyword != "" {
|
if keyword != "" {
|
||||||
if r.db.Dialector.Name() == "postgres" {
|
if r.db.Dialector.Name() == "postgres" {
|
||||||
searchPattern := "%" + keyword + "%"
|
searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%"
|
||||||
query = query.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern)
|
query = query.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern)
|
||||||
} else {
|
} else {
|
||||||
searchPattern := "%" + keyword + "%"
|
searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%"
|
||||||
query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
|
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 query.Keyword != "" {
|
||||||
if r.db.Dialector.Name() == "postgres" {
|
if r.db.Dialector.Name() == "postgres" {
|
||||||
searchPattern := "%" + query.Keyword + "%"
|
searchPattern := "%" + utils.EscapeLikeWildcard(query.Keyword) + "%"
|
||||||
db = db.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern)
|
db = db.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern)
|
||||||
} else {
|
} else {
|
||||||
searchPattern := "%" + query.Keyword + "%"
|
searchPattern := "%" + utils.EscapeLikeWildcard(query.Keyword) + "%"
|
||||||
db = db.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
|
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 keyword != "" {
|
||||||
if r.db.Dialector.Name() == "postgres" {
|
if r.db.Dialector.Name() == "postgres" {
|
||||||
searchPattern := "%" + keyword + "%"
|
searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%"
|
||||||
query = query.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern)
|
query = query.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern)
|
||||||
} else {
|
} else {
|
||||||
searchPattern := "%" + keyword + "%"
|
searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%"
|
||||||
query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
|
query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
|
"with_you/internal/pkg/utils"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -69,9 +70,10 @@ func (r *verificationRepository) List(page, pageSize int, status, keyword string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if keyword != "" {
|
if keyword != "" {
|
||||||
|
kw := "%" + utils.EscapeLikeWildcard(keyword) + "%"
|
||||||
query = query.Joins("JOIN users ON users.id = verification_records.user_id").
|
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 ?",
|
Where("users.username LIKE ? OR users.nickname LIKE ? OR verification_records.real_name LIKE ?",
|
||||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
kw, kw, kw)
|
||||||
}
|
}
|
||||||
|
|
||||||
query.Count(&total)
|
query.Count(&total)
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ func New(
|
|||||||
// setupRoutes 设置路由
|
// setupRoutes 设置路由
|
||||||
func (r *Router) setupRoutes() {
|
func (r *Router) setupRoutes() {
|
||||||
// 中间件
|
// 中间件
|
||||||
|
r.engine.Use(middleware.SecurityHeaders())
|
||||||
r.engine.Use(middleware.CORS())
|
r.engine.Use(middleware.CORS())
|
||||||
|
|
||||||
// 健康检查
|
// 健康检查
|
||||||
@@ -150,15 +151,16 @@ func (r *Router) setupRoutes() {
|
|||||||
// API v1
|
// API v1
|
||||||
v1 := r.engine.Group("/api/v1")
|
v1 := r.engine.Group("/api/v1")
|
||||||
{
|
{
|
||||||
// 认证路由(公开,需要速率限制)
|
// 认证路由(公开,按接口类型设置不同速率限制)
|
||||||
auth := v1.Group("/auth")
|
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", middleware.RegisterRateLimit(), r.userHandler.Register)
|
||||||
auth.POST("/register/send-code", r.userHandler.SendRegisterCode)
|
auth.POST("/register/send-code", middleware.SendCodeRateLimit(), r.userHandler.SendRegisterCode)
|
||||||
auth.POST("/login", r.userHandler.Login)
|
auth.POST("/login", middleware.LoginRateLimit(), r.userHandler.Login)
|
||||||
auth.POST("/password/send-code", r.userHandler.SendPasswordResetCode)
|
auth.POST("/password/send-code", middleware.PasswordResetRateLimit(), r.userHandler.SendPasswordResetCode)
|
||||||
auth.POST("/password/reset", r.userHandler.ResetPassword)
|
auth.POST("/password/reset", middleware.PasswordResetRateLimit(), r.userHandler.ResetPassword)
|
||||||
auth.POST("/logout", r.userHandler.Logout)
|
auth.POST("/logout", r.userHandler.Logout)
|
||||||
auth.POST("/refresh", r.userHandler.RefreshToken)
|
auth.POST("/refresh", r.userHandler.RefreshToken)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
apperrors "with_you/internal/errors"
|
||||||
"with_you/internal/cache"
|
"with_you/internal/cache"
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
@@ -299,16 +300,22 @@ func (s *CommentService) GetReplies(ctx context.Context, parentID string) ([]*mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update 更新评论
|
// 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)
|
return s.commentRepo.Update(comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除评论
|
// 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)
|
comment, err := s.commentRepo.GetByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if comment.UserID != userID {
|
||||||
|
return apperrors.ErrForbidden
|
||||||
|
}
|
||||||
if err := s.commentRepo.Delete(id); err != nil {
|
if err := s.commentRepo.Delete(id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
|
"with_you/internal/pkg/netutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidateChatMessageImageSegments 校验聊天消息中的图片段:禁止 data:/base64 内联,仅允许引用本站 S3 公开前缀下的 URL。
|
// ValidateChatMessageImageSegments 校验聊天消息中的图片段:禁止 data:/base64 内联,仅允许引用本站 S3 公开前缀下的 URL。
|
||||||
@@ -45,6 +46,12 @@ func (s *UploadService) ValidateChatMessageImageSegments(segments model.MessageS
|
|||||||
if u.Host == "" {
|
if u.Host == "" {
|
||||||
return fmt.Errorf("图片地址无效")
|
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) {
|
if !strings.HasPrefix(urlStr, prefix) {
|
||||||
return fmt.Errorf("图片必须使用本站上传接口生成的资源地址")
|
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)
|
return s.notificationRepo.GetByUserID(userID, page, pageSize, unreadOnly)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkAsRead 标记为已读
|
// MarkAsReadWithUserID 标记为已读(带用户ID,验证所有权)
|
||||||
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,用于缓存失效)
|
|
||||||
func (s *NotificationService) MarkAsReadWithUserID(ctx context.Context, id, userID string) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 失效未读数缓存
|
|
||||||
cache.InvalidateUnreadSystem(s.cache, userID)
|
cache.InvalidateUnreadSystem(s.cache, userID)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -97,11 +91,6 @@ func (s *NotificationService) MarkAllAsRead(ctx context.Context, userID string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除通知
|
|
||||||
func (s *NotificationService) Delete(ctx context.Context, id string) error {
|
|
||||||
return s.notificationRepo.Delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUnreadCount 获取未读数量(带缓存)
|
// GetUnreadCount 获取未读数量(带缓存)
|
||||||
func (s *NotificationService) GetUnreadCount(ctx context.Context, userID string) (int64, error) {
|
func (s *NotificationService) GetUnreadCount(ctx context.Context, userID string) (int64, error) {
|
||||||
cacheSettings := cache.GetSettings()
|
cacheSettings := cache.GetSettings()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
apperrors "with_you/internal/errors"
|
||||||
"with_you/internal/cache"
|
"with_you/internal/cache"
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
@@ -30,7 +31,7 @@ type PostService interface {
|
|||||||
GetByID(ctx context.Context, id string) (*model.Post, error)
|
GetByID(ctx context.Context, id string) (*model.Post, error)
|
||||||
Update(ctx context.Context, post *model.Post) error
|
Update(ctx context.Context, post *model.Post) error
|
||||||
UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) 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)
|
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 删除帖子
|
// Delete 删除帖子
|
||||||
func (s *postServiceImpl) Delete(ctx context.Context, id string) error {
|
func (s *postServiceImpl) Delete(ctx context.Context, userID string, id string) error {
|
||||||
err := s.postRepo.Delete(id)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 失效帖子详情缓存和列表缓存
|
|
||||||
cache.InvalidatePostDetail(s.cache, id)
|
cache.InvalidatePostDetail(s.cache, id)
|
||||||
cache.InvalidatePostList(s.cache)
|
cache.InvalidatePostList(s.cache)
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,10 @@ func (s *scheduleSyncService) convertCourses(data *pb.ScheduleResultData) []*mod
|
|||||||
for _, c := range data.Courses {
|
for _, c := range data.Courses {
|
||||||
for _, st := range c.Schedule {
|
for _, st := range c.Schedule {
|
||||||
dayOfWeek := 0
|
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
|
startSection := 1
|
||||||
endSection := 1
|
endSection := 1
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ func (s *stickerService) DeleteSticker(userID string, stickerID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if sticker.UserID != userID {
|
if sticker.UserID != userID {
|
||||||
return errors.New("sticker not found")
|
return apperrors.ErrForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.stickerRepo.Delete(stickerID)
|
return s.stickerRepo.Delete(stickerID)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
apperrors "with_you/internal/errors"
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/cursor"
|
"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 {
|
func (s *tradeService) Update(ctx context.Context, userID string, id string, req dto.UpdateTradeItemRequest) error {
|
||||||
item, err := s.tradeRepo.GetByID(ctx, id)
|
item, err := s.tradeRepo.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("trade item not found")
|
return apperrors.ErrNotFound
|
||||||
}
|
}
|
||||||
if item.UserID != userID {
|
if item.UserID != userID {
|
||||||
return fmt.Errorf("not authorized")
|
return apperrors.ErrForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.TradeType != nil {
|
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 {
|
func (s *tradeService) Delete(ctx context.Context, userID string, id string) error {
|
||||||
item, err := s.tradeRepo.GetByID(ctx, id)
|
item, err := s.tradeRepo.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("trade item not found")
|
return apperrors.ErrNotFound
|
||||||
}
|
}
|
||||||
if item.UserID != userID {
|
if item.UserID != userID {
|
||||||
return fmt.Errorf("not authorized")
|
return apperrors.ErrForbidden
|
||||||
}
|
}
|
||||||
return s.tradeRepo.Delete(ctx, id)
|
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 {
|
func (s *tradeService) UpdateStatus(ctx context.Context, userID string, id string, status model.TradeItemStatus) error {
|
||||||
item, err := s.tradeRepo.GetByID(ctx, id)
|
item, err := s.tradeRepo.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("trade item not found")
|
return apperrors.ErrNotFound
|
||||||
}
|
}
|
||||||
if item.UserID != userID {
|
if item.UserID != userID {
|
||||||
return fmt.Errorf("not authorized")
|
return apperrors.ErrForbidden
|
||||||
}
|
}
|
||||||
return s.tradeRepo.UpdateStatus(ctx, id, status)
|
return s.tradeRepo.UpdateStatus(ctx, id, status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -591,19 +592,20 @@ func (s *userServiceImpl) IsBlockedBatch(ctx context.Context, blockerID string,
|
|||||||
|
|
||||||
// GetFollowingList 获取关注列表(字符串参数版本)
|
// GetFollowingList 获取关注列表(字符串参数版本)
|
||||||
func (s *userServiceImpl) GetFollowingList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) {
|
func (s *userServiceImpl) GetFollowingList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) {
|
||||||
// 转换字符串参数为整数
|
|
||||||
pageInt := 1
|
pageInt := 1
|
||||||
pageSizeInt := 20
|
pageSizeInt := 20
|
||||||
if page != "" {
|
if page != "" {
|
||||||
_, err := fmt.Sscanf(page, "%d", &pageInt)
|
if v, err := strconv.Atoi(page); err != nil || v < 1 {
|
||||||
if err != nil {
|
|
||||||
pageInt = 1
|
pageInt = 1
|
||||||
|
} else {
|
||||||
|
pageInt = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if pageSize != "" {
|
if pageSize != "" {
|
||||||
_, err := fmt.Sscanf(pageSize, "%d", &pageSizeInt)
|
if v, err := strconv.Atoi(pageSize); err != nil || v < 1 {
|
||||||
if err != nil {
|
|
||||||
pageSizeInt = 20
|
pageSizeInt = 20
|
||||||
|
} else {
|
||||||
|
pageSizeInt = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,19 +615,20 @@ func (s *userServiceImpl) GetFollowingList(ctx context.Context, userID, page, pa
|
|||||||
|
|
||||||
// GetFollowersList 获取粉丝列表(字符串参数版本)
|
// GetFollowersList 获取粉丝列表(字符串参数版本)
|
||||||
func (s *userServiceImpl) GetFollowersList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) {
|
func (s *userServiceImpl) GetFollowersList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) {
|
||||||
// 转换字符串参数为整数
|
|
||||||
pageInt := 1
|
pageInt := 1
|
||||||
pageSizeInt := 20
|
pageSizeInt := 20
|
||||||
if page != "" {
|
if page != "" {
|
||||||
_, err := fmt.Sscanf(page, "%d", &pageInt)
|
if v, err := strconv.Atoi(page); err != nil || v < 1 {
|
||||||
if err != nil {
|
|
||||||
pageInt = 1
|
pageInt = 1
|
||||||
|
} else {
|
||||||
|
pageInt = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if pageSize != "" {
|
if pageSize != "" {
|
||||||
_, err := fmt.Sscanf(pageSize, "%d", &pageSizeInt)
|
if v, err := strconv.Atoi(pageSize); err != nil || v < 1 {
|
||||||
if err != nil {
|
|
||||||
pageSizeInt = 20
|
pageSizeInt = 20
|
||||||
|
} else {
|
||||||
|
pageSizeInt = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
apperrors "with_you/internal/errors"
|
||||||
"with_you/internal/cache"
|
"with_you/internal/cache"
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
@@ -128,7 +129,16 @@ func (s *VoteService) reviewVotePostAsync(postID, userID, title, content string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var authorID uint
|
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{}
|
result := &hook.ModerationResult{}
|
||||||
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{
|
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 {
|
if post.UserID != userID {
|
||||||
return errors.New("只有帖子作者可以更新投票选项")
|
return apperrors.ErrForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用voteRepo.UpdateOption
|
// 调用voteRepo.UpdateOption
|
||||||
|
|||||||
Reference in New Issue
Block a user