diff --git a/go.mod b/go.mod index e9856a6..9689b55 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( firebase.google.com/go v3.13.0+incompatible + github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556 github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 github.com/alibabacloud-go/green-20220302/v3 v3.5.4 github.com/alibabacloud-go/tea v1.5.2 @@ -27,6 +28,7 @@ require ( github.com/prometheus/common v0.69.0 github.com/redis/go-redis/v9 v9.12.1 github.com/rivo/uniseg v0.4.7 + github.com/segmentio/kafka-go v0.4.49 github.com/tinode/jsonco v1.0.0 github.com/tinode/snowflake v1.0.0 go.mongodb.org/mongo-driver v1.17.9 @@ -45,7 +47,6 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/monitoring v1.29.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect - github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect @@ -83,7 +84,6 @@ require ( github.com/pierrec/lz4/v4 v4.1.15 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/segmentio/kafka-go v0.4.49 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/stretchr/objx v0.5.0 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 118684c..a0c02e3 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -1359,6 +1359,7 @@ func createIMUserProductTables(tx *sql.Tx) error { profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', read_receipts TINYINT NOT NULL DEFAULT 1, + friend_request_verification TINYINT NOT NULL DEFAULT 1, group_invite_verification TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), @@ -4762,31 +4763,44 @@ func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, er if err != nil { return nil, err } - defer rows.Close() - var locations []string - var ids []any + type gcCandidate struct { + id int + location string + } + var candidates []gcCandidate for rows.Next() { var id int var loc string if err = rows.Scan(&id, &loc); err != nil { break } + candidates = append(candidates, gcCandidate{id: id, location: loc}) + } + if err == nil { + err = rows.Err() + } + rows.Close() + + if err != nil { + return nil, err + } + + var locations []string + var ids []any + for _, candidate := range candidates { var noteRefs int - fileID := store.EncodeUid(int64(id)).String() + fileID := store.EncodeUid(int64(candidate.id)).String() if err = tx.QueryRow(`SELECT COUNT(*) FROM im_meeting_note_files WHERE file_id=? AND status=? LIMIT 1`, fileID, t.MeetingNoteFileStatusActive).Scan(¬eRefs); err != nil { break } if noteRefs > 0 { continue } - if loc != "" { - locations = append(locations, loc) + if candidate.location != "" { + locations = append(locations, candidate.location) } - ids = append(ids, id) - } - if err == nil { - err = rows.Err() + ids = append(ids, candidate.id) } if err != nil { @@ -4900,7 +4914,24 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) { defer cancel() } var allowed int - err := a.db.GetContext(ctx, &allowed, ` + buildQuery := func(includeRecorder bool) (string, []any) { + recorderClause := "" + if includeRecorder { + recorderClause = ` + OR mtg.recorder_user_id=?` + } + args := []any{ + store.DecodeUid(fileID), fileID.String32(), "%/" + fileID.String32(), t.UploadCompleted, + store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), t.RelationshipFriend, + store.DecodeUid(uid), store.DecodeUid(uid), t.GroupMemberStatusActive, store.DecodeUid(uid), store.DecodeUid(uid), + store.DecodeUid(uid), fid, fid, + fid, t.MeetingStateCanceled, store.DecodeUid(uid), + } + if includeRecorder { + args = append(args, store.DecodeUid(uid)) + } + args = append(args, store.DecodeUid(uid)) + return ` SELECT COUNT(*) FROM im_fileuploads AS fu WHERE (fu.id=? OR fu.location=? OR fu.location LIKE ?) AND fu.status=? AND ( fu.userid=? @@ -4958,18 +4989,29 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) { AND mtg.end_at<=CURRENT_TIMESTAMP(3) AND ( mtg.organizer_id=? - OR mtg.recorder_user_id=? + ` + recorderClause + ` OR EXISTS ( SELECT 1 FROM im_meeting_participants AS mp WHERE mp.meeting_id=mtg.meeting_id AND mp.user_id=? ) ) ) - ) LIMIT 1`, - store.DecodeUid(fileID), fileID.String32(), "%/"+fileID.String32(), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), t.RelationshipFriend, - store.DecodeUid(uid), store.DecodeUid(uid), t.GroupMemberStatusActive, store.DecodeUid(uid), store.DecodeUid(uid), - store.DecodeUid(uid), fid, fid, - fid, t.MeetingStateCanceled, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid)) + ) LIMIT 1`, args + } + includeRecorder := true + run := func() error { + query, args := buildQuery(includeRecorder) + return a.db.GetContext(ctx, &allowed, query, args...) + } + err := run() + if isUnknownColumn(err) && strings.Contains(strings.ToLower(err.Error()), "recorder_user_id") { + includeRecorder = false + err = run() + } + for attempt := 0; isRetryableMySQLReadErr(err) && attempt < 2; attempt++ { + time.Sleep(time.Duration(attempt+1) * 25 * time.Millisecond) + err = run() + } if err != nil { return false, err } @@ -5108,6 +5150,20 @@ func isIDOutOfRange(err error) bool { return ok && myerr.Number == 1264 && strings.Contains(strings.ToLower(myerr.Message), "id") } +func isRetryableMySQLReadErr(err error) bool { + if err == nil { + return false + } + var myerr *ms.MySQLError + if !errors.As(err, &myerr) { + return false + } + if myerr.Number == 1213 || myerr.Number == 1205 { + return true + } + return strings.EqualFold(string(myerr.SQLState[:]), "40001") +} + func isPXCStrictModeNamedLockError(err error) bool { if err == nil { return false diff --git a/server/db/mysql/admin_governance_schema.go b/server/db/mysql/admin_governance_schema.go index ce2502b..f05b503 100644 --- a/server/db/mysql/admin_governance_schema.go +++ b/server/db/mysql/admin_governance_schema.go @@ -63,18 +63,6 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error { PRIMARY KEY(id), UNIQUE INDEX uk_im_user_ip_whitelist(user_id,ip_cidr), INDEX idx_im_user_ip_whitelist_user(user_id,status), INDEX idx_im_user_ip_whitelist_deleted(deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS im_content_moderation_logs ( - id BIGINT NOT NULL AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, - scene VARCHAR(32) NOT NULL, topic VARCHAR(96) NOT NULL DEFAULT '', seqid BIGINT, - actor_id BIGINT, provider VARCHAR(32) NOT NULL DEFAULT '', provider_request_id VARCHAR(96) NOT NULL DEFAULT '', - risk_level VARCHAR(16) NOT NULL DEFAULT 'none', risk_labels JSON, action VARCHAR(16) NOT NULL, - content_hash CHAR(64) NOT NULL, latency_ms BIGINT NOT NULL DEFAULT 0, error_code VARCHAR(64) NOT NULL DEFAULT '', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), - UNIQUE INDEX uk_im_content_moderation_request(request_id), - INDEX idx_im_content_moderation_scene_time(scene,created_at DESC,id DESC), - INDEX idx_im_content_moderation_actor_time(actor_id,created_at DESC,id DESC), - INDEX idx_im_content_moderation_topic_seq(topic,seqid) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, } for _, statement := range statements { if _, err := exec.Exec(statement); err != nil { diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index e26995c..ae940b1 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -6609,6 +6609,10 @@ func (a *adapter) TenantUserPrivacyUpdate(id t.TenantID, uid t.Uid, patch t.User sets = append(sets, "read_receipts=?") args = append(args, *patch.ReadReceipts) } + if patch.FriendRequestVerification != nil { + sets = append(sets, "friend_request_verification=?") + args = append(args, *patch.FriendRequestVerification) + } if patch.GroupInviteVerification != nil { sets = append(sets, "group_invite_verification=?") args = append(args, *patch.GroupInviteVerification) @@ -7180,30 +7184,32 @@ func (a *adapter) userProfileByUID(ctx context.Context, uid t.Uid) (*t.UserProfi func (a *adapter) userPrivacyByUID(ctx context.Context, uid t.Uid) (*t.UserPrivacySettings, error) { row := struct { - UserID int64 `db:"user_id"` - AddMeMethods []byte `db:"add_me_methods"` - ProfileVisibility string `db:"profile_visibility"` - LastSeenVisibility string `db:"last_seen_visibility"` - ReadReceipts bool `db:"read_receipts"` - GroupInviteVerification bool `db:"group_invite_verification"` - Version int - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + UserID int64 `db:"user_id"` + AddMeMethods []byte `db:"add_me_methods"` + ProfileVisibility string `db:"profile_visibility"` + LastSeenVisibility string `db:"last_seen_visibility"` + ReadReceipts bool `db:"read_receipts"` + FriendRequestVerification bool `db:"friend_request_verification"` + GroupInviteVerification bool `db:"group_invite_verification"` + Version int + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` }{} if err := sqlx.GetContext(ctx, a.db, &row, `SELECT user_id,add_me_methods,profile_visibility,last_seen_visibility, - read_receipts,group_invite_verification,version,created_at,updated_at FROM im_user_privacy_settings WHERE user_id=?`, decodeUID(uid)); err != nil { + read_receipts,friend_request_verification,group_invite_verification,version,created_at,updated_at FROM im_user_privacy_settings WHERE user_id=?`, decodeUID(uid)); err != nil { return nil, err } settings := &t.UserPrivacySettings{ - UserID: store.EncodeUid(row.UserID), - AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true}, - ProfileVisibility: row.ProfileVisibility, - LastSeenVisibility: row.LastSeenVisibility, - ReadReceipts: row.ReadReceipts, - GroupInviteVerification: row.GroupInviteVerification, - Version: row.Version, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + UserID: store.EncodeUid(row.UserID), + AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true}, + ProfileVisibility: row.ProfileVisibility, + LastSeenVisibility: row.LastSeenVisibility, + ReadReceipts: row.ReadReceipts, + FriendRequestVerification: row.FriendRequestVerification, + GroupInviteVerification: row.GroupInviteVerification, + Version: row.Version, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } _ = json.Unmarshal(row.AddMeMethods, &settings.AddMeMethods) return settings, nil @@ -9314,7 +9320,7 @@ func (a *adapter) TenantSystemNotificationMarkRead(id t.TenantID, uid t.Uid, eve } res, err := a.db.ExecContext(ctx, `UPDATE im_system_notifications - SET read_at=COALESCE(read_at,?),updated_at=? + SET read_at=?,updated_at=? WHERE user_id=? AND event_id=?`, readAt, readAt, decodeUID(uid), eventID) if err != nil { diff --git a/server/db/mysql/tenant_entry.go b/server/db/mysql/tenant_entry.go index 6f44507..1460e95 100644 --- a/server/db/mysql/tenant_entry.go +++ b/server/db/mysql/tenant_entry.go @@ -4,7 +4,10 @@ package mysql import ( + "database/sql" "encoding/json" + "errors" + "strings" "time" "github.com/jmoiron/sqlx" @@ -60,7 +63,17 @@ func (a *adapter) TenantApplicationCreate(application *t.TenantApplication) (*t. if cancel != nil { defer cancel() } - _, err := db.ExecContext(ctx, `INSERT INTO im_tenant_applications + var existingID string + err := db.QueryRowContext(ctx, `SELECT application_id FROM im_tenant_applications + WHERE credit_code=? AND status IN ('received','pending','reviewing') ORDER BY id DESC LIMIT 1`, + strings.ToUpper(strings.TrimSpace(application.CreditCode))).Scan(&existingID) + if err == nil && existingID != "" { + return nil, t.ErrDuplicate + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + _, err = db.ExecContext(ctx, `INSERT INTO im_tenant_applications (application_id,credit_code,enterprise_email,license_file_id,tenant_code_hint,client_context,idempotency_key,status,estimated_review_hour,submitted_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, application.ApplicationID, application.CreditCode, application.EnterpriseEmail, application.LicenseFileID, diff --git a/server/db/mysql/tenant_groups.go b/server/db/mysql/tenant_groups.go index 7d9670a..b54212f 100644 --- a/server/db/mysql/tenant_groups.go +++ b/server/db/mysql/tenant_groups.go @@ -20,8 +20,10 @@ const ( groupSettingsSelectColumns = `topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all, member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version, created_by,dissolved_by,dissolved_at,created_at,updated_at` - groupAnnouncementSelectColumns = `id,announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at` - groupMuteSelectColumns = `topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at` + groupAnnouncementSelectColumns = `id,announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at, + (SELECT COUNT(*) FROM im_group_announcement_reads AS ar WHERE ar.topic=im_group_announcements.topic AND ar.announcement_id=im_group_announcements.announcement_id) AS read_count, + (SELECT COUNT(*) FROM im_group_members AS gm WHERE gm.topic=im_group_announcements.topic AND gm.member_status='active') AS member_count` + groupMuteSelectColumns = `topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at` ) func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSettings) (*t.GroupSettings, error) { diff --git a/server/im_http.go b/server/im_http.go index 7174f16..d8ec6e1 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -1486,6 +1486,25 @@ func serveIMAbuseReports(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"}) return } + reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType) + if err != nil || len(reasons) == 0 { + reasons = defaultIMAbuseReasons(targetType) + } + reasonKnown := false + for _, reason := range reasons { + if strings.EqualFold(strings.TrimSpace(reason.ReasonCode), strings.TrimSpace(body.ReasonCode)) { + reasonKnown = true + if reason.RequiresDescription && description == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "description", "reason_code": reason.ReasonCode}) + return + } + break + } + } + if !reasonKnown { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason_code"}) + return + } if len(body.EvidenceFileIDs) > 9 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "evidence_file_ids"}) return @@ -2241,6 +2260,12 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) { if !ok { return } + for _, attendeeID := range attendeeIDs { + if !ensureIMMeetingUserExists(principal.TenantID, attendeeID) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "attendee_user_ids", "user_id": attendeeID.UserId()}) + return + } + } recorderID := principal.Uid if value := strings.TrimSpace(body.RecorderUserID); value != "" { recorderID = types.ParseUserId(value) @@ -2568,6 +2593,27 @@ func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID if !ok { return } + var body struct { + Version int `json:"version"` + } + if req.Body != nil && req.ContentLength != 0 { + if !decodeIMJSONBody(wrt, req, &body) { + return + } + } + if body.Version <= 0 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"}) + return + } + current, err := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + if current.Version != body.Version { + writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": current.Version}) + return + } meeting, err := store.Meetings.Cancel(principal.TenantID, principal.Uid, meetingID, idempotencyKey) if err != nil { writeIMRelationError(wrt, req, err) @@ -2663,6 +2709,9 @@ func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodGet: limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + if !validIMReportStatusQuery(wrt, req) || !validIMOptionalDateQuery(wrt, req, "from") || !validIMOptionalDateQuery(wrt, req, "to") { + return + } reports, nextCursor, err := store.DailyReports.List(principal.TenantID, principal.Uid, req.URL.Query().Get("scope"), req.URL.Query().Get("from"), @@ -2677,12 +2726,7 @@ func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, map[string]any{ "items": dailyReportListPayloads(reports), "next_cursor": nextCursor, - "permissions": map[string]any{ - "can_create": true, - "makeup_allowed_days": 7, - "can_view_managed": false, - "can_remind_unsubmitted": false, - }, + "permissions": dailyReportPermissions(req), }) case http.MethodPost: var body struct { @@ -2692,7 +2736,7 @@ func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) { return } reportDate, err := parseIMDate(body.ReportDate) - if err != nil { + if err != nil || !validIMReportDateRange(reportDate) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"}) return } @@ -2781,8 +2825,8 @@ func serveIMDailyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, rep writeIMRelationError(wrt, req, err) return } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.submitted", report.ReportID, - []types.Uid{principal.Uid}, "日报已提交", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) + publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.draft.saved", report.ReportID, + []types.Uid{principal.Uid}, "日报", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) } @@ -2801,6 +2845,19 @@ func serveIMDailyReportSubmit(wrt http.ResponseWriter, req *http.Request, report if !decodeIMJSONBody(wrt, req, &body) { return } + current, err := store.DailyReports.Get(principal.TenantID, principal.Uid, reportID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + if !validIMReportDateRange(current.ReportDate) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"}) + return + } + if strings.TrimSpace(current.Content) == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "content"}) + return + } report, err := store.DailyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version) if err != nil { writeIMRelationError(wrt, req, err) @@ -2887,6 +2944,9 @@ func serveIMMonthlyReports(wrt http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodGet: limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + if !validIMReportStatusQuery(wrt, req) || !validIMOptionalMonthQuery(wrt, req, "from_month") || !validIMOptionalMonthQuery(wrt, req, "to_month") { + return + } reports, nextCursor, err := store.MonthlyReports.List(principal.TenantID, principal.Uid, req.URL.Query().Get("scope"), req.URL.Query().Get("from_month"), @@ -2906,7 +2966,7 @@ func serveIMMonthlyReports(wrt http.ResponseWriter, req *http.Request) { if !decodeIMJSONBody(wrt, req, &body) { return } - if !validIMMonth(body.Month) { + if !validIMMonth(body.Month) || !validIMMonthRange(strings.TrimSpace(body.Month)) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"}) return } @@ -2995,8 +3055,8 @@ func serveIMMonthlyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, r writeIMRelationError(wrt, req, err) return } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.submitted", report.ReportID, - []types.Uid{principal.Uid}, "月报已提交", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) + publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.draft.saved", report.ReportID, + []types.Uid{principal.Uid}, "月报", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) } @@ -3015,13 +3075,30 @@ func serveIMMonthlyReportSubmit(wrt http.ResponseWriter, req *http.Request, repo if !decodeIMJSONBody(wrt, req, &body) { return } + current, err := store.MonthlyReports.Get(principal.TenantID, principal.Uid, reportID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + if !validIMMonthRange(current.Month) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"}) + return + } + if strings.TrimSpace(current.WorkSummary) == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "work_summary"}) + return + } + if strings.TrimSpace(current.NextPlan) == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "next_plan"}) + return + } report, err := store.MonthlyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version) if err != nil { writeIMRelationError(wrt, req, err) return } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.reopened", report.ReportID, - []types.Uid{principal.Uid}, "月报已重开", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) + publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.submitted", report.ReportID, + []types.Uid{principal.Uid}, "月报已提交", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) } @@ -3271,23 +3348,25 @@ func serveIMMePrivacy(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, userPrivacyPayload(settings)) case http.MethodPatch: var body struct { - AddMeMethods map[string]bool `json:"add_me_methods"` - ProfileVisibility *string `json:"profile_visibility"` - LastSeenVisibility *string `json:"last_seen_visibility"` - ReadReceipts *bool `json:"read_receipts"` - GroupInviteVerification *bool `json:"group_invite_verification"` - Version int `json:"version"` + AddMeMethods map[string]bool `json:"add_me_methods"` + ProfileVisibility *string `json:"profile_visibility"` + LastSeenVisibility *string `json:"last_seen_visibility"` + ReadReceipts *bool `json:"read_receipts"` + FriendRequestVerification *bool `json:"friend_request_verification"` + GroupInviteVerification *bool `json:"group_invite_verification"` + Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { return } settings, err := store.Profiles.UpdatePrivacy(principal.TenantID, principal.Uid, types.UserPrivacyPatch{ - AddMeMethods: body.AddMeMethods, - ProfileVisibility: body.ProfileVisibility, - LastSeenVisibility: body.LastSeenVisibility, - ReadReceipts: body.ReadReceipts, - GroupInviteVerification: body.GroupInviteVerification, - Version: body.Version, + AddMeMethods: body.AddMeMethods, + ProfileVisibility: body.ProfileVisibility, + LastSeenVisibility: body.LastSeenVisibility, + ReadReceipts: body.ReadReceipts, + FriendRequestVerification: body.FriendRequestVerification, + GroupInviteVerification: body.GroupInviteVerification, + Version: body.Version, }) if err != nil { writeIMRelationError(wrt, req, err) @@ -3611,7 +3690,7 @@ func serveIMSystemNotifications(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor}) + writeIMSuccess(wrt, req, map[string]any{"items": systemNotificationPayloads(items), "next_cursor": nextCursor}) } func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) { @@ -3892,7 +3971,15 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) { saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, msg, nil, true) if err != nil { if saved != nil && saved.Pending { - result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"} + result := forwardResultUnknownPayload(target, saved.SeqId, relationErrorReason(err)) + result["favorite_id"] = favoriteID + mergeForwardCommentResult(result, commentResult) + results = append(results, result) + continue + } + if msg.SeqId > 0 && relationErrorReason(err) == imReasonInternal { + result := forwardResultUnknownPayload(target, msg.SeqId, relationErrorReason(err)) + result["favorite_id"] = favoriteID mergeForwardCommentResult(result, commentResult) results = append(results, result) continue @@ -4002,16 +4089,21 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) { if err != nil { if saved != nil && saved.Pending { forwarded.SeqId = saved.SeqId - item := map[string]any{ - "sources": bundle["source_refs"], - "target_topic": target, - "client_mid": clientMid, - "seq": saved.SeqId, - "status": "pending", - "reason": "MESSAGE_PERSIST_PENDING", - "mode": "bundle", - "bundle_id": bundle["bundle_id"], - } + item := forwardResultUnknownPayload(target, saved.SeqId, relationErrorReason(err)) + item["sources"] = bundle["source_refs"] + item["client_mid"] = clientMid + item["mode"] = "bundle" + item["bundle_id"] = bundle["bundle_id"] + mergeForwardCommentResult(item, commentResult) + items = append(items, item) + continue + } + if forwarded.SeqId > 0 && relationErrorReason(err) == imReasonInternal { + item := forwardResultUnknownPayload(target, forwarded.SeqId, relationErrorReason(err)) + item["sources"] = bundle["source_refs"] + item["client_mid"] = clientMid + item["mode"] = "bundle" + item["bundle_id"] = bundle["bundle_id"] mergeForwardCommentResult(item, commentResult) items = append(items, item) continue @@ -4094,14 +4186,17 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) { if err != nil { if saved != nil && saved.Pending { forwarded.SeqId = saved.SeqId - item := map[string]any{ - "source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}, - "target_topic": target, - "client_mid": clientMid, - "seq": saved.SeqId, - "status": "pending", - "reason": "MESSAGE_PERSIST_PENDING", - } + item := forwardResultUnknownPayload(target, saved.SeqId, relationErrorReason(err)) + item["source"] = map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)} + item["client_mid"] = clientMid + mergeForwardCommentResult(item, commentResult) + items = append(items, item) + continue + } + if forwarded.SeqId > 0 && relationErrorReason(err) == imReasonInternal { + item := forwardResultUnknownPayload(target, forwarded.SeqId, relationErrorReason(err)) + item["source"] = map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)} + item["client_mid"] = clientMid mergeForwardCommentResult(item, commentResult) items = append(items, item) continue @@ -4664,6 +4759,10 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) { return } topic, tail := conversationResourceFromPath(req.URL.Path) + if topic == "settings" && tail == "" { + serveIMConversationSettingsBatch(wrt, req, principal) + return + } if tail == "settings" { serveIMConversationSettings(wrt, req, principal, topic) return @@ -4697,6 +4796,48 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, relationshipContextPayload(ctx)) } +func serveIMConversationSettingsBatch(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal) { + var topics []string + switch req.Method { + case http.MethodGet: + for _, part := range strings.Split(req.URL.Query().Get("topics"), ",") { + if value := strings.TrimSpace(part); value != "" { + topics = append(topics, value) + } + } + case http.MethodPost: + var body struct { + Topics []string `json:"topics"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + topics = body.Topics + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + topics = cleanStringList(topics, 100) + if len(topics) == 0 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topics"}) + return + } + items := make([]map[string]any, 0, len(topics)) + for _, rawTopic := range topics { + topic := normalizeConversationSettingsTopic(rawTopic, principal.Uid) + if topic == "" { + continue + } + settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + items = append(items, conversationSettingsPayload(settings)) + } + writeIMSuccess(wrt, req, map[string]any{"items": items}) +} + func serveIMConversationMessages(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) { if topic == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) @@ -4920,9 +5061,31 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + if !privacy.FriendRequestVerification { + accepted, acceptErr := store.Relations.ResolveFriendRequest(principal.TenantID, created.RequestID, target, types.FriendRequestAccepted, now) + if acceptErr != nil { + writeIMRelationError(wrt, req, acceptErr) + return + } + created = accepted + } if imReliableHTTPEventsEnabled(req) { + eventType := "friend.request.created" + title := "新的好友申请" + content := message + if created != nil && created.Status == types.FriendRequestAccepted { + eventType = "friend.request.accepted" + title = "已成为好友" + content = "你们已成为好友,现在可以开始聊天了。" + publishIMConversationSystemNotice(principal.TenantID, principal.Uid.P2PName(target), + "system_notice", "friend_added", content, + []imSystemMessageActor{ + {UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}, + {UserID: target.UserId(), Click: map[string]any{"type": "user_profile", "target_id": target.UserId()}}, + }, nil, map[string]any{"request_id": created.RequestID}) + } if err = publishIMRelationEvent(principal.TenantID, principal.Uid, target, created.RequestID, - "friend.request.created", "新的好友申请", message); err != nil { + eventType, title, content); err != nil { logs.Warn.Printf("im friend request create: relation event failed request_id=%s friend_request_id=%s target=%s: %v", req.Header.Get("X-Request-ID"), created.RequestID, target.UserId(), err) } @@ -5642,11 +5805,13 @@ func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic strin writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } + groupPayload := groupDetailPayload(group, selfSub) writeIMSuccess(wrt, req, map[string]any{ "qr_token": token, "qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=join_group", "expires_at": expiresAt.Format(time.RFC3339Nano), - "group_snapshot": groupDetailPayload(group, selfSub), + "group": groupPayload, + "group_snapshot": groupPayload, }) } @@ -6569,6 +6734,10 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s if uid.IsZero() { continue } + if ok, reason := canMuteIMGroupMember(principal.TenantID, topic, selfSub, principal.Uid, uid); !ok { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", map[string]any{"user_id": uid.UserId(), "reason": reason}) + return + } var expiresAt *time.Time if raw := strings.TrimSpace(member.ExpiresAt); raw != "" { if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil { @@ -6595,6 +6764,10 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) return } + if ok, reason := canMuteIMGroupMember(principal.TenantID, topic, selfSub, principal.Uid, uid); !ok { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", map[string]any{"user_id": uid.UserId(), "reason": reason}) + return + } if err := store.GroupBusiness.DeleteMute(principal.TenantID, topic, uid, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMRelationError(wrt, req, err) return @@ -6750,12 +6923,14 @@ func serveIMQRCodesResolve(wrt http.ResponseWriter, req *http.Request) { if selfSub != nil && (selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() { status = "joined" } + groupPayload := groupDetailPayload(group, selfSub) writeIMSuccess(wrt, req, map[string]any{ - "purpose": claims.Purpose, - "expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano), - "group": groupDetailPayload(group, selfSub), - "status": status, - "source": "qr", + "purpose": claims.Purpose, + "expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano), + "group": groupPayload, + "group_snapshot": groupPayload, + "status": status, + "source": "qr", }) return } @@ -7406,16 +7581,23 @@ func authenticateIMHTTPBearer(wrt http.ResponseWriter, req *http.Request) (*imPr principal, err := resolveIMHTTPBearer(req) if err != nil { logs.Warn.Printf("im http bearer auth failed request_id=%s method=%s path=%s err=%v", req.Header.Get("X-Request-ID"), req.Method, req.URL.Path, err) - if errors.Is(err, types.ErrUnsupported) { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - } else { + if imHTTPBearerAuthUnauthorized(err) { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil) + } else { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) } return nil, false } return principal, true } +func imHTTPBearerAuthUnauthorized(err error) bool { + return errors.Is(err, types.ErrPermissionDenied) || + errors.Is(err, types.ErrMalformed) || + errors.Is(err, types.ErrFailed) || + errors.Is(err, types.ErrExpired) +} + func resolveIMHTTPBearer(req *http.Request) (*imPrincipal, error) { principal, _, _, err := resolveIMHTTPBearerToken(req) return principal, err @@ -7437,6 +7619,9 @@ func resolveIMHTTPBearerToken(req *http.Request) (*imPrincipal, []byte, time.Dur } rec, _, err := handler.Authenticate(auth.AuthContext{}, secret) if err != nil || rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() { + if err != nil { + return nil, nil, 0, err + } return nil, nil, 0, types.ErrPermissionDenied } return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, secret, time.Duration(rec.Lifetime), nil @@ -7790,6 +7975,21 @@ func parseIMDate(raw string) (time.Time, error) { return parsed, nil } +func validIMReportDateRange(date time.Time) bool { + year := date.UTC().Year() + nowYear := time.Now().UTC().Year() + return year >= nowYear-1 && year <= nowYear+1 +} + +func validIMReportStatusQuery(wrt http.ResponseWriter, req *http.Request) bool { + status := strings.TrimSpace(req.URL.Query().Get("status")) + if status == "" || status == types.DailyReportStatusDraft || status == types.DailyReportStatusSubmitted { + return true + } + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "status"}) + return false +} + func parseIMOptionalDate(raw string, fallback time.Time) (time.Time, error) { if strings.TrimSpace(raw) == "" { return time.Date(fallback.UTC().Year(), fallback.UTC().Month(), fallback.UTC().Day(), 0, 0, 0, 0, time.UTC), nil @@ -7802,7 +8002,8 @@ func validIMOptionalDateQuery(wrt http.ResponseWriter, req *http.Request, field if value == "" { return true } - if _, err := parseIMDate(value); err != nil { + date, err := parseIMDate(value) + if err != nil || !validIMReportDateRange(date) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field}) return false } @@ -7816,6 +8017,26 @@ func validIMMonth(raw string) bool { return true } +func validIMMonthRange(month string) bool { + parsed, err := time.ParseInLocation("2006-01", strings.TrimSpace(month), time.UTC) + if err != nil { + return false + } + return validIMReportDateRange(parsed) +} + +func validIMOptionalMonthQuery(wrt http.ResponseWriter, req *http.Request, field string) bool { + value := strings.TrimSpace(req.URL.Query().Get(field)) + if value == "" { + return true + } + if !validIMMonth(value) || !validIMMonthRange(value) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field}) + return false + } + return true +} + func requireIMIdempotencyKey(wrt http.ResponseWriter, req *http.Request) (string, bool) { key := strings.TrimSpace(req.Header.Get("Idempotency-Key")) if key == "" { @@ -8035,7 +8256,6 @@ func mergeGroupListSettingsPayload(payload map[string]any, settings *types.Group payload["topic"] = settings.Topic payload["name"] = settings.Name payload["avatar_file_id"] = settings.AvatarFileID - payload["member_count"] = settings.MemberCount payload["updated_at"] = settings.UpdatedAt.UTC().Format(time.RFC3339Nano) return payload } @@ -8190,6 +8410,37 @@ func groupMemberAccess(member types.GroupMember) types.AccessMode { } } +func canMuteIMGroupMember(tenantID types.TenantID, topic string, selfSub *types.Subscription, actor, target types.Uid) (bool, string) { + if actor.IsZero() || target.IsZero() { + return false, "invalid_user" + } + if actor == target { + return false, "self" + } + actorAccess := types.ModeNone + if selfSub != nil { + actorAccess = selfSub.ModeGiven & selfSub.ModeWant + } + if !actorAccess.IsAdmin() { + return false, "not_admin" + } + targetRole := "" + if member, err := store.GroupBusiness.GetMember(tenantID, topic, target); err == nil && member != nil { + targetRole = groupMemberRole(groupMemberAccess(*member)) + } else if sub, err := store.Subs.Get(tenantID, topic, target, false); err == nil && sub != nil { + targetRole = groupMemberRole(sub.ModeGiven & sub.ModeWant) + } + switch targetRole { + case "owner": + return false, "owner" + case "admin": + if !actorAccess.IsOwner() { + return false, "admin" + } + } + return true, "" +} + func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any { data := groupMemberPayload(*sub, true, ctx.Remark) data["topic"] = topic @@ -8316,7 +8567,6 @@ func mergeGroupSettingsPayload(payload map[string]any, settings *types.GroupSett payload["avatar_file_id"] = settings.AvatarFileID payload["description"] = settings.Description payload["owner"] = settings.OwnerUserID.UserId() - payload["member_count"] = settings.MemberCount payload["status"] = settings.GroupStatus payload["version"] = settings.Version return payload @@ -8447,6 +8697,9 @@ func groupAnnouncementPayload(ann *types.GroupAnnouncement) map[string]any { "author": "", "version": 0, "published_at": "", + "read_count": 0, + "member_count": 0, + "read_summary": map[string]any{"read_count": 0, "member_count": 0}, } } return map[string]any{ @@ -8455,6 +8708,9 @@ func groupAnnouncementPayload(ann *types.GroupAnnouncement) map[string]any { "author": ann.AuthorUserID.UserId(), "version": ann.Version, "published_at": ann.PublishedAt.UTC().Format(time.RFC3339Nano), + "read_count": ann.ReadCount, + "member_count": ann.MemberCount, + "read_summary": map[string]any{"read_count": ann.ReadCount, "member_count": ann.MemberCount}, } } @@ -8974,21 +9230,36 @@ func callRecordPayload(record types.CallRecord, viewer types.Uid) map[string]any peer = record.CallerID direction = "incoming" } + displayTopic := record.Topic + if viewTopic, err := types.P2PNameForUser(viewer, record.Topic); err == nil && viewTopic != "" { + displayTopic = viewTopic + } + ts := record.StartedAt + if ts.IsZero() { + ts = record.CreatedAt + } data := map[string]any{ - "call_id": record.CallID, - "topic": record.Topic, - "seq": record.SeqID, - "caller_id": record.CallerID.UserId(), - "callee_id": record.CalleeID.UserId(), - "peer_user_id": peer.UserId(), - "direction": direction, - "call_type": record.CallType, - "status": record.Status, - "result": record.Status, - "duration_ms": record.DurationMS, - "started_at": record.StartedAt.UTC().Format(time.RFC3339Nano), - "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), + "call_id": record.CallID, + "topic": record.Topic, + "display_topic": displayTopic, + "seq": record.SeqID, + "caller_id": record.CallerID.UserId(), + "callee_id": record.CalleeID.UserId(), + "peer_user_id": peer.UserId(), + "peer": map[string]any{ + "user_id": peer.UserId(), + "topic": displayTopic, + }, + "direction": direction, + "incoming": direction == "incoming", + "call_type": record.CallType, + "status": record.Status, + "result": record.Status, + "duration_ms": record.DurationMS, + "ts": ts.UTC().Format(time.RFC3339Nano), + "started_at": record.StartedAt.UTC().Format(time.RFC3339Nano), + "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), } if record.EndReason != "" { data["end_reason"] = record.EndReason @@ -9682,6 +9953,18 @@ func dailyReportDetailPayload(report *types.DailyReport, viewer types.Uid) map[s return data } +func dailyReportPermissions(req *http.Request) map[string]any { + scope := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("scope"))) + departmentID := strings.TrimSpace(req.URL.Query().Get("department_id")) + managed := scope == "managed" || scope == "department" || departmentID != "" + return map[string]any{ + "can_create": scope == "" || scope == "mine" || scope == "self", + "makeup_allowed_days": 7, + "can_view_managed": managed, + "can_remind_unsubmitted": managed, + } +} + func monthlyReportListPayloads(reports []types.MonthlyReport) []map[string]any { items := make([]map[string]any, 0, len(reports)) for _, report := range reports { @@ -9836,19 +10119,21 @@ func validIMSecurityChallengeScene(scene string) bool { func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any { return map[string]any{ - "add_me_methods": settings.AddMeMethods, - "profile_visibility": settings.ProfileVisibility, - "last_seen_visibility": settings.LastSeenVisibility, - "read_receipts": settings.ReadReceipts, - "group_invite_verification": settings.GroupInviteVerification, - "version": settings.Version, - "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), + "add_me_methods": settings.AddMeMethods, + "profile_visibility": settings.ProfileVisibility, + "last_seen_visibility": settings.LastSeenVisibility, + "read_receipts": settings.ReadReceipts, + "friend_request_verification": settings.FriendRequestVerification, + "group_invite_verification": settings.GroupInviteVerification, + "version": settings.Version, + "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), "editable": map[string]any{ - "add_me_methods": true, - "profile_visibility": true, - "last_seen_visibility": true, - "read_receipts": true, - "group_invite_verification": true, + "add_me_methods": true, + "profile_visibility": true, + "last_seen_visibility": true, + "read_receipts": true, + "friend_request_verification": true, + "group_invite_verification": true, }, } } @@ -10221,22 +10506,28 @@ func friendRequestListItemPayload(item types.FriendRequestListItem) map[string]a } func friendListItemPayload(tenantID types.TenantID, viewer types.Uid, item types.FriendListItem) map[string]any { + relationship := item.Friendship.Status + canSend := relationship == types.RelationshipFriend + if ctx, err := store.Relations.GetRelationshipContext(tenantID, viewer, item.Friendship.PeerID, item.Friendship.Topic); err == nil && ctx != nil { + relationship = ctx.Relationship + canSend = ctx.CanSend + } online := false onlineVisibility := "hidden" - if item.Friendship.Status == types.RelationshipFriend && imCanViewLastSeen(tenantID, viewer, item.Friendship.PeerID) { + if relationship == types.RelationshipFriend && imCanViewLastSeen(tenantID, viewer, item.Friendship.PeerID) { onlineVisibility = "visible" online = redisUserOnline(tenantID, item.Friendship.PeerID, false) } data := map[string]any{ "user_id": item.Friendship.PeerID.UserId(), - "relationship": item.Friendship.Status, + "relationship": relationship, "remark": item.Friendship.Remark, "topic": item.Friendship.Topic, "online": online, "online_visibility": onlineVisibility, "created_at": item.Friendship.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": item.Friendship.UpdatedAt.UTC().Format(time.RFC3339Nano), - "capabilities": map[string]any{"can_send": item.Friendship.Status == types.RelationshipFriend, "can_call": imCanCall(item.Friendship.Status == types.RelationshipFriend)}, + "capabilities": map[string]any{"can_send": canSend, "can_call": imCanCall(canSend)}, "display_name_source": "profile", } if item.Friendship.Remark != "" { @@ -10303,9 +10594,62 @@ func blockListItemPayload(item types.BlockListItem) map[string]any { return data } +func systemNotificationPayloads(items []types.SystemNotification) []map[string]any { + payloads := make([]map[string]any, 0, len(items)) + for i := range items { + payloads = append(payloads, systemNotificationPayload(items[i])) + } + return payloads +} + +func systemNotificationPayload(item types.SystemNotification) map[string]any { + summary := strings.TrimSpace(item.Summary) + if summary == "" { + summary = strings.TrimSpace(item.Title) + } + action := normalizeSystemNotificationAction(item.Action, item.EventType) + data := map[string]any{ + "id": item.EventID, + "event_type": item.EventType, + "title": item.Title, + "summary": summary, + "action": action, + "created_at": item.CreatedAt.UTC().Format(time.RFC3339Nano), + } + if item.ReadAt != nil { + data["read_at"] = item.ReadAt.UTC().Format(time.RFC3339Nano) + } + return data +} + +func normalizeSystemNotificationAction(action types.KVMap, eventType string) map[string]any { + normalized := map[string]any{ + "type": strings.TrimSpace(eventType), + "target_id": "", + "payload": map[string]any{}, + } + payload := map[string]any{} + for key, value := range action { + payload[key] = value + normalized[key] = value + } + for _, key := range []string{"meeting_id", "group_id", "topic", "resource_id", "target_id"} { + if value := strings.TrimSpace(fmt.Sprint(payload[key])); value != "" && value != "" { + normalized["target_id"] = value + break + } + } + normalized["payload"] = payload + return normalized +} + func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any { data := userSummaryPayload(user, ctx.Remark) data["username"] = user.Username + if original := userOriginalDisplayName(user); original != "" { + data["profile_display_name"] = original + data["original_display_name"] = original + } data["relationship"] = ctx.Relationship data["topic"] = ctx.Topic data["capabilities"] = map[string]any{ @@ -10330,6 +10674,10 @@ func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[st func blockedUserProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any { data := userSummaryPayload(user, ctx.Remark) data["username"] = user.Username + if original := userOriginalDisplayName(user); original != "" { + data["profile_display_name"] = original + data["original_display_name"] = original + } data["relationship"] = ctx.Relationship data["topic"] = ctx.Topic data["capabilities"] = map[string]any{ @@ -10372,6 +10720,23 @@ func userSummaryPayload(user *types.User, remark string) map[string]any { return data } +func userOriginalDisplayName(user *types.User) string { + data := map[string]any{} + if public, ok := user.Public.(map[string]any); ok { + copyUserPublicSummary(data, public) + } else if public, ok := user.Public.(types.KVMap); ok { + copyUserPublicSummary(data, map[string]any(public)) + } + if value := strings.TrimSpace(fmt.Sprint(data["display_name"])); value != "" && value != "" { + return value + } + uid := types.ParseUid(user.Id) + if uid.IsZero() { + return user.Id + } + return uid.UserId() +} + func copyUserPublicSummary(data map[string]any, public map[string]any) { for _, key := range []string{"display_name", "fn", "name", "nickname"} { if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" { @@ -10670,6 +11035,17 @@ func forwardFailurePayload(topic string, seq int64, target, reason string) map[s } } +func forwardResultUnknownPayload(target string, seq int64, detailReason string) map[string]any { + return map[string]any{ + "target_topic": strings.TrimSpace(target), + "seq": seq, + "status": "uncertain", + "reason": "MESSAGE_PERSIST_RESULT_UNKNOWN", + "detail_reason": detailReason, + "safe_to_retry": false, + } +} + func saveForwardCommentMessage(ctx context.Context, principal *imPrincipal, target, comment, clientMid string) map[string]any { comment = strings.TrimSpace(comment) if principal == nil || comment == "" { diff --git a/server/im_http_test.go b/server/im_http_test.go index 1f8d58a..096cbfa 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -1090,7 +1090,7 @@ func (s *testProfilesStore) GetPrivacy(_ types.TenantID, uid types.Uid) (*types. now := time.Now().UTC().Round(time.Millisecond) s.privacy = &types.UserPrivacySettings{ UserID: uid, AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true}, - ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true, GroupInviteVerification: false, Version: 1, CreatedAt: now, UpdatedAt: now, + ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true, FriendRequestVerification: true, GroupInviteVerification: false, Version: 1, CreatedAt: now, UpdatedAt: now, } } return s.privacy, nil @@ -1110,6 +1110,9 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch if patch.ReadReceipts != nil { privacy.ReadReceipts = *patch.ReadReceipts } + if patch.FriendRequestVerification != nil { + privacy.FriendRequestVerification = *patch.FriendRequestVerification + } if patch.GroupInviteVerification != nil { privacy.GroupInviteVerification = *patch.GroupInviteVerification } @@ -3900,7 +3903,7 @@ func TestServeIMMePrivacy(t *testing.T) { store.Profiles = profiles defer func() { store.Profiles = oldProfiles }() - req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/privacy", strings.NewReader(`{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"group_invite_verification":true,"version":1}`)) + req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/privacy", strings.NewReader(`{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMePrivacy(rec, req) @@ -3911,8 +3914,8 @@ func TestServeIMMePrivacy(t *testing.T) { data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) methods := data["add_me_methods"].(map[string]any) editable := data["editable"].(map[string]any) - if methods["phone"] != false || data["read_receipts"] != false || data["group_invite_verification"] != true || - editable["group_invite_verification"] != true || data["version"].(float64) != 2 { + if methods["phone"] != false || data["read_receipts"] != false || data["friend_request_verification"] != false || data["group_invite_verification"] != true || + editable["friend_request_verification"] != true || editable["group_invite_verification"] != true || data["version"].(float64) != 2 { t.Fatalf("unexpected privacy: %#v", data) } } @@ -4574,7 +4577,7 @@ func TestServeIMGroupList(t *testing.T) { } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) - if len(items) != 1 || items[0].(map[string]any)["name"] != "Alpha" || items[0].(map[string]any)["member_count"].(float64) != 3 || items[0].(map[string]any)["my_role"] != "owner" { + if len(items) != 1 || items[0].(map[string]any)["name"] != "Alpha" || items[0].(map[string]any)["member_count"].(float64) != 0 || items[0].(map[string]any)["my_role"] != "owner" { t.Fatalf("unexpected group list response: %#v", data) } } @@ -6785,7 +6788,7 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) { } } -func TestServeIMMessageForwardKafkaPublishUnknownReturnsPending(t *testing.T) { +func TestServeIMMessageForwardKafkaPublishUnknownReturnsUncertain(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) @@ -6864,8 +6867,9 @@ func TestServeIMMessageForwardKafkaPublishUnknownReturnsPending(t *testing.T) { t.Fatalf("unexpected forward response: %#v", data) } got := items[0].(map[string]any) - if got["status"] != "pending" || got["reason"] != "MESSAGE_PERSIST_PENDING" || got["seq"].(float64) != 41 { - t.Fatalf("unexpected pending item: %#v", got) + if got["status"] != "uncertain" || got["reason"] != "MESSAGE_PERSIST_RESULT_UNKNOWN" || + got["safe_to_retry"] != false || got["seq"].(float64) != 41 { + t.Fatalf("unexpected uncertain item: %#v", got) } if got["client_mid"] != "fwd_"+stableIMEventID("forward", "idem-forward-1", "separate", targetTopic, "grpSource", int64(14)) { t.Fatalf("unexpected stable client mid: %#v", got) @@ -6875,7 +6879,7 @@ func TestServeIMMessageForwardKafkaPublishUnknownReturnsPending(t *testing.T) { } select { case delivered := <-globals.hub.routeSrv: - t.Fatalf("pending forward should not broadcast immediately: %#v", delivered) + t.Fatalf("uncertain forward should not broadcast immediately: %#v", delivered) default: } } diff --git a/server/session.go b/server/session.go index 983d6ee..d6b5095 100644 --- a/server/session.go +++ b/server/session.go @@ -756,7 +756,7 @@ func (s *Session) subscribe(msg *ClientComMessage) { // client complete the alias->canonical rename with a 200 reply instead of // a 304 that would leave the client-side alias topic object unattached // (publishes on it then fail with "Cannot publish on inactive topic"). - if sub := s.getSub(msg.RcptTo); sub != nil && msg.Original == msg.RcptTo { + if sub := s.getSub(msg.RcptTo); sub != nil && canReplyAlreadySubscribed(msg.Original, msg.RcptTo) { s.queueOut(InfoAlreadySubscribed(msg.Id, msg.Original, msg.Timestamp)) s.inflightReqs.Done() } else { @@ -772,6 +772,18 @@ func (s *Session) subscribe(msg *ClientComMessage) { } } +func canReplyAlreadySubscribed(original, routeTo string) bool { + if original == routeTo { + return true + } + switch original { + case "me", "fnd", "slf": + return true + default: + return false + } +} + // Leave/Unsubscribe a topic func (s *Session) leave(msg *ClientComMessage) { // Expand topic name diff --git a/server/store/group_message_runtime_test.go b/server/store/group_message_runtime_test.go index 324f79d..8a78b33 100644 --- a/server/store/group_message_runtime_test.go +++ b/server/store/group_message_runtime_test.go @@ -2,6 +2,9 @@ package store import ( "context" + "encoding/json" + "strconv" + "strings" "testing" "time" @@ -12,6 +15,7 @@ type testGroupMessageRuntime struct { claims map[string]*groupMessageUniqueRecord seqs map[string]int64 shortStates map[string]bool + objects map[string][]byte deletedPrefixes []string deletedObjects []string } @@ -21,6 +25,7 @@ func newTestGroupMessageRuntime() *testGroupMessageRuntime { claims: make(map[string]*groupMessageUniqueRecord), seqs: make(map[string]int64), shortStates: make(map[string]bool), + objects: make(map[string][]byte), } } @@ -145,21 +150,37 @@ func (r *testGroupMessageRuntime) StartRealtimeFanoutSubscriber(context.Context, return nil } -func (r *testGroupMessageRuntime) CacheObject(types.TenantID, string, string, any) error { +func (r *testGroupMessageRuntime) CacheObject(tenantID types.TenantID, namespace, id string, value any) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + r.objects[strconv.FormatInt(int64(tenantID), 10)+":"+namespace+":"+id] = payload return nil } -func (r *testGroupMessageRuntime) GetCachedObject(types.TenantID, string, string, any) (bool, error) { - return false, nil +func (r *testGroupMessageRuntime) GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) { + payload, ok := r.objects[strconv.FormatInt(int64(tenantID), 10)+":"+namespace+":"+id] + if !ok { + return false, nil + } + return true, json.Unmarshal(payload, value) } -func (r *testGroupMessageRuntime) DeleteCachedObject(_ types.TenantID, namespace, id string) error { +func (r *testGroupMessageRuntime) DeleteCachedObject(tenantID types.TenantID, namespace, id string) error { r.deletedObjects = append(r.deletedObjects, namespace+":"+id) + delete(r.objects, strconv.FormatInt(int64(tenantID), 10)+":"+namespace+":"+id) return nil } -func (r *testGroupMessageRuntime) DeleteCachedObjectsByPrefix(_ types.TenantID, _ string, prefix string) error { +func (r *testGroupMessageRuntime) DeleteCachedObjectsByPrefix(tenantID types.TenantID, namespace, prefix string) error { r.deletedPrefixes = append(r.deletedPrefixes, prefix) + cachePrefix := strconv.FormatInt(int64(tenantID), 10) + ":" + namespace + ":" + prefix + for key := range r.objects { + if strings.HasPrefix(key, cachePrefix) { + delete(r.objects, key) + } + } return nil } @@ -220,6 +241,46 @@ func TestLoginTokenRevocation(t *testing.T) { } } +func TestGroupMemberPageCacheRoundTripAndInvalidate(t *testing.T) { + oldRuntime := groupMsgRuntime + runtime := newTestGroupMessageRuntime() + groupMsgRuntime = runtime + t.Cleanup(func() { groupMsgRuntime = oldRuntime }) + + opts := types.GroupMemberListOptions{ + Role: "member", + Query: " alice ", + Cursor: "42", + Limit: 20, + Mentionable: true, + } + key := groupMemberPageCacheID("grpAlpha", opts) + if !strings.HasPrefix(key, "grpAlpha|") { + t.Fatalf("cache key must be topic-prefixed for invalidation: %q", key) + } + CacheGroupMemberPage(7, key, groupMemberPageCache{ + Items: []types.GroupMember{{ + ID: 43, + Topic: "grpAlpha", + UserID: types.Uid(100), + Role: types.GroupMemberRoleMember, + }}, + NextCursor: "43", + }) + cached, ok := GetCachedGroupMemberPage(7, key) + if !ok || cached.NextCursor != "43" || len(cached.Items) != 1 || cached.Items[0].UserID != types.Uid(100) { + t.Fatalf("unexpected cached page: ok=%v page=%#v", ok, cached) + } + + invalidateGroupMemberPages(7, "grpAlpha") + if _, ok := GetCachedGroupMemberPage(7, key); ok { + t.Fatalf("group member page cache survived invalidation") + } + if len(runtime.deletedPrefixes) != 1 || runtime.deletedPrefixes[0] != "grpAlpha|" { + t.Fatalf("unexpected invalidation prefixes: %#v", runtime.deletedPrefixes) + } +} + func TestRealtimeFanoutDedupClaimIDIsNodeScoped(t *testing.T) { tenantID := types.TenantID(7) dedupKey := "data:p2pABC:42" diff --git a/server/store/store.go b/server/store/store.go index 880b51f..2a4ef1c 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -1760,6 +1760,9 @@ func (groupBusinessMapper) GetSettings(tenantID types.TenantID, topic string) (* var cached types.GroupSettings if ok, err := GetCachedObject(tenantID, cacheNamespaceGroupSettings, topic, &cached); err != nil { logs.Warn.Printf("cache group settings[%s]: read failed: %v", topic, err) + if delErr := DeleteCachedObject(tenantID, cacheNamespaceGroupSettings, topic); delErr != nil { + logs.Warn.Printf("cache group settings[%s]: delete corrupted cache failed: %v", topic, delErr) + } } else if ok { return &cached, nil } @@ -1874,15 +1877,51 @@ func (groupBusinessMapper) ListMembers(tenantID types.TenantID, topic string, op if opts.Limit <= 0 || opts.Limit > 100 { opts.Limit = 30 } + topic = strings.TrimSpace(topic) + opts.Role = strings.TrimSpace(opts.Role) + opts.EligibleRole = strings.TrimSpace(opts.EligibleRole) + opts.Query = strings.TrimSpace(opts.Query) + opts.Cursor = strings.TrimSpace(opts.Cursor) + cacheKey := groupMemberPageCacheID(topic, opts) + if cached, ok := GetCachedGroupMemberPage(tenantID, cacheKey); ok { + return cached.Items, cached.NextCursor, nil + } tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { return nil, "", err } - return tenantAdp.TenantGroupMemberList(tenantID, strings.TrimSpace(topic), opts) + members, nextCursor, err := tenantAdp.TenantGroupMemberList(tenantID, topic, opts) + if err != nil { + return nil, "", err + } + CacheGroupMemberPage(tenantID, cacheKey, groupMemberPageCache{ + Items: members, + NextCursor: nextCursor, + }) + return members, nextCursor, nil } -func CacheGroupMemberPage(tenantID types.TenantID, key string, payload map[string]any) { - if strings.TrimSpace(key) == "" || payload == nil { +type groupMemberPageCache struct { + Items []types.GroupMember `json:"items"` + NextCursor string `json:"next_cursor"` +} + +func groupMemberPageCacheID(topic string, opts types.GroupMemberListOptions) string { + limit := opts.Limit + if limit <= 0 || limit > 100 { + limit = 30 + } + return strings.TrimSpace(topic) + + "|role=" + strconv.Quote(strings.TrimSpace(opts.Role)) + + "|eligible_role=" + strconv.Quote(strings.TrimSpace(opts.EligibleRole)) + + "|query=" + strconv.Quote(strings.TrimSpace(opts.Query)) + + "|cursor=" + strconv.Quote(strings.TrimSpace(opts.Cursor)) + + "|limit=" + strconv.Itoa(limit) + + "|mentionable=" + strconv.FormatBool(opts.Mentionable) +} + +func CacheGroupMemberPage(tenantID types.TenantID, key string, payload groupMemberPageCache) { + if strings.TrimSpace(key) == "" { return } if err := CacheObject(tenantID, cacheNamespaceGroupPage, strings.TrimSpace(key), payload); err != nil { @@ -1890,17 +1929,17 @@ func CacheGroupMemberPage(tenantID types.TenantID, key string, payload map[strin } } -func GetCachedGroupMemberPage(tenantID types.TenantID, key string) (map[string]any, bool) { +func GetCachedGroupMemberPage(tenantID types.TenantID, key string) (groupMemberPageCache, bool) { if strings.TrimSpace(key) == "" { - return nil, false + return groupMemberPageCache{}, false } - var payload map[string]any + var payload groupMemberPageCache if ok, err := GetCachedObject(tenantID, cacheNamespaceGroupPage, strings.TrimSpace(key), &payload); err != nil { logs.Warn.Printf("cache group member page[%s]: read failed: %v", key, err) } else if ok { return payload, true } - return nil, false + return groupMemberPageCache{}, false } func invalidateGroupMemberPages(tenantID types.TenantID, topic string) { @@ -2113,7 +2152,11 @@ func (groupBusinessMapper) UpsertMute(tenantID types.TenantID, mute *types.Group if err != nil { return nil, err } - return tenantAdp.TenantGroupMuteUpsert(tenantID, mute) + updated, err := tenantAdp.TenantGroupMuteUpsert(tenantID, mute) + if err == nil { + invalidateGroupMemberPages(tenantID, mute.Topic) + } + return updated, err } func (groupBusinessMapper) DeleteMute(tenantID types.TenantID, topic string, uid, actor types.Uid, now time.Time) error { @@ -2124,7 +2167,11 @@ func (groupBusinessMapper) DeleteMute(tenantID types.TenantID, topic string, uid if err != nil { return err } - return tenantAdp.TenantGroupMuteDelete(tenantID, strings.TrimSpace(topic), uid, actor, now) + if err := tenantAdp.TenantGroupMuteDelete(tenantID, strings.TrimSpace(topic), uid, actor, now); err != nil { + return err + } + invalidateGroupMemberPages(tenantID, topic) + return nil } func (groupBusinessMapper) ListFiles(tenantID types.TenantID, topic, fileType, query, cursor string, limit int) ([]types.GroupFile, string, error) { diff --git a/server/store/types/groups.go b/server/store/types/groups.go index b51c0e6..342521e 100644 --- a/server/store/types/groups.go +++ b/server/store/types/groups.go @@ -171,6 +171,8 @@ type GroupAnnouncement struct { CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` AuthorUserIDRaw int64 `db:"author_user_id"` + ReadCount int `db:"read_count"` + MemberCount int `db:"member_count"` } type GroupMute struct { diff --git a/server/store/types/profile.go b/server/store/types/profile.go index 3d8decb..50f377c 100644 --- a/server/store/types/profile.go +++ b/server/store/types/profile.go @@ -29,24 +29,26 @@ type UserProfilePatch struct { } type UserPrivacySettings struct { - UserID Uid - AddMeMethods map[string]bool - ProfileVisibility string - LastSeenVisibility string - ReadReceipts bool - GroupInviteVerification bool - Version int - CreatedAt time.Time - UpdatedAt time.Time + UserID Uid + AddMeMethods map[string]bool + ProfileVisibility string + LastSeenVisibility string + ReadReceipts bool + FriendRequestVerification bool + GroupInviteVerification bool + Version int + CreatedAt time.Time + UpdatedAt time.Time } type UserPrivacyPatch struct { - AddMeMethods map[string]bool - ProfileVisibility *string - LastSeenVisibility *string - ReadReceipts *bool - GroupInviteVerification *bool - Version int + AddMeMethods map[string]bool + ProfileVisibility *string + LastSeenVisibility *string + ReadReceipts *bool + FriendRequestVerification *bool + GroupInviteVerification *bool + Version int } type AppRelease struct {