fix: port mailbox validation from im server

This commit is contained in:
richjohnrambo
2026-09-15 13:31:45 +08:00
parent 1d994c9302
commit cf08f7ceeb
5 changed files with 95 additions and 2 deletions
+23 -1
View File
@@ -25,6 +25,11 @@ type API struct {
const workbenchEventPublishTimeout = 15 * time.Second const workbenchEventPublishTimeout = 15 * time.Second
const (
maxMailboxSubjectLength = 200
maxMailboxContentLength = 5000
)
func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher) *API { func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher) *API {
if publisher == nil { if publisher == nil {
publisher = events.NoopPublisher{} publisher = events.NoopPublisher{}
@@ -879,9 +884,14 @@ func (a *API) mailboxSubmissions(w http.ResponseWriter, r *http.Request, p *auth
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return return
} }
subject := strings.TrimSpace(body.Subject)
content := strings.TrimSpace(body.Content)
if ok := validateMailboxSubmission(w, r, subject, content); !ok {
return
}
attachments, _ := json.Marshal(body.AttachmentFileIDs) attachments, _ := json.Marshal(body.AttachmentFileIDs)
submission, err := a.store.CreateMailboxSubmission(r.Context(), p.UserID, store.MailboxSubmission{ submission, err := a.store.CreateMailboxSubmission(r.Context(), p.UserID, store.MailboxSubmission{
SubmissionID: "mb_" + randomToken(12), Subject: strings.TrimSpace(body.Subject), Content: strings.TrimSpace(body.Content), SubmissionID: "mb_" + randomToken(12), Subject: subject, Content: content,
Anonymous: body.Anonymous, AttachmentFileIDs: string(attachments), NoticeVersion: strings.TrimSpace(body.NoticeVersion), Anonymous: body.Anonymous, AttachmentFileIDs: string(attachments), NoticeVersion: strings.TrimSpace(body.NoticeVersion),
}, idempotencyKey) }, idempotencyKey)
if err != nil { if err != nil {
@@ -896,6 +906,18 @@ func (a *API) mailboxSubmissions(w http.ResponseWriter, r *http.Request, p *auth
writeSuccess(w, r, mailboxSubmissionPayload(*submission)) writeSuccess(w, r, mailboxSubmissionPayload(*submission))
} }
func validateMailboxSubmission(w http.ResponseWriter, r *http.Request, subject, content string) bool {
if len([]rune(subject)) > maxMailboxSubjectLength {
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "subject", "max_length": maxMailboxSubjectLength})
return false
}
if len([]rune(content)) > maxMailboxContentLength {
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "content", "max_length": maxMailboxContentLength})
return false
}
return true
}
func (a *API) publishWorkbenchEvent(w http.ResponseWriter, r *http.Request, p *auth.Principal, module, action, resourceID string, targets []int64, title, summary string, data map[string]any) bool { func (a *API) publishWorkbenchEvent(w http.ResponseWriter, r *http.Request, p *auth.Principal, module, action, resourceID string, targets []int64, title, summary string, data map[string]any) bool {
if a.events == nil { if a.events == nil {
return true return true
+51
View File
@@ -0,0 +1,51 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
)
func TestValidateMailboxSubmissionRejectsTooLongContent(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/workbench/executive-mailbox/submissions", nil)
rec := httptest.NewRecorder()
if validateMailboxSubmission(rec, req, "建议", strings.Repeat("a", maxMailboxContentLength+1)) {
t.Fatal("validateMailboxSubmission returned true for too-long content")
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body apiError
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if body.Reason != reasonValidationFailed {
t.Fatalf("reason=%q body=%s", body.Reason, rec.Body.String())
}
if body.Details["field"] != "content" {
t.Fatalf("details=%v", body.Details)
}
}
func TestWriteStoreErrorMapsPolicyToValidationFailed(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/workbench/executive-mailbox/submissions", nil)
rec := httptest.NewRecorder()
writeStoreError(rec, req, store.ErrPolicy)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body apiError
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if body.Reason != reasonValidationFailed {
t.Fatalf("reason=%q body=%s", body.Reason, rec.Body.String())
}
}
+3
View File
@@ -17,6 +17,7 @@ const (
reasonForbidden = "FORBIDDEN" reasonForbidden = "FORBIDDEN"
reasonNotFound = "RESOURCE_NOT_FOUND" reasonNotFound = "RESOURCE_NOT_FOUND"
reasonConflict = "CONFLICT" reasonConflict = "CONFLICT"
reasonValidationFailed = "VALIDATION_FAILED"
reasonInternal = "INTERNAL" reasonInternal = "INTERNAL"
) )
@@ -57,6 +58,8 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil) writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil)
case errors.Is(err, ErrConflict), errors.Is(err, store.ErrConflict): case errors.Is(err, ErrConflict), errors.Is(err, store.ErrConflict):
writeError(w, r, http.StatusConflict, 30040, reasonConflict, "conflict", nil) writeError(w, r, http.StatusConflict, 30040, reasonConflict, "conflict", nil)
case errors.Is(err, store.ErrPolicy):
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil)
default: default:
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
} }
+1
View File
@@ -6,4 +6,5 @@ var (
ErrNotFound = errors.New("resource not found") ErrNotFound = errors.New("resource not found")
ErrForbidden = errors.New("forbidden") ErrForbidden = errors.New("forbidden")
ErrConflict = errors.New("conflict") ErrConflict = errors.New("conflict")
ErrPolicy = errors.New("policy violation")
) )
+17 -1
View File
@@ -719,8 +719,24 @@ func (s *Store) MailboxConfig(ctx context.Context) (*MailboxConfig, error) {
} }
func (s *Store) CreateMailboxSubmission(ctx context.Context, userID int64, submission MailboxSubmission, idempotencyKey string) (*MailboxSubmission, error) { func (s *Store) CreateMailboxSubmission(ctx context.Context, userID int64, submission MailboxSubmission, idempotencyKey string) (*MailboxSubmission, error) {
cfg, err := s.MailboxConfig(ctx)
if err != nil {
return nil, err
}
submission.Subject = strings.TrimSpace(submission.Subject)
submission.Content = strings.TrimSpace(submission.Content)
if !cfg.Enabled || (submission.Anonymous && !cfg.AllowAnonymous) {
return nil, ErrPolicy
}
maxLength := cfg.MaxLength
if maxLength <= 0 {
maxLength = 5000
}
if len([]rune(submission.Subject)) > 200 || len([]rune(submission.Content)) > maxLength {
return nil, ErrPolicy
}
now := time.Now().UTC().Round(time.Millisecond) now := time.Now().UTC().Round(time.Millisecond)
_, err := s.db.ExecContext(ctx, `INSERT INTO im_mailbox_submissions(submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,idempotency_key,status,submitted_at) _, err = s.db.ExecContext(ctx, `INSERT INTO im_mailbox_submissions(submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,idempotency_key,status,submitted_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, submission.SubmissionID, userID, submission.Subject, submission.Content, submission.Anonymous, submission.AttachmentFileIDs, submission.NoticeVersion, idempotencyKey, "received", now) VALUES(?,?,?,?,?,?,?,?,?,?)`, submission.SubmissionID, userID, submission.Subject, submission.Content, submission.Anonymous, submission.AttachmentFileIDs, submission.NoticeVersion, idempotencyKey, "received", now)
if isDuplicate(err) && idempotencyKey != "" { if isDuplicate(err) && idempotencyKey != "" {
var existing MailboxSubmission var existing MailboxSubmission