Implement user directory for local users #649

Signed-off-by: Anton Stuetz <opensource@ti-zero.com>
This commit is contained in:
Anton Stuetz 2019-10-17 21:09:29 +02:00
parent 145921f207
commit 9441f0863e
17 changed files with 585 additions and 6 deletions

View file

@ -18,6 +18,12 @@ import (
"context"
"database/sql"
"github.com/matrix-org/dendrite/clientapi/userutil"
"github.com/matrix-org/gomatrixserverlib"
searchtypes "github.com/matrix-org/dendrite/userdirectoryapi/types"
"github.com/matrix-org/dendrite/clientapi/auth/authtypes"
)
@ -39,6 +45,9 @@ const insertProfileSQL = "" +
const selectProfileByLocalpartSQL = "" +
"SELECT localpart, display_name, avatar_url FROM account_profiles WHERE localpart = $1"
const selectSearchTermLikeLocalpartOrDisplayName = "" +
"SELECT localpart, display_name, avatar_url FROM account_profiles WHERE localpart LIKE $1 OR display_name LIKE $1 LIMIT $2"
const setAvatarURLSQL = "" +
"UPDATE account_profiles SET avatar_url = $1 WHERE localpart = $2"
@ -46,10 +55,11 @@ const setDisplayNameSQL = "" +
"UPDATE account_profiles SET display_name = $1 WHERE localpart = $2"
type profilesStatements struct {
insertProfileStmt *sql.Stmt
selectProfileByLocalpartStmt *sql.Stmt
setAvatarURLStmt *sql.Stmt
setDisplayNameStmt *sql.Stmt
insertProfileStmt *sql.Stmt
selectProfileByLocalpartStmt *sql.Stmt
selectSearchTermLikeLocalpartOrDisplayNameStmt *sql.Stmt
setAvatarURLStmt *sql.Stmt
setDisplayNameStmt *sql.Stmt
}
func (s *profilesStatements) prepare(db *sql.DB) (err error) {
@ -69,6 +79,9 @@ func (s *profilesStatements) prepare(db *sql.DB) (err error) {
if s.setDisplayNameStmt, err = db.Prepare(setDisplayNameSQL); err != nil {
return
}
if s.selectSearchTermLikeLocalpartOrDisplayNameStmt, err = db.Prepare(selectSearchTermLikeLocalpartOrDisplayName); err != nil {
return
}
return
}
@ -92,6 +105,37 @@ func (s *profilesStatements) selectProfileByLocalpart(
return &profile, nil
}
func (s *profilesStatements) selectSearchTermLikeLocalpartOrDisplayName(
ctx context.Context, searchTerm string, limit int8, serverName gomatrixserverlib.ServerName,
) (*[]searchtypes.SearchResult, bool, error) {
//increase limit by one to find out if the query was limited
rows, err := s.selectSearchTermLikeLocalpartOrDisplayNameStmt.QueryContext(ctx, searchTerm, limit+1)
if err != nil {
return nil, false, err
}
searchResults := []searchtypes.SearchResult{}
counter := int8(1)
limited := false
for rows.Next() {
if counter > limit {
limited = true
break
}
var r searchtypes.SearchResult
err = rows.Scan(
&r.UserId, &r.DisplayName, &r.AvatarUrl,
)
r.UserId = userutil.MakeUserID(r.UserId, serverName)
if err != nil {
return &searchResults, false, err
}
searchResults = append(searchResults, r)
counter++
}
return &searchResults, limited, nil
}
func (s *profilesStatements) setAvatarURL(
ctx context.Context, localpart string, avatarURL string,
) (err error) {

View file

@ -19,6 +19,8 @@ import (
"database/sql"
"errors"
searchtypes "github.com/matrix-org/dendrite/userdirectoryapi/types"
"github.com/matrix-org/dendrite/clientapi/auth/authtypes"
"github.com/matrix-org/dendrite/common"
"github.com/matrix-org/gomatrixserverlib"
@ -28,6 +30,69 @@ import (
_ "github.com/lib/pq"
)
type AccountDatabase interface {
GetAccountByPassword(
ctx context.Context, localpart, plaintextPassword string,
) (*authtypes.Account, error)
GetProfileByLocalpart(
ctx context.Context, localpart string,
) (*authtypes.Profile, error)
SetAvatarURL(
ctx context.Context, localpart string, avatarURL string,
) error
SetDisplayName(
ctx context.Context, localpart string, displayName string,
) error
CreateAccount(
ctx context.Context, localpart, plaintextPassword, appserviceID string,
) (*authtypes.Account, error)
UpdateMemberships(
ctx context.Context, eventsToAdd []gomatrixserverlib.Event, idsToRemove []string,
) error
GetMembershipInRoomByLocalpart(
ctx context.Context, localpart, roomID string,
) (authtypes.Membership, error)
GetMembershipsByLocalpart(
ctx context.Context, localpart string,
) (memberships []authtypes.Membership, err error)
SaveAccountData(
ctx context.Context, localpart, roomID, dataType, content string,
) error
GetAccountData(ctx context.Context, localpart string) (
global []gomatrixserverlib.ClientEvent,
rooms map[string][]gomatrixserverlib.ClientEvent,
err error,
)
GetAccountDataByType(
ctx context.Context, localpart, roomID, dataType string,
) (data *gomatrixserverlib.ClientEvent, err error)
GetNewNumericLocalpart(
ctx context.Context,
) (int64, error)
SaveThreePIDAssociation(
ctx context.Context, threepid, localpart, medium string,
) (err error)
RemoveThreePIDAssociation(
ctx context.Context, threepid string, medium string,
) (err error)
GetLocalpartForThreePID(
ctx context.Context, threepid string, medium string,
) (localpart string, err error)
GetThreePIDsForLocalpart(
ctx context.Context, localpart string,
) (threepids []authtypes.ThreePID, err error)
GetFilter(
ctx context.Context, localpart string, filterID string,
) (*gomatrixserverlib.Filter, error)
PutFilter(
ctx context.Context, localpart string, filter *gomatrixserverlib.Filter,
) (string, error)
CheckAccountAvailability(ctx context.Context, localpart string) (bool, error)
GetAccountByLocalpart(ctx context.Context, localpart string,
) (*authtypes.Account, error)
SearchUserIdAndDisplayName(ctx context.Context, searchTerm string, limit int8) (*[]searchtypes.SearchResult, bool, error)
}
// Database represents an account database
type Database struct {
db *sql.DB
@ -379,3 +444,7 @@ func (d *Database) GetAccountByLocalpart(ctx context.Context, localpart string,
) (*authtypes.Account, error) {
return d.accounts.selectAccountByLocalpart(ctx, localpart)
}
func (d *Database) SearchUserIdAndDisplayName(ctx context.Context, searchTerm string, limit int8) (*[]searchtypes.SearchResult, bool, error) {
return d.profiles.selectSearchTermLikeLocalpartOrDisplayName(ctx, searchTerm, limit, d.serverName)
}

View file

@ -517,7 +517,6 @@ func handleRegistrationFlow(
deviceDB *devices.Database,
) util.JSONResponse {
// TODO: Shared secret registration (create new user scripts)
// TODO: Enable registration config flag
// TODO: Guest account upgrading
// TODO: Handle loading of previous session parameters from database.

View file

@ -18,6 +18,8 @@ import (
"flag"
"net/http"
"github.com/matrix-org/dendrite/userdirectoryapi"
"github.com/matrix-org/dendrite/appservice"
"github.com/matrix-org/dendrite/clientapi"
"github.com/matrix-org/dendrite/common"
@ -71,6 +73,7 @@ func main() {
mediaapi.SetupMediaAPIComponent(base, deviceDB)
publicroomsapi.SetupPublicRoomsAPIComponent(base, deviceDB)
syncapi.SetupSyncAPIComponent(base, deviceDB, accountDB, query)
userdirectoryapi.SetupUserDirectoryApi(base, accountDB, deviceDB)
httpHandler := common.WrapHandlerInCORS(base.APIMux)

View file

@ -0,0 +1,34 @@
// Copyright 2017 Vector Creations Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"github.com/matrix-org/dendrite/common/basecomponent"
"github.com/matrix-org/dendrite/userdirectoryapi"
)
func main() {
cfg := basecomponent.ParseFlags()
base := basecomponent.NewBaseDendrite(cfg, "PublicRoomsAPI")
defer base.Close() // nolint: errcheck
deviceDB := base.CreateDeviceDB()
accountDB := base.CreateAccountsDB()
userdirectoryapi.SetupUserDirectoryApi(base, accountDB, deviceDB)
base.SetupAndServeHTTP(string(base.Cfg.Bind.UserDirectoryAPI), string(base.Cfg.Listen.UserDirectoryAPI))
}

View file

@ -30,7 +30,7 @@ import (
"github.com/matrix-org/gomatrixserverlib"
"github.com/sirupsen/logrus"
"golang.org/x/crypto/ed25519"
yaml "gopkg.in/yaml.v2"
"gopkg.in/yaml.v2"
jaegerconfig "github.com/uber/jaeger-client-go/config"
jaegermetrics "github.com/uber/jaeger-lib/metrics"
@ -206,6 +206,7 @@ type Dendrite struct {
RoomServer Address `yaml:"room_server"`
FederationSender Address `yaml:"federation_sender"`
PublicRoomsAPI Address `yaml:"public_rooms_api"`
UserDirectoryAPI Address `yaml:"user_directory_api"`
TypingServer Address `yaml:"typing_server"`
} `yaml:"bind"`
@ -219,6 +220,7 @@ type Dendrite struct {
RoomServer Address `yaml:"room_server"`
FederationSender Address `yaml:"federation_sender"`
PublicRoomsAPI Address `yaml:"public_rooms_api"`
UserDirectoryAPI Address `yaml:"user_directory_api"`
TypingServer Address `yaml:"typing_server"`
} `yaml:"listen"`

View file

@ -63,6 +63,7 @@ listen:
media_api: "localhost:7774"
appservice_api: "localhost:7777"
typing_server: "localhost:7778"
user_directory_api: "localhost:7779"
logging:
- type: "file"
level: "info"

View file

@ -115,6 +115,7 @@ listen:
federation_sender: "localhost:7776"
appservice_api: "localhost:7777"
typing_server: "localhost:7778"
user_directory_api: "localhost:7779"
# The configuration for tracing the dendrite components.
tracing:

View file

@ -115,6 +115,7 @@ listen:
public_rooms_api: "public_rooms_api:7775"
federation_sender: "federation_sender:7776"
typing_server: "typing_server:7777"
user_directory_api: "localhost:7779"
# The configuration for tracing the dendrite components.
tracing:

View file

@ -134,6 +134,18 @@ services:
networks:
- internal
userdirectory_api:
container_name: dendrite_userdirectory_api
hostname: userdirectory_api
entrypoint: ["bash", "./docker/services/userdirectory-api.sh"]
build: ./
volumes:
- ..:/build
depends_on:
- postgres
networks:
- internal
federation_sender:
container_name: dendrite_federation_sender
hostname: federation_sender

View file

@ -0,0 +1,5 @@
#!/bin/bash
bash ./docker/build.sh
./bin/dendrite-userdirectory-api-server --config dendrite.yaml

View file

@ -0,0 +1,5 @@
# User Directory API
This server is responsible for serving requests hitting `/user_directory` as per:
https://matrix.org/docs/spec/client_server/r0.5.0#post-matrix-client-r0-user-directory-search

View file

@ -0,0 +1,50 @@
// Copyright 2019 Anton Stuetz
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package routing
import (
"net/http"
"github.com/matrix-org/dendrite/userdirectoryapi/search"
"github.com/matrix-org/dendrite/clientapi/auth/storage/devices"
"github.com/matrix-org/dendrite/clientapi/auth"
"github.com/matrix-org/dendrite/clientapi/auth/authtypes"
"github.com/matrix-org/dendrite/common"
"github.com/matrix-org/util"
"github.com/gorilla/mux"
"github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
)
const pathPrefixR0 = "/_matrix/client/r0"
func Setup(apiMux *mux.Router, accountDB *accounts.Database, deviceDB *devices.Database) {
r0mux := apiMux.PathPrefix(pathPrefixR0).Subrouter()
authData := auth.Data{
AccountDB: nil,
DeviceDB: deviceDB,
AppServices: nil,
}
r0mux.Handle("/user_directory/search",
common.MakeAuthAPI("user_directory_search", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
return search.Search(req, accountDB)
})).Methods(http.MethodPost)
}

View file

@ -0,0 +1,80 @@
// Copyright 2019 Anton Stuetz
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package search
import (
"errors"
"net/http"
"github.com/matrix-org/dendrite/clientapi/httputil"
"github.com/matrix-org/dendrite/clientapi/jsonerror"
searchtypes "github.com/matrix-org/dendrite/userdirectoryapi/types"
"github.com/matrix-org/util"
"github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
)
const (
DEFAULT_LIMIT = 10
)
type userSearchRequest struct {
SearchTerm string `json:"search_term"`
Limit int8 `json:"limit"`
}
type userSearchResponse struct {
Results *[]searchtypes.SearchResult `json:"results"`
Limited bool `json:"limited"`
}
func Search(req *http.Request, accountDB accounts.AccountDatabase) util.JSONResponse {
var r userSearchRequest
resErr := httputil.UnmarshalJSONRequest(req, &r)
if resErr != nil {
return *resErr
}
err := validateSearchTerm(r.SearchTerm)
if err != nil {
return util.JSONResponse{
Code: http.StatusBadRequest,
JSON: jsonerror.InvalidArgumentValue(err.Error()),
}
}
if r.Limit == 0 {
r.Limit = DEFAULT_LIMIT
}
results, limited, err := accountDB.SearchUserIdAndDisplayName(req.Context(), r.SearchTerm+"%", r.Limit)
if err != nil {
return util.ErrorResponse(err)
}
return mapProfilesToResponse(results, limited)
}
func validateSearchTerm(search_term string) error {
if len(search_term) == 0 {
return errors.New("Search term is empty")
}
return nil
}
func mapProfilesToResponse(results *[]searchtypes.SearchResult, limited bool) util.JSONResponse {
return util.JSONResponse{
Code: http.StatusOK,
JSON: userSearchResponse{results, limited},
}
}

View file

@ -0,0 +1,224 @@
package search
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
"github.com/matrix-org/dendrite/clientapi/auth/authtypes"
searchtypes "github.com/matrix-org/dendrite/userdirectoryapi/types"
"github.com/matrix-org/gomatrixserverlib"
)
type MockAccountDatabase struct {
limited bool
wantError bool
}
func (d *MockAccountDatabase) GetAccountByPassword(
ctx context.Context, localpart, plaintextPassword string,
) (*authtypes.Account, error) {
panic("implement me")
}
func (d *MockAccountDatabase) GetProfileByLocalpart(
ctx context.Context, localpart string,
) (*authtypes.Profile, error) {
panic("implement me")
}
func (d *MockAccountDatabase) SetAvatarURL(
ctx context.Context, localpart string, avatarURL string,
) error {
panic("implement me")
}
func (d *MockAccountDatabase) SetDisplayName(
ctx context.Context, localpart string, displayName string,
) error {
panic("implement me")
}
func (d *MockAccountDatabase) CreateAccount(
ctx context.Context, localpart, plaintextPassword, appserviceID string,
) (*authtypes.Account, error) {
panic("implement me")
}
func (d *MockAccountDatabase) UpdateMemberships(
ctx context.Context, eventsToAdd []gomatrixserverlib.Event, idsToRemove []string,
) error {
panic("implement me")
}
func (d *MockAccountDatabase) GetMembershipInRoomByLocalpart(
ctx context.Context, localpart, roomID string,
) (authtypes.Membership, error) {
panic("implement me")
}
func (d *MockAccountDatabase) GetMembershipsByLocalpart(
ctx context.Context, localpart string,
) (memberships []authtypes.Membership, err error) {
panic("implement me")
}
func (d *MockAccountDatabase) SaveAccountData(
ctx context.Context, localpart, roomID, dataType, content string,
) error {
panic("implement me")
}
func (d *MockAccountDatabase) GetAccountData(ctx context.Context, localpart string) (
global []gomatrixserverlib.ClientEvent,
rooms map[string][]gomatrixserverlib.ClientEvent,
err error,
) {
panic("implement me")
}
func (d *MockAccountDatabase) GetAccountDataByType(
ctx context.Context, localpart, roomID, dataType string,
) (data *gomatrixserverlib.ClientEvent, err error) {
panic("implement me")
}
func (d *MockAccountDatabase) GetNewNumericLocalpart(
ctx context.Context,
) (int64, error) {
panic("implement me")
}
func (d *MockAccountDatabase) SaveThreePIDAssociation(
ctx context.Context, threepid, localpart, medium string,
) (err error) {
panic("implement me")
}
func (d *MockAccountDatabase) RemoveThreePIDAssociation(
ctx context.Context, threepid string, medium string,
) (err error) {
panic("implement me")
}
func (d *MockAccountDatabase) GetLocalpartForThreePID(
ctx context.Context, threepid string, medium string,
) (localpart string, err error) {
panic("implement me")
}
func (d *MockAccountDatabase) GetThreePIDsForLocalpart(
ctx context.Context, localpart string,
) (threepids []authtypes.ThreePID, err error) {
panic("implement me")
}
func (d *MockAccountDatabase) GetFilter(
ctx context.Context, localpart string, filterID string,
) (*gomatrixserverlib.Filter, error) {
panic("implement me")
}
func (d *MockAccountDatabase) PutFilter(
ctx context.Context, localpart string, filter *gomatrixserverlib.Filter,
) (string, error) {
panic("implement me")
}
func (d *MockAccountDatabase) CheckAccountAvailability(ctx context.Context, localpart string) (bool, error) {
panic("implement me")
}
func (d *MockAccountDatabase) GetAccountByLocalpart(ctx context.Context, localpart string,
) (*authtypes.Account, error) {
panic("implement me")
}
func (d *MockAccountDatabase) SearchUserIdAndDisplayName(ctx context.Context, searchTerm string, limit int8) (*[]searchtypes.SearchResult, bool, error) {
var searchResults []searchtypes.SearchResult
searchResults = append(searchResults, searchtypes.SearchResult{UserId: "alice:localhost", AvatarUrl: "", DisplayName: ""})
if d.wantError {
return nil, false, fmt.Errorf("")
} else {
return &searchResults, d.limited, nil
}
}
func TestSearch(t *testing.T) {
type testCase struct {
name string
wantError bool
errorCode int
results []string
limited bool
requestBody string
}
tt := []testCase{
{
"Find user do not hit limit",
false,
0,
[]string{"alice:localhost"},
false,
"{ \"search_term\": \"alice\" }",
},
{
"Find user do hit limit",
false,
0,
[]string{"alice:localhost"},
true,
"{ \"search_term\": \"alice\" }",
},
{
"Find user and fail",
true,
500,
[]string{"alice:localhost"},
false,
"{ \"search_term\": \"alice\" }",
},
{
"Find user and fail to parse request",
true,
400,
[]string{"alice:localhost"},
false,
"{ \"search_term\": \"alicINVALID }",
},
}
setupAccountDb := func(limited bool, wantError bool) accounts.AccountDatabase {
return &MockAccountDatabase{limited, wantError}
}
for _, tc := range tt {
fmt.Printf("Executing %s", tc.name)
requestBody := ioutil.NopCloser(strings.NewReader(tc.requestBody))
req := &http.Request{Body: requestBody}
searchResults := Search(req, setupAccountDb(tc.limited, tc.wantError))
if tc.wantError {
assert.EqualValues(t, searchResults.Code, tc.errorCode)
} else {
response := searchResults.JSON.(userSearchResponse)
for _, result := range tc.results {
exists := false
for _, item := range *response.Results {
if item.UserId == result {
exists = true
break
}
}
assert.EqualValues(t, true, exists, "Expected value not found in response")
}
}
}
}

View file

@ -0,0 +1,21 @@
// Copyright 2019 Anton Stuetz
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package searchtypes
type SearchResult struct {
UserId string `json:"user_id"`
DisplayName string `json:"display_name,omitempty"`
AvatarUrl string `json:"avatar_url,omitempty"`
}

View file

@ -0,0 +1,28 @@
// Copyright 2019 Anton Stuetz
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package userdirectoryapi
import (
"github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
"github.com/matrix-org/dendrite/clientapi/auth/storage/devices"
"github.com/matrix-org/dendrite/common/basecomponent"
"github.com/matrix-org/dendrite/userdirectoryapi/routing"
)
func SetupUserDirectoryApi(base *basecomponent.BaseDendrite, accountDB *accounts.Database, deviceDB *devices.Database) {
routing.Setup(base.APIMux, accountDB, deviceDB)
}