From 3e1ce5df26137648ee16b5a257d68b7e3e4be9ae Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Thu, 20 Aug 2026 17:49:45 +0800 Subject: [PATCH] feat: allow viewing user profile QR codes --- server/im_http.go | 148 ++++++++++++++++++++++++++++++++++++++--- server/im_http_test.go | 142 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 10 deletions(-) diff --git a/server/im_http.go b/server/im_http.go index d241e0d..6413523 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -4527,15 +4527,22 @@ func serveIMUserSearch(wrt http.ResponseWriter, req *http.Request) { } func serveIMUserResource(wrt http.ResponseWriter, req *http.Request) { - userID, tail, ok := userResourcePath(req.URL.Path) - if !ok || tail != "profile" { + identifier, tail, ok := userResourcePath(req.URL.Path) + if !ok { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil) return } - serveIMUserProfile(wrt, req, userID) + switch tail { + case "profile": + serveIMUserProfile(wrt, req, identifier) + case "qrcodes": + serveIMUserQRCodes(wrt, req, identifier) + default: + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil) + } } -func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, userID types.Uid) { +func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, identifier string) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return @@ -4544,6 +4551,11 @@ func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, userID types if !ok { return } + userID, err := resolveIMUserIdentifier(principal, identifier) + if err != nil { + writeIMResolveUserError(wrt, req, err) + return + } if userID.IsZero() || userID == principal.Uid { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) return @@ -4574,6 +4586,121 @@ func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, userID types writeIMSuccess(wrt, req, userProfilePayload(user, ctx)) } +func serveIMUserQRCodes(wrt http.ResponseWriter, req *http.Request, identifier string) { + 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 + } + userID, err := resolveIMUserIdentifier(principal, identifier) + if err != nil { + writeIMResolveUserError(wrt, req, err) + return + } + if userID.IsZero() { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) + return + } + var body imQRCodeCreateRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + purpose := strings.TrimSpace(body.Purpose) + if purpose == "" { + purpose = "add_friend" + } + if purpose != "add_friend" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"}) + return + } + user, err := store.Users.Get(principal.TenantID, userID) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if user == nil { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil) + return + } + if userID != principal.Uid { + ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, userID, principal.Uid.P2PName(userID)) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + privacy, err := store.Profiles.GetPrivacy(principal.TenantID, userID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + if !imPrivacyAllowsProfile(privacy, ctx.Relationship) || !imPrivacyAllowsAdd(privacy, "qr") { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + } + expiresIn := body.ExpiresInSeconds + if expiresIn <= 0 { + expiresIn = imQRCodeDefaultExpiresInSeconds + } + if expiresIn > imQRCodeMaxExpiresInSeconds { + expiresIn = imQRCodeMaxExpiresInSeconds + } + expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond) + token, err := generateIMQRCodeToken(principal.TenantID, userID, purpose, expiresAt) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "qr_token": token, + "qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=" + url.QueryEscape(purpose), + "expires_at": expiresAt.Format(time.RFC3339Nano), + "display_profile": userSummaryPayload(user, ""), + }) +} + +func resolveIMUserIdentifier(principal *imPrincipal, identifier string) (types.Uid, error) { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return types.ZeroUid, types.ErrMalformed + } + if userID := types.ParseUserId(identifier); !userID.IsZero() { + return userID, nil + } + if store.Relations == nil { + return types.ZeroUid, types.ErrUnsupported + } + items, _, err := store.Relations.SearchUsers(principal.TenantID, principal.Uid, identifier, "exact_account", "", 2) + if err != nil { + return types.ZeroUid, err + } + for _, item := range items { + if item.User == nil { + continue + } + if userID := item.User.Uid(); !userID.IsZero() { + return userID, nil + } + } + return types.ZeroUid, types.ErrNotFound +} + +func writeIMResolveUserError(wrt http.ResponseWriter, req *http.Request, err error) { + switch { + case errors.Is(err, types.ErrMalformed): + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) + case errors.Is(err, types.ErrNotFound): + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil) + case errors.Is(err, types.ErrPermissionDenied): + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + default: + writeIMRelationError(wrt, req, err) + } +} + func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -7492,18 +7619,19 @@ func groupResourcePath(path string) (string, string, bool) { return topic, strings.Trim(tail, "/"), true } -func userResourcePath(path string) (types.Uid, string, bool) { +func userResourcePath(path string) (string, string, bool) { const prefix = "/v1/im/users/" idx := strings.Index(path, prefix) if idx < 0 { - return types.ZeroUid, "", false + return "", "", false } rest := strings.TrimPrefix(path[idx:], prefix) - userID, tail, ok := strings.Cut(rest, "/") - if !ok || userID == "" || tail == "" { - return types.ZeroUid, "", false + identifier, tail, ok := strings.Cut(rest, "/") + identifier = strings.TrimSpace(identifier) + if !ok || identifier == "" || tail == "" { + return "", "", false } - return types.ParseUserId(strings.TrimSpace(userID)), strings.Trim(tail, "/"), true + return identifier, strings.Trim(tail, "/"), true } func organizationDepartmentResourcePath(path string) (string, string, bool) { diff --git a/server/im_http_test.go b/server/im_http_test.go index bed3a5c..41bbc4b 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -4196,6 +4196,148 @@ func TestServeIMUserProfile(t *testing.T) { } } +func TestServeIMUserQRCodes(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldUsers := store.Users + oldRelations := store.Relations + oldProfiles := store.Profiles + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + peer := types.Uid(2) + users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ + ObjHeader: types.ObjHeader{Id: peer.String()}, + TenantID: 7, + Public: map[string]any{"display_name": "Alice"}, + }, nil) + store.Users = users + store.Relations = &testRelationsStore{context: &types.RelationshipContext{ + TenantID: 7, + UserID: 1, + PeerID: peer, + Topic: types.Uid(1).P2PName(peer), + Relationship: types.RelationshipFriend, + CanSend: true, + }} + store.Profiles = &testProfilesStore{} + defer func() { + store.Users = oldUsers + store.Relations = oldRelations + store.Profiles = oldProfiles + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/users/"+peer.UserId()+"/qrcodes", bytes.NewBufferString(`{"expires_in_seconds":60}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMUserResource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["qr_token"] == "" || !strings.Contains(data["qr_payload"].(string), "purpose=add_friend") { + t.Fatalf("unexpected qr response: %#v", data) + } +} + +func TestServeIMUserQRCodesByUsername(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldUsers := store.Users + oldRelations := store.Relations + oldProfiles := store.Profiles + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + peer := types.Uid(2) + peerUser := &types.User{ + ObjHeader: types.ObjHeader{Id: peer.String()}, + TenantID: 7, + Public: map[string]any{"display_name": "Alice", "public_account": "alice001"}, + } + users.EXPECT().Get(types.TenantID(7), peer).Return(peerUser, nil) + store.Users = users + store.Relations = &testRelationsStore{ + context: &types.RelationshipContext{ + TenantID: 7, + UserID: 1, + PeerID: peer, + Topic: types.Uid(1).P2PName(peer), + Relationship: types.RelationshipFriend, + CanSend: true, + }, + searchResults: []types.UserSearchItem{{User: peerUser, Relationship: types.RelationshipFriend}}, + } + store.Profiles = &testProfilesStore{} + defer func() { + store.Users = oldUsers + store.Relations = oldRelations + store.Profiles = oldProfiles + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/users/alice001/qrcodes", bytes.NewBufferString(`{"expires_in_seconds":60}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMUserResource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + token := data["qr_token"].(string) + resolved, err := parseIMQRCodeToken(token, "add_friend", time.Now().UTC()) + if err != nil { + t.Fatalf("parse token: %v", err) + } + if resolved.UserID != peer.UserId() { + t.Fatalf("token user id: got %s, want %s", resolved.UserID, peer.UserId()) + } +} + +func TestServeIMUserQRCodesRespectsPrivacy(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldUsers := store.Users + oldRelations := store.Relations + oldProfiles := store.Profiles + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + peer := types.Uid(2) + users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ + ObjHeader: types.ObjHeader{Id: peer.String()}, + TenantID: 7, + }, nil) + store.Users = users + store.Relations = &testRelationsStore{context: &types.RelationshipContext{ + TenantID: 7, + UserID: 1, + PeerID: peer, + Topic: types.Uid(1).P2PName(peer), + Relationship: types.RelationshipFriend, + CanSend: true, + }} + store.Profiles = &testProfilesStore{privacy: &types.UserPrivacySettings{ + UserID: peer, AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": false, "group": true}, + ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true, + }} + defer func() { + store.Users = oldUsers + store.Relations = oldRelations + store.Profiles = oldProfiles + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/users/"+peer.UserId()+"/qrcodes", bytes.NewBufferString(`{}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMUserResource(rec, req) + + if rec.Code != http.StatusForbidden || decodeIMHTTPResponse(t, rec).Reason != imReasonPermissionDenied { + t.Fatalf("privacy status: got %d body=%s", rec.Code, rec.Body.String()) + } +} + func TestServeIMBlocks(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish()