feat: add safety reporting APIs
This commit is contained in:
+376
-2
@@ -40,6 +40,7 @@ const (
|
||||
imReasonRealNameRequired = "REALNAME_REQUIRED"
|
||||
imReasonActionToken = "ACTION_TOKEN_INVALID"
|
||||
imReasonPermissionDenied = "PERMISSION_DENIED"
|
||||
imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED"
|
||||
imReasonInternal = "INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
@@ -133,6 +134,26 @@ type imDevicePushTokenRequest struct {
|
||||
Platform string `json:"platform,omitempty"`
|
||||
}
|
||||
|
||||
type imRiskChallengeConfirmRequest struct {
|
||||
Decision string `json:"decision"`
|
||||
}
|
||||
|
||||
type imAbuseReportRequest struct {
|
||||
Target types.AbuseTarget `json:"target"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Description string `json:"description,omitempty"`
|
||||
EvidenceFileIDs []string `json:"evidence_file_ids,omitempty"`
|
||||
}
|
||||
|
||||
type imFeedbackRequest struct {
|
||||
Category string `json:"category"`
|
||||
Content string `json:"content"`
|
||||
Contact string `json:"contact,omitempty"`
|
||||
AttachmentFileIDs []string `json:"attachment_file_ids,omitempty"`
|
||||
ClientContext map[string]any `json:"client_context,omitempty"`
|
||||
IncludeDiagnostics bool `json:"include_diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
type imSystemNotificationReadRequest struct {
|
||||
ReadAt string `json:"read_at,omitempty"`
|
||||
}
|
||||
@@ -663,7 +684,8 @@ func serveIMSecurityURLCheck(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := authenticateIMHTTPBearer(wrt, req); !ok {
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body imURLCheckRequest
|
||||
@@ -679,11 +701,208 @@ func serveIMSecurityURLCheck(wrt http.ResponseWriter, req *http.Request) {
|
||||
"reason": reason,
|
||||
}
|
||||
if decision != "allow" {
|
||||
payload["risk_challenge_id"] = "url_" + shortTokenHash(strings.ToLower(strings.TrimSpace(body.URL)))
|
||||
challengeID := "risk_" + shortTokenHash(fmt.Sprintf("%d:%s:%d", principal.Uid, strings.ToLower(strings.TrimSpace(body.URL)), time.Now().UnixNano()))
|
||||
expiresAt := time.Now().UTC().Round(time.Millisecond).Add(10 * time.Minute)
|
||||
if _, err := store.Safety.CreateRiskChallenge(principal.TenantID, &types.RiskChallenge{
|
||||
ChallengeID: challengeID,
|
||||
UserID: principal.Uid,
|
||||
Action: "open_url",
|
||||
Level: riskLevel,
|
||||
NoticeCode: "url_" + riskLevel,
|
||||
NoticeParams: map[string]any{"source": strings.TrimSpace(body.Source)},
|
||||
State: types.RiskChallengeStatePending,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
payload["risk_challenge_id"] = challengeID
|
||||
payload["expires_at"] = expiresAt.Format(time.RFC3339Nano)
|
||||
}
|
||||
writeIMSuccess(wrt, req, payload)
|
||||
}
|
||||
|
||||
func serveIMRiskChallengeConfirm(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
challengeID := riskChallengeIDFromPath(req.URL.Path)
|
||||
if challengeID == "" {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "risk_challenge_id"})
|
||||
return
|
||||
}
|
||||
var body imRiskChallengeConfirmRequest
|
||||
if !decodeIMJSONBody(wrt, req, &body) {
|
||||
return
|
||||
}
|
||||
decision := strings.ToLower(strings.TrimSpace(body.Decision))
|
||||
var tokenID string
|
||||
var token string
|
||||
var expiresAt time.Time
|
||||
var err error
|
||||
if decision == "continue" {
|
||||
token, expiresAt, err = issueIMActionToken(principal.TenantID, principal.Uid, "risk_continue", challengeID)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
tokenID, _, _ = strings.Cut(token, ".")
|
||||
}
|
||||
challenge, err := store.Safety.ConfirmRiskChallenge(principal.TenantID, principal.Uid, challengeID, decision, tokenID, time.Now().UTC().Round(time.Millisecond))
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
payload := riskChallengePayload(challenge)
|
||||
if decision == "continue" {
|
||||
payload["risk_action_token"] = token
|
||||
payload["token_action"] = "risk_continue"
|
||||
payload["token_expires_at"] = expiresAt.Format(time.RFC3339Nano)
|
||||
}
|
||||
writeIMSuccess(wrt, req, payload)
|
||||
}
|
||||
|
||||
func serveIMAbuseReasons(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetType := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("target_type")))
|
||||
reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(reasons))
|
||||
for _, reason := range reasons {
|
||||
items = append(items, abuseReasonPayload(reason))
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func serveIMAbuseReports(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body imAbuseReportRequest
|
||||
if !decodeIMJSONBody(wrt, req, &body) {
|
||||
return
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
||||
if idempotencyKey == "" {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
|
||||
return
|
||||
}
|
||||
targetType, targetUser, targetTopic, targetSeq, snapshot, err := normalizeIMAbuseTarget(principal.TenantID, principal.Uid, body.Target)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
description := strings.TrimSpace(body.Description)
|
||||
if len([]rune(description)) > 500 {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
|
||||
return
|
||||
}
|
||||
if len(body.EvidenceFileIDs) > 9 {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "evidence_file_ids"})
|
||||
return
|
||||
}
|
||||
token, err := randomIMToken(12)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
report, err := store.Safety.CreateAbuseReport(principal.TenantID, &types.AbuseReport{
|
||||
ReportID: "ar_" + token,
|
||||
ReporterID: principal.Uid,
|
||||
TargetType: targetType,
|
||||
TargetUserID: targetUser,
|
||||
TargetTopic: targetTopic,
|
||||
TargetSeq: targetSeq,
|
||||
ReasonCode: strings.TrimSpace(body.ReasonCode),
|
||||
Description: description,
|
||||
EvidenceFileIDs: cleanStringList(body.EvidenceFileIDs, 9),
|
||||
Snapshot: snapshot,
|
||||
}, idempotencyKey)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, abuseReportPayload(report))
|
||||
}
|
||||
|
||||
func serveIMFeedbackConfig(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
config, err := store.Safety.FeedbackConfig(principal.TenantID, principal.Uid)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, feedbackConfigPayload(config))
|
||||
}
|
||||
|
||||
func serveIMFeedback(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body imFeedbackRequest
|
||||
if !decodeIMJSONBody(wrt, req, &body) {
|
||||
return
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
||||
content := strings.TrimSpace(body.Content)
|
||||
if idempotencyKey == "" || strings.TrimSpace(body.Category) == "" || content == "" || len([]rune(content)) > 1000 || len(body.AttachmentFileIDs) > 5 {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
||||
return
|
||||
}
|
||||
token, err := randomIMToken(12)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
feedback, err := store.Safety.CreateFeedback(principal.TenantID, &types.FeedbackSubmission{
|
||||
FeedbackID: "fb_" + token,
|
||||
UserID: principal.Uid,
|
||||
Category: strings.TrimSpace(body.Category),
|
||||
Content: content,
|
||||
Contact: strings.TrimSpace(body.Contact),
|
||||
AttachmentFileIDs: cleanStringList(body.AttachmentFileIDs, 5),
|
||||
ClientContext: body.ClientContext,
|
||||
IncludeDiagnostics: body.IncludeDiagnostics,
|
||||
}, idempotencyKey)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, feedbackSubmissionPayload(feedback))
|
||||
}
|
||||
|
||||
func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
@@ -5426,6 +5645,57 @@ func devicePushTokenPayload(token *types.DevicePushToken) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func riskChallengePayload(challenge *types.RiskChallenge) map[string]any {
|
||||
return map[string]any{
|
||||
"risk_challenge_id": challenge.ChallengeID,
|
||||
"action": challenge.Action,
|
||||
"level": challenge.Level,
|
||||
"notice_code": challenge.NoticeCode,
|
||||
"notice_params": challenge.NoticeParams,
|
||||
"state": challenge.State,
|
||||
"decision": challenge.Decision,
|
||||
"expires_at": challenge.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": challenge.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func abuseReasonPayload(reason types.AbuseReason) map[string]any {
|
||||
return map[string]any{
|
||||
"target_type": reason.TargetType,
|
||||
"reason_code": reason.ReasonCode,
|
||||
"label": reason.Label,
|
||||
"requires_description": reason.RequiresDescription,
|
||||
"allows_evidence": reason.AllowsEvidence,
|
||||
}
|
||||
}
|
||||
|
||||
func abuseReportPayload(report *types.AbuseReport) map[string]any {
|
||||
return map[string]any{
|
||||
"report_id": report.ReportID,
|
||||
"status": report.Status,
|
||||
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func feedbackConfigPayload(config *types.FeedbackConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"categories": config.Categories,
|
||||
"max_length": config.MaxLength,
|
||||
"attachment_policy": config.AttachmentPolicy,
|
||||
"allow_contact": config.AllowContact,
|
||||
"diagnostics_notice": config.DiagnosticsNotice,
|
||||
"updated_at": config.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func feedbackSubmissionPayload(feedback *types.FeedbackSubmission) map[string]any {
|
||||
return map[string]any{
|
||||
"feedback_id": feedback.FeedbackID,
|
||||
"status": feedback.Status,
|
||||
"created_at": feedback.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedIMWorkbenchRoute(route string) bool {
|
||||
switch route {
|
||||
case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox":
|
||||
@@ -6228,6 +6498,110 @@ func securityChallengeIDFromPath(path string) string {
|
||||
return strings.TrimSpace(challengeID)
|
||||
}
|
||||
|
||||
func riskChallengeIDFromPath(path string) string {
|
||||
const marker = "/v1/im/security/risk-challenges/"
|
||||
idx := strings.Index(path, marker)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimPrefix(path[idx:], marker)
|
||||
challengeID, suffix, ok := strings.Cut(rest, "/")
|
||||
if !ok || suffix != "confirm" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(challengeID)
|
||||
}
|
||||
|
||||
func cleanStringList(values []string, limit int) []string {
|
||||
if limit <= 0 {
|
||||
limit = len(values)
|
||||
}
|
||||
capacity := len(values)
|
||||
if capacity > limit {
|
||||
capacity = limit
|
||||
}
|
||||
cleaned := make([]string, 0, capacity)
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
cleaned = append(cleaned, value)
|
||||
if len(cleaned) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int, map[string]any, error) {
|
||||
targetType := strings.ToLower(strings.TrimSpace(target.Type))
|
||||
snapshot := map[string]any{"type": targetType}
|
||||
switch targetType {
|
||||
case "user":
|
||||
uid := types.ParseUserId(strings.TrimSpace(target.UserID))
|
||||
if uid.IsZero() || uid == reporter {
|
||||
return "", 0, "", 0, nil, types.ErrMalformed
|
||||
}
|
||||
if store.Users != nil {
|
||||
if _, err := store.Users.Get(tenantID, uid); err != nil {
|
||||
return "", 0, "", 0, nil, err
|
||||
}
|
||||
}
|
||||
snapshot["user_id"] = uid.UserId()
|
||||
return targetType, uid, "", 0, snapshot, nil
|
||||
case "group":
|
||||
topic := strings.TrimSpace(target.Topic)
|
||||
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
||||
return "", 0, "", 0, nil, types.ErrMalformed
|
||||
}
|
||||
if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil {
|
||||
return "", 0, "", 0, nil, err
|
||||
}
|
||||
snapshot["topic"] = topic
|
||||
return targetType, 0, topic, 0, snapshot, nil
|
||||
case "message":
|
||||
topic := strings.TrimSpace(target.Topic)
|
||||
seq, err := strconv.Atoi(strings.TrimSpace(target.Seq))
|
||||
if topic == "" || seq <= 0 || err != nil {
|
||||
return "", 0, "", 0, nil, types.ErrMalformed
|
||||
}
|
||||
if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil {
|
||||
return "", 0, "", 0, nil, err
|
||||
}
|
||||
msg, err := store.Messages.GetOne(tenantID, topic, seq)
|
||||
if err != nil {
|
||||
return "", 0, "", 0, nil, err
|
||||
}
|
||||
snapshot["topic"] = topic
|
||||
snapshot["seq"] = seq
|
||||
snapshot["from"] = msg.From
|
||||
snapshot["ts"] = msg.CreatedAt.UTC().Format(time.RFC3339Nano)
|
||||
return targetType, 0, topic, seq, snapshot, nil
|
||||
default:
|
||||
return "", 0, "", 0, nil, types.ErrMalformed
|
||||
}
|
||||
}
|
||||
|
||||
func requireIMTopicReadable(tenantID types.TenantID, uid types.Uid, topic string) error {
|
||||
if store.Subs == nil {
|
||||
return types.ErrPermissionDenied
|
||||
}
|
||||
sub, err := store.Subs.Get(tenantID, topic, uid, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sub == nil || !sub.ModeGiven.IsReader() {
|
||||
return types.ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func systemNotificationIDFromPath(path string) string {
|
||||
const marker = "/v1/im/system-notifications/"
|
||||
idx := strings.Index(path, marker)
|
||||
|
||||
Reference in New Issue
Block a user