Add end-to-end encryption for chat messages (X25519 / NaCl box)
Messages between matched users are now encrypted client-side with each user's own key pair before they ever reach the server; the backend only ever stores/relays opaque ciphertext + nonce and cannot read message content, including for the last-message preview on the matches list. - User gains a PublicKey field, registered/rotated via PUT /profile/public-key - Message gains CipherText/Nonce; legacy plaintext Text is kept as-is for messages sent before this change - MatchView exposes the partner's public key and the last message's ciphertext/nonce so the client can decrypt previews itself - Removed the now-dead plaintext SanitizeMessageText utility — the server can no longer sanitize message content since it's opaque ciphertext Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,10 @@ type SwipeResponse struct {
|
|||||||
Match *MatchView `json:"match,omitempty"`
|
Match *MatchView `json:"match,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchView خلاصهی یک مچ برای نمایش در لیست مچها/گفتگوها
|
// MatchView خلاصهی یک مچ برای نمایش در لیست مچها/گفتگوها. برای پیشنمایش
|
||||||
|
// آخرین پیام، سرور رمزگشایی نمیکنه (نمیتونه) — CipherText/Nonce خودِ آخرین
|
||||||
|
// پیام رو برمیگردونه تا کلاینت خودش رمزگشایی کنه؛ LastMessage فقط برای
|
||||||
|
// پیامهای قدیمیِ قبل از E2EE پر میشه.
|
||||||
type MatchView struct {
|
type MatchView struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
UserID uint `json:"user_id"`
|
UserID uint `json:"user_id"`
|
||||||
@@ -30,22 +33,34 @@ type MatchView struct {
|
|||||||
Photos []string `json:"photos"`
|
Photos []string `json:"photos"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastMessage *string `json:"last_message,omitempty"`
|
LastMessage *string `json:"last_message,omitempty"`
|
||||||
|
LastMessageCipherText *string `json:"last_message_cipher_text,omitempty"`
|
||||||
|
LastMessageNonce *string `json:"last_message_nonce,omitempty"`
|
||||||
LastAt *string `json:"last_at,omitempty"`
|
LastAt *string `json:"last_at,omitempty"`
|
||||||
|
// PartnerPublicKey کلید عمومی X25519 طرف مقابل (base64) برای رمزنگاری
|
||||||
|
// سرتاسری؛ اگه طرف مقابل هنوز کلیدی ثبت نکرده باشه خالیه.
|
||||||
|
PartnerPublicKey *string `json:"partner_public_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageView یک پیام در یک گفتگو
|
// MessageView یک پیام در یک گفتگو. یا Text (پیام قدیمیِ قبل از E2EE) پر
|
||||||
|
// میشه، یا CipherText+Nonce (پیام رمزنگاریشده) — سرور خودش نمیدونه محتوای
|
||||||
|
// رمزنگاریشده چیه، فقط بین دو طرف مچ ردوبدلش میکنه.
|
||||||
type MessageView struct {
|
type MessageView struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
MatchID uint `json:"match_id"`
|
MatchID uint `json:"match_id"`
|
||||||
SenderID uint `json:"sender_id"`
|
SenderID uint `json:"sender_id"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text,omitempty"`
|
||||||
|
CipherText *string `json:"cipher_text,omitempty"`
|
||||||
|
Nonce *string `json:"nonce,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
IsMine bool `json:"is_mine"`
|
IsMine bool `json:"is_mine"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMessageRequest بدنهی درخواست ارسال پیام
|
// SendMessageRequest بدنهی درخواست ارسال پیام رمزنگاریشده. متن خام پیام
|
||||||
|
// هیچوقت به سرور نمیرسه؛ کلاینت قبل از ارسال با کلید عمومی طرف مقابل
|
||||||
|
// رمزنگاریش میکنه.
|
||||||
type SendMessageRequest struct {
|
type SendMessageRequest struct {
|
||||||
Text string `json:"text" binding:"required,min=1,max=2000"`
|
CipherText string `json:"cipher_text" binding:"required,min=1,max=12000"`
|
||||||
|
Nonce string `json:"nonce" binding:"required,len=32"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CandidateAnswer پاسخ یک سوال پروفایل، برای نمایش عمومی
|
// CandidateAnswer پاسخ یک سوال پروفایل، برای نمایش عمومی
|
||||||
|
|||||||
@@ -23,3 +23,10 @@ type UpdateNotificationSettingsRequest struct {
|
|||||||
NotifyMessages bool `json:"notify_messages"`
|
NotifyMessages bool `json:"notify_messages"`
|
||||||
NotifyLikes bool `json:"notify_likes"`
|
NotifyLikes bool `json:"notify_likes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatePublicKeyRequest ثبت/بهروزرسانی کلید عمومی X25519 کاربر برای
|
||||||
|
// رمزنگاری سرتاسری پیامها. کلید باید دقیقاً ۳۲ بایت خام باشه که بهصورت
|
||||||
|
// base64 استاندارد (۴۴ کاراکتر، شامل padding) کدگذاری شده.
|
||||||
|
type UpdatePublicKeyRequest struct {
|
||||||
|
PublicKey string `json:"public_key" binding:"required,len=44"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ func (h *ChatHandler) ListMessages(c *gin.Context) {
|
|||||||
utils.Success(c, http.StatusOK, "", messages)
|
utils.Success(c, http.StatusOK, "", messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMessage POST /matches/:id/messages body: {text}
|
// SendMessage POST /matches/:id/messages body: {cipher_text, nonce}
|
||||||
|
// متن خام پیام هیچوقت اینجا نمیرسه؛ کلاینت قبل از ارسال با کلید عمومی
|
||||||
|
// طرف مقابل (NaCl box / X25519) رمزنگاریش کرده.
|
||||||
func (h *ChatHandler) SendMessage(c *gin.Context) {
|
func (h *ChatHandler) SendMessage(c *gin.Context) {
|
||||||
user, ok := currentUser(c)
|
user, ok := currentUser(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -66,10 +68,10 @@ func (h *ChatHandler) SendMessage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var req dto.SendMessageRequest
|
var req dto.SendMessageRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
utils.Fail(c, http.StatusBadRequest, "متن پیام نمیتونه خالی باشه", err.Error())
|
utils.Fail(c, http.StatusBadRequest, "پیام رمزنگاریشده نامعتبر است", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg, err := h.chatService.SendMessage(user.ID, uint(matchID), req.Text)
|
msg, err := h.chatService.SendMessage(user.ID, uint(matchID), req.CipherText, req.Nonce)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.handleError(c, err)
|
h.handleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -81,9 +83,7 @@ func (h *ChatHandler) handleError(c *gin.Context, err error) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, service.ErrNotMatchMember):
|
case errors.Is(err, service.ErrNotMatchMember):
|
||||||
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
|
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
|
||||||
case errors.Is(err, utils.ErrInvalidMessageEncoding),
|
case errors.Is(err, service.ErrInvalidMessageEncoding):
|
||||||
errors.Is(err, utils.ErrMessageEmpty),
|
|
||||||
errors.Is(err, utils.ErrMessageTooLong):
|
|
||||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
default:
|
default:
|
||||||
utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil)
|
utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil)
|
||||||
|
|||||||
@@ -201,3 +201,27 @@ func (h *ProfileHandler) DeleteAccount(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
utils.Success(c, http.StatusOK, "حساب کاربری غیرفعال شد", nil)
|
utils.Success(c, http.StatusOK, "حساب کاربری غیرفعال شد", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatePublicKey PUT /profile/public-key — ثبت/جایگزینی کلید عمومی X25519
|
||||||
|
// برای رمزنگاری سرتاسری پیامها. کلاینت این کلید رو خودش تولید میکنه؛ کلید
|
||||||
|
// خصوصی متناظرش هیچوقت به این endpoint (یا هیچ endpoint دیگهای) فرستاده نمیشه.
|
||||||
|
func (h *ProfileHandler) UpdatePublicKey(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.UpdatePublicKeyRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "کلید عمومی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.profileService.UpdatePublicKey(user, req.PublicKey); err != nil {
|
||||||
|
if errors.Is(err, service.ErrInvalidPublicKey) {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی کلید عمومی", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "کلید عمومی ذخیره شد", nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,12 +55,18 @@ func (Match) TableName() string {
|
|||||||
return "matches"
|
return "matches"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message یک پیام متنی داخل یک مچ
|
// Message یک پیام داخل یک مچ. از رمزنگاری سرتاسری (E2EE) استفاده میشه:
|
||||||
|
// سرور فقط CipherText+Nonce (خروجی رمزنگاریشدهی سمت کلاینت با NaCl box،
|
||||||
|
// X25519-XSalsa20-Poly1305) رو ذخیره میکنه و هیچوقت متن خام پیام رو نمیبینه.
|
||||||
|
// Text قدیمی (قبل از اضافه شدن E2EE) برای نمایش پیامهای تاریخی نگه داشته میشه؛
|
||||||
|
// پیامهای جدید Text خالی دارن و بهجاش CipherText/Nonce پر میشه.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
MatchID uint `gorm:"not null;index" json:"match_id"`
|
MatchID uint `gorm:"not null;index" json:"match_id"`
|
||||||
SenderID uint `gorm:"not null" json:"sender_id"`
|
SenderID uint `gorm:"not null" json:"sender_id"`
|
||||||
Text string `gorm:"size:2000;not null" json:"text"`
|
Text string `gorm:"size:2000;not null" json:"text"`
|
||||||
|
CipherText *string `gorm:"size:12000" json:"cipher_text,omitempty"`
|
||||||
|
Nonce *string `gorm:"size:64" json:"nonce,omitempty"`
|
||||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ type User struct {
|
|||||||
Latitude *float64 `json:"-"`
|
Latitude *float64 `json:"-"`
|
||||||
Longitude *float64 `json:"-"`
|
Longitude *float64 `json:"-"`
|
||||||
|
|
||||||
|
// -------- کلید عمومی رمزنگاری سرتاسری پیامها (E2EE) --------
|
||||||
|
// کلید خصوصی هیچوقت به سرور نمیرسه و فقط روی دستگاه کاربر (localStorage)
|
||||||
|
// ذخیره میشه؛ سرور فقط این کلید عمومی رو نگه میداره تا طرف مقابل بتونه
|
||||||
|
// باهاش پیام رمزنگاری کنه. کلید X25519 هست، base64-encoded (۳۲ بایت خام).
|
||||||
|
PublicKey *string `gorm:"size:64" json:"public_key,omitempty"`
|
||||||
|
|
||||||
// -------- وضعیت آنبوردینگ --------
|
// -------- وضعیت آنبوردینگ --------
|
||||||
CurrentStep OnboardingStep `gorm:"default:1;not null" json:"current_step"`
|
CurrentStep OnboardingStep `gorm:"default:1;not null" json:"current_step"`
|
||||||
IsOnboarded bool `gorm:"default:false;not null" json:"is_onboarded"`
|
IsOnboarded bool `gorm:"default:false;not null" json:"is_onboarded"`
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func Setup(
|
|||||||
profile.POST("/checkin", h.Profile.CheckIn)
|
profile.POST("/checkin", h.Profile.CheckIn)
|
||||||
profile.PATCH("/notifications", h.Profile.UpdateNotificationSettings)
|
profile.PATCH("/notifications", h.Profile.UpdateNotificationSettings)
|
||||||
profile.DELETE("/me", h.Profile.DeleteAccount)
|
profile.DELETE("/me", h.Profile.DeleteAccount)
|
||||||
|
profile.PUT("/public-key", h.Profile.UpdatePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- آنبوردینگ (نیاز به ورود) --------
|
// -------- آنبوردینگ (نیاز به ورود) --------
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dating-app/internal/dto"
|
"dating-app/internal/dto"
|
||||||
"dating-app/internal/models"
|
"dating-app/internal/models"
|
||||||
"dating-app/internal/repository"
|
"dating-app/internal/repository"
|
||||||
"dating-app/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotMatchMember = errors.New("این گفتگو مال تو نیست")
|
var (
|
||||||
|
ErrNotMatchMember = errors.New("این گفتگو مال تو نیست")
|
||||||
|
ErrInvalidMessageEncoding = errors.New("پیام رمزنگاریشده نامعتبر است (کدگذاری اشتباه)")
|
||||||
|
)
|
||||||
|
|
||||||
type ChatService struct {
|
type ChatService struct {
|
||||||
matchRepo *repository.MatchRepository
|
matchRepo *repository.MatchRepository
|
||||||
@@ -90,12 +93,18 @@ func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) {
|
|||||||
FirstName: other.FirstName,
|
FirstName: other.FirstName,
|
||||||
Photos: orEmptyStrings(photosByUser[otherID]),
|
Photos: orEmptyStrings(photosByUser[otherID]),
|
||||||
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
||||||
|
PartnerPublicKey: other.PublicKey,
|
||||||
}
|
}
|
||||||
if last, ok := lastMessages[m.ID]; ok {
|
if last, ok := lastMessages[m.ID]; ok {
|
||||||
text := last.Text
|
|
||||||
at := last.CreatedAt.Format(time.RFC3339)
|
at := last.CreatedAt.Format(time.RFC3339)
|
||||||
view.LastMessage = &text
|
|
||||||
view.LastAt = &at
|
view.LastAt = &at
|
||||||
|
if last.CipherText != nil {
|
||||||
|
view.LastMessageCipherText = last.CipherText
|
||||||
|
view.LastMessageNonce = last.Nonce
|
||||||
|
} else {
|
||||||
|
text := last.Text
|
||||||
|
view.LastMessage = &text
|
||||||
|
}
|
||||||
}
|
}
|
||||||
views = append(views, view)
|
views = append(views, view)
|
||||||
}
|
}
|
||||||
@@ -129,6 +138,8 @@ func (s *ChatService) ListMessages(userID, matchID uint) ([]dto.MessageView, err
|
|||||||
MatchID: m.MatchID,
|
MatchID: m.MatchID,
|
||||||
SenderID: m.SenderID,
|
SenderID: m.SenderID,
|
||||||
Text: m.Text,
|
Text: m.Text,
|
||||||
|
CipherText: m.CipherText,
|
||||||
|
Nonce: m.Nonce,
|
||||||
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
||||||
IsMine: m.SenderID == userID,
|
IsMine: m.SenderID == userID,
|
||||||
})
|
})
|
||||||
@@ -136,18 +147,22 @@ func (s *ChatService) ListMessages(userID, matchID uint) ([]dto.MessageView, err
|
|||||||
return views, nil
|
return views, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMessage یک پیام جدید توی یک مچ ثبت میکنه؛ فقط اگه کاربر عضو اون مچ باشه.
|
// SendMessage یک پیام رمزنگاریشده توی یک مچ ثبت میکنه؛ فقط اگه کاربر عضو
|
||||||
func (s *ChatService) SendMessage(userID, matchID uint, text string) (*dto.MessageView, error) {
|
// اون مچ باشه. سرور محتوای پیام رو نمیبینه و نمیتونه — CipherText/Nonce
|
||||||
|
// قبلاً روی دستگاه فرستنده با کلید عمومی طرف مقابل رمزنگاری شدن؛ اینجا فقط
|
||||||
|
// اندازهی base64 اونا (از طریق binding توی SendMessageRequest) چک میشه.
|
||||||
|
func (s *ChatService) SendMessage(userID, matchID uint, cipherText, nonce string) (*dto.MessageView, error) {
|
||||||
if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
|
if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if _, err := base64.StdEncoding.DecodeString(cipherText); err != nil {
|
||||||
clean, err := utils.SanitizeMessageText(text)
|
return nil, ErrInvalidMessageEncoding
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
if _, err := base64.StdEncoding.DecodeString(nonce); err != nil {
|
||||||
|
return nil, ErrInvalidMessageEncoding
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := &models.Message{MatchID: matchID, SenderID: userID, Text: clean}
|
msg := &models.Message{MatchID: matchID, SenderID: userID, CipherText: &cipherText, Nonce: &nonce}
|
||||||
if err := s.messageRepo.Create(msg); err != nil {
|
if err := s.messageRepo.Create(msg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -156,7 +171,8 @@ func (s *ChatService) SendMessage(userID, matchID uint, text string) (*dto.Messa
|
|||||||
ID: msg.ID,
|
ID: msg.ID,
|
||||||
MatchID: msg.MatchID,
|
MatchID: msg.MatchID,
|
||||||
SenderID: msg.SenderID,
|
SenderID: msg.SenderID,
|
||||||
Text: msg.Text,
|
CipherText: msg.CipherText,
|
||||||
|
Nonce: msg.Nonce,
|
||||||
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
|
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
|
||||||
IsMine: true,
|
IsMine: true,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,6 +18,7 @@ var (
|
|||||||
ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست")
|
ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست")
|
||||||
ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه")
|
ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه")
|
||||||
ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه")
|
ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه")
|
||||||
|
ErrInvalidPublicKey = errors.New("کلید عمومی نامعتبر است")
|
||||||
)
|
)
|
||||||
|
|
||||||
const MaxPhotoSizeBytes = 5 * 1024 * 1024
|
const MaxPhotoSizeBytes = 5 * 1024 * 1024
|
||||||
@@ -163,3 +165,18 @@ func (s *ProfileService) UpdateNotificationSettings(user *models.User, req dto.U
|
|||||||
func (s *ProfileService) DeleteAccount(user *models.User) error {
|
func (s *ProfileService) DeleteAccount(user *models.User) error {
|
||||||
return s.userRepo.UpdateFields(user, map[string]interface{}{"is_active": false})
|
return s.userRepo.UpdateFields(user, map[string]interface{}{"is_active": false})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatePublicKey کلید عمومی X25519 کاربر رو برای رمزنگاری سرتاسری پیامها
|
||||||
|
// ثبت/جایگزین میکنه. کلید خصوصی متناظرش هیچوقت به سرور نمیرسه؛ اینجا فقط
|
||||||
|
// طول کلید خام (باید دقیقاً ۳۲ بایت باشه) بعد از decode چک میشه، نه محتواش.
|
||||||
|
func (s *ProfileService) UpdatePublicKey(user *models.User, publicKeyB64 string) error {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(publicKeyB64)
|
||||||
|
if err != nil || len(raw) != 32 {
|
||||||
|
return ErrInvalidPublicKey
|
||||||
|
}
|
||||||
|
if err := s.userRepo.UpdateFields(user, map[string]interface{}{"public_key": publicKeyB64}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.PublicKey = &publicKeyB64
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrInvalidMessageEncoding = errors.New("متن پیام معتبر نیست (کدگذاری اشتباه)")
|
|
||||||
ErrMessageEmpty = errors.New("متن پیام نمیتواند خالی باشد")
|
|
||||||
ErrMessageTooLong = errors.New("متن پیام بیش از حد مجاز طولانی است")
|
|
||||||
)
|
|
||||||
|
|
||||||
const MaxMessageLength = 2000
|
|
||||||
|
|
||||||
// SanitizeMessageText متن یک پیام چت رو قبل از ذخیرهسازی پاکسازی/اعتبارسنجی میکنه:
|
|
||||||
// - رد کردن رشتههایی که UTF-8 معتبر نیستن (بایت خراب/دستکاریشده)
|
|
||||||
// - حذف کاراکترهای کنترلی (بهجز \n و \t) که میتونن نمایش یا لاگها رو خراب کنن
|
|
||||||
// - trim فاصلههای اضافه و محدود کردن طول
|
|
||||||
func SanitizeMessageText(raw string) (string, error) {
|
|
||||||
if !utf8.ValidString(raw) {
|
|
||||||
return "", ErrInvalidMessageEncoding
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
b.Grow(len(raw))
|
|
||||||
for _, r := range raw {
|
|
||||||
if r == '\n' || r == '\t' {
|
|
||||||
b.WriteRune(r)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r < 0x20 || r == 0x7F {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteRune(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
text := strings.TrimSpace(b.String())
|
|
||||||
if text == "" {
|
|
||||||
return "", ErrMessageEmpty
|
|
||||||
}
|
|
||||||
if utf8.RuneCountInString(text) > MaxMessageLength {
|
|
||||||
return "", ErrMessageTooLong
|
|
||||||
}
|
|
||||||
return text, nil
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSanitizeMessageText_TrimsWhitespace(t *testing.T) {
|
|
||||||
got, err := SanitizeMessageText(" سلام دوست من ")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if got != "سلام دوست من" {
|
|
||||||
t.Fatalf("expected trimmed text, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizeMessageText_EmptyAfterTrim(t *testing.T) {
|
|
||||||
_, err := SanitizeMessageText(" ")
|
|
||||||
if !errors.Is(err, ErrMessageEmpty) {
|
|
||||||
t.Fatalf("expected ErrMessageEmpty, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizeMessageText_StripsControlChars(t *testing.T) {
|
|
||||||
got, err := SanitizeMessageText("سلام\x00\x07دنیا")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if got != "سلامدنیا" {
|
|
||||||
t.Fatalf("expected control chars stripped, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizeMessageText_KeepsNewlinesAndTabs(t *testing.T) {
|
|
||||||
got, err := SanitizeMessageText("خط اول\nخط دوم\tبا تب")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if got != "خط اول\nخط دوم\tبا تب" {
|
|
||||||
t.Fatalf("expected newline/tab preserved, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizeMessageText_InvalidUTF8(t *testing.T) {
|
|
||||||
invalid := string([]byte{0xff, 0xfe, 0xfd})
|
|
||||||
_, err := SanitizeMessageText(invalid)
|
|
||||||
if !errors.Is(err, ErrInvalidMessageEncoding) {
|
|
||||||
t.Fatalf("expected ErrInvalidMessageEncoding, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizeMessageText_TooLong(t *testing.T) {
|
|
||||||
long := strings.Repeat("a", MaxMessageLength+1)
|
|
||||||
_, err := SanitizeMessageText(long)
|
|
||||||
if !errors.Is(err, ErrMessageTooLong) {
|
|
||||||
t.Fatalf("expected ErrMessageTooLong, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizeMessageText_ExactlyMaxLength(t *testing.T) {
|
|
||||||
exact := strings.Repeat("a", MaxMessageLength)
|
|
||||||
got, err := SanitizeMessageText(exact)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error at exact max length: %v", err)
|
|
||||||
}
|
|
||||||
if len(got) != MaxMessageLength {
|
|
||||||
t.Fatalf("expected length %d, got %d", MaxMessageLength, len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user