feat: allow viewing user profile QR codes
This commit is contained in:
+138
-10
@@ -4527,15 +4527,22 @@ func serveIMUserSearch(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func serveIMUserResource(wrt http.ResponseWriter, req *http.Request) {
|
func serveIMUserResource(wrt http.ResponseWriter, req *http.Request) {
|
||||||
userID, tail, ok := userResourcePath(req.URL.Path)
|
identifier, tail, ok := userResourcePath(req.URL.Path)
|
||||||
if !ok || tail != "profile" {
|
if !ok {
|
||||||
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil)
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil)
|
||||||
return
|
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 {
|
if req.Method != http.MethodGet {
|
||||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||||
return
|
return
|
||||||
@@ -4544,6 +4551,11 @@ func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, userID types
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
userID, err := resolveIMUserIdentifier(principal, identifier)
|
||||||
|
if err != nil {
|
||||||
|
writeIMResolveUserError(wrt, req, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
if userID.IsZero() || userID == principal.Uid {
|
if userID.IsZero() || userID == principal.Uid {
|
||||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
||||||
return
|
return
|
||||||
@@ -4574,6 +4586,121 @@ func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, userID types
|
|||||||
writeIMSuccess(wrt, req, userProfilePayload(user, ctx))
|
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) {
|
func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
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
|
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/"
|
const prefix = "/v1/im/users/"
|
||||||
idx := strings.Index(path, prefix)
|
idx := strings.Index(path, prefix)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return types.ZeroUid, "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
rest := strings.TrimPrefix(path[idx:], prefix)
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
||||||
userID, tail, ok := strings.Cut(rest, "/")
|
identifier, tail, ok := strings.Cut(rest, "/")
|
||||||
if !ok || userID == "" || tail == "" {
|
identifier = strings.TrimSpace(identifier)
|
||||||
return types.ZeroUid, "", false
|
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) {
|
func organizationDepartmentResourcePath(path string) (string, string, bool) {
|
||||||
|
|||||||
@@ -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) {
|
func TestServeIMBlocks(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
|
|||||||
Reference in New Issue
Block a user