aboutsummaryrefslogtreecommitdiff
path: root/clientapi
diff options
context:
space:
mode:
authorruben <code@rbn.im>2019-05-21 22:56:55 +0200
committerBrendan Abolivier <babolivier@matrix.org>2019-05-21 21:56:55 +0100
commit74827428bd3e11faab65f12204449c1b9469b0ae (patch)
tree0decafa542436a0667ed2d3e3cfd4df0f03de1e5 /clientapi
parent4d588f7008afe5600219ac0930c2eee2de5c447b (diff)
use go module for dependencies (#594)
Diffstat (limited to 'clientapi')
-rw-r--r--clientapi/README.md11
-rw-r--r--clientapi/auth/auth.go210
-rw-r--r--clientapi/auth/authtypes/account.go31
-rw-r--r--clientapi/auth/authtypes/device.go25
-rw-r--r--clientapi/auth/authtypes/flow.go21
-rw-r--r--clientapi/auth/authtypes/logintypes.go12
-rw-r--r--clientapi/auth/authtypes/membership.go23
-rw-r--r--clientapi/auth/authtypes/profile.go22
-rw-r--r--clientapi/auth/authtypes/threepid.go21
-rw-r--r--clientapi/auth/storage/accounts/account_data_table.go148
-rw-r--r--clientapi/auth/storage/accounts/accounts_table.go153
-rw-r--r--clientapi/auth/storage/accounts/filter_table.go107
-rw-r--r--clientapi/auth/storage/accounts/membership_table.go132
-rw-r--r--clientapi/auth/storage/accounts/profile_table.go107
-rw-r--r--clientapi/auth/storage/accounts/storage.go380
-rw-r--r--clientapi/auth/storage/accounts/threepid_table.go129
-rw-r--r--clientapi/auth/storage/devices/devices_table.go208
-rw-r--r--clientapi/auth/storage/devices/storage.go167
-rw-r--r--clientapi/clientapi.go72
-rw-r--r--clientapi/consumers/roomserver.go144
-rw-r--r--clientapi/httputil/httputil.go46
-rw-r--r--clientapi/httputil/parse.go39
-rw-r--r--clientapi/jsonerror/jsonerror.go148
-rw-r--r--clientapi/jsonerror/jsonerror_test.go44
-rw-r--r--clientapi/producers/roomserver.go112
-rw-r--r--clientapi/producers/syncapi.go50
-rw-r--r--clientapi/producers/typingserver.go54
-rw-r--r--clientapi/producers/userupdate.go62
-rw-r--r--clientapi/routing/account_data.go76
-rw-r--r--clientapi/routing/createroom.go337
-rw-r--r--clientapi/routing/device.go155
-rw-r--r--clientapi/routing/directory.go183
-rw-r--r--clientapi/routing/filter.go123
-rw-r--r--clientapi/routing/joinroom.go333
-rw-r--r--clientapi/routing/login.go152
-rw-r--r--clientapi/routing/logout.go71
-rw-r--r--clientapi/routing/membership.go217
-rw-r--r--clientapi/routing/memberships.go60
-rw-r--r--clientapi/routing/profile.go292
-rw-r--r--clientapi/routing/register.go958
-rw-r--r--clientapi/routing/register_test.go209
-rw-r--r--clientapi/routing/routing.go413
-rw-r--r--clientapi/routing/sendevent.go153
-rw-r--r--clientapi/routing/sendtyping.go80
-rw-r--r--clientapi/routing/threepid.go178
-rw-r--r--clientapi/routing/voip.go78
-rw-r--r--clientapi/routing/whoami.go34
-rw-r--r--clientapi/threepid/invites.go364
-rw-r--r--clientapi/threepid/threepid.go187
-rw-r--r--clientapi/userutil/userutil.go49
-rw-r--r--clientapi/userutil/userutil_test.go71
51 files changed, 7451 insertions, 0 deletions
diff --git a/clientapi/README.md b/clientapi/README.md
new file mode 100644
index 00000000..6d4a9dce
--- /dev/null
+++ b/clientapi/README.md
@@ -0,0 +1,11 @@
+This component roughly corresponds to "Client Room Send" and "Client Sync" on [the WIRING diagram](https://github.com/matrix-org/dendrite/blob/master/WIRING.md).
+This component produces multiple binaries.
+
+## Internals
+
+- HTTP routing is done using `gorilla/mux` and the routing paths are in the `routing` package.
+
+### Writers
+- Each HTTP "write operation" (`/createRoom`, `/rooms/$room_id/send/$type`, etc) is contained entirely to a single file in the `writers` package.
+- This file contains the request and response `struct` definitions, as well as a `Validate() bool` function to validate incoming requests.
+- The entry point for each write operation is a stand-alone function as this makes testing easier. All dependencies should be injected into this function, including server keys/name, etc.
diff --git a/clientapi/auth/auth.go b/clientapi/auth/auth.go
new file mode 100644
index 00000000..00943fb8
--- /dev/null
+++ b/clientapi/auth/auth.go
@@ -0,0 +1,210 @@
+// 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 auth implements authentication checks and storage.
+package auth
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/matrix-org/dendrite/appservice/types"
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/userutil"
+ "github.com/matrix-org/dendrite/common/config"
+ "github.com/matrix-org/util"
+)
+
+// OWASP recommends at least 128 bits of entropy for tokens: https://www.owasp.org/index.php/Insufficient_Session-ID_Length
+// 32 bytes => 256 bits
+var tokenByteLength = 32
+
+// DeviceDatabase represents a device database.
+type DeviceDatabase interface {
+ // Look up the device matching the given access token.
+ GetDeviceByAccessToken(ctx context.Context, token string) (*authtypes.Device, error)
+}
+
+// AccountDatabase represents an account database.
+type AccountDatabase interface {
+ // Look up the account matching the given localpart.
+ GetAccountByLocalpart(ctx context.Context, localpart string) (*authtypes.Account, error)
+}
+
+// Data contains information required to authenticate a request.
+type Data struct {
+ AccountDB AccountDatabase
+ DeviceDB DeviceDatabase
+ // AppServices is the list of all registered AS
+ AppServices []config.ApplicationService
+}
+
+// VerifyUserFromRequest authenticates the HTTP request,
+// on success returns Device of the requester.
+// Finds local user or an application service user.
+// Note: For an AS user, AS dummy device is returned.
+// On failure returns an JSON error response which can be sent to the client.
+func VerifyUserFromRequest(
+ req *http.Request, data Data,
+) (*authtypes.Device, *util.JSONResponse) {
+ // Try to find the Application Service user
+ token, err := ExtractAccessToken(req)
+ if err != nil {
+ return nil, &util.JSONResponse{
+ Code: http.StatusUnauthorized,
+ JSON: jsonerror.MissingToken(err.Error()),
+ }
+ }
+
+ // Search for app service with given access_token
+ var appService *config.ApplicationService
+ for _, as := range data.AppServices {
+ if as.ASToken == token {
+ appService = &as
+ break
+ }
+ }
+
+ if appService != nil {
+ // Create a dummy device for AS user
+ dev := authtypes.Device{
+ // Use AS dummy device ID
+ ID: types.AppServiceDeviceID,
+ // AS dummy device has AS's token.
+ AccessToken: token,
+ }
+
+ userID := req.URL.Query().Get("user_id")
+ localpart, err := userutil.ParseUsernameParam(userID, nil)
+ if err != nil {
+ return nil, &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.InvalidUsername(err.Error()),
+ }
+ }
+
+ if localpart != "" { // AS is masquerading as another user
+ // Verify that the user is registered
+ account, err := data.AccountDB.GetAccountByLocalpart(req.Context(), localpart)
+ // Verify that account exists & appServiceID matches
+ if err == nil && account.AppServiceID == appService.ID {
+ // Set the userID of dummy device
+ dev.UserID = userID
+ return &dev, nil
+ }
+
+ return nil, &util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("Application service has not registered this user"),
+ }
+ }
+
+ // AS is not masquerading as any user, so use AS's sender_localpart
+ dev.UserID = appService.SenderLocalpart
+ return &dev, nil
+ }
+
+ // Try to find local user from device database
+ dev, devErr := verifyAccessToken(req, data.DeviceDB)
+ if devErr == nil {
+ return dev, verifyUserParameters(req)
+ }
+
+ return nil, &util.JSONResponse{
+ Code: http.StatusUnauthorized,
+ JSON: jsonerror.UnknownToken("Unrecognized access token"),
+ }
+}
+
+// verifyUserParameters ensures that a request coming from a regular user is not
+// using any query parameters reserved for an application service
+func verifyUserParameters(req *http.Request) *util.JSONResponse {
+ if req.URL.Query().Get("ts") != "" {
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.Unknown("parameter 'ts' not allowed without valid parameter 'access_token'"),
+ }
+ }
+ return nil
+}
+
+// verifyAccessToken verifies that an access token was supplied in the given HTTP request
+// and returns the device it corresponds to. Returns resErr (an error response which can be
+// sent to the client) if the token is invalid or there was a problem querying the database.
+func verifyAccessToken(req *http.Request, deviceDB DeviceDatabase) (device *authtypes.Device, resErr *util.JSONResponse) {
+ token, err := ExtractAccessToken(req)
+ if err != nil {
+ resErr = &util.JSONResponse{
+ Code: http.StatusUnauthorized,
+ JSON: jsonerror.MissingToken(err.Error()),
+ }
+ return
+ }
+ device, err = deviceDB.GetDeviceByAccessToken(req.Context(), token)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ resErr = &util.JSONResponse{
+ Code: http.StatusUnauthorized,
+ JSON: jsonerror.UnknownToken("Unknown token"),
+ }
+ } else {
+ jsonErr := httputil.LogThenError(req, err)
+ resErr = &jsonErr
+ }
+ }
+ return
+}
+
+// GenerateAccessToken creates a new access token. Returns an error if failed to generate
+// random bytes.
+func GenerateAccessToken() (string, error) {
+ b := make([]byte, tokenByteLength)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ // url-safe no padding
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// ExtractAccessToken from a request, or return an error detailing what went wrong. The
+// error message MUST be human-readable and comprehensible to the client.
+func ExtractAccessToken(req *http.Request) (string, error) {
+ // cf https://github.com/matrix-org/synapse/blob/v0.19.2/synapse/api/auth.py#L631
+ authBearer := req.Header.Get("Authorization")
+ queryToken := req.URL.Query().Get("access_token")
+ if authBearer != "" && queryToken != "" {
+ return "", fmt.Errorf("mixing Authorization headers and access_token query parameters")
+ }
+
+ if queryToken != "" {
+ return queryToken, nil
+ }
+
+ if authBearer != "" {
+ parts := strings.SplitN(authBearer, " ", 2)
+ if len(parts) != 2 || parts[0] != "Bearer" {
+ return "", fmt.Errorf("invalid Authorization header")
+ }
+ return parts[1], nil
+ }
+
+ return "", fmt.Errorf("missing access token")
+}
diff --git a/clientapi/auth/authtypes/account.go b/clientapi/auth/authtypes/account.go
new file mode 100644
index 00000000..fd3c15a8
--- /dev/null
+++ b/clientapi/auth/authtypes/account.go
@@ -0,0 +1,31 @@
+// 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 authtypes
+
+import (
+ "github.com/matrix-org/gomatrixserverlib"
+)
+
+// Account represents a Matrix account on this home server.
+type Account struct {
+ UserID string
+ Localpart string
+ ServerName gomatrixserverlib.ServerName
+ Profile *Profile
+ AppServiceID string
+ // TODO: Other flags like IsAdmin, IsGuest
+ // TODO: Devices
+ // TODO: Associations (e.g. with application services)
+}
diff --git a/clientapi/auth/authtypes/device.go b/clientapi/auth/authtypes/device.go
new file mode 100644
index 00000000..a6d3a7b0
--- /dev/null
+++ b/clientapi/auth/authtypes/device.go
@@ -0,0 +1,25 @@
+// 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 authtypes
+
+// Device represents a client's device (mobile, web, etc)
+type Device struct {
+ ID string
+ UserID string
+ // The access_token granted to this device.
+ // This uniquely identifies the device from all other devices and clients.
+ AccessToken string
+ // TODO: display name, last used timestamp, keys, etc
+}
diff --git a/clientapi/auth/authtypes/flow.go b/clientapi/auth/authtypes/flow.go
new file mode 100644
index 00000000..d5766fcc
--- /dev/null
+++ b/clientapi/auth/authtypes/flow.go
@@ -0,0 +1,21 @@
+// Copyright Andrew Morgan <andrew@amorgan.xyz>
+//
+// 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 authtypes
+
+// Flow represents one possible way that the client can authenticate a request.
+// https://matrix.org/docs/spec/client_server/r0.3.0.html#user-interactive-authentication-api
+type Flow struct {
+ Stages []LoginType `json:"stages"`
+}
diff --git a/clientapi/auth/authtypes/logintypes.go b/clientapi/auth/authtypes/logintypes.go
new file mode 100644
index 00000000..087e4504
--- /dev/null
+++ b/clientapi/auth/authtypes/logintypes.go
@@ -0,0 +1,12 @@
+package authtypes
+
+// LoginType are specified by http://matrix.org/docs/spec/client_server/r0.2.0.html#login-types
+type LoginType string
+
+// The relevant login types implemented in Dendrite
+const (
+ LoginTypeDummy = "m.login.dummy"
+ LoginTypeSharedSecret = "org.matrix.login.shared_secret"
+ LoginTypeRecaptcha = "m.login.recaptcha"
+ LoginTypeApplicationService = "m.login.application_service"
+)
diff --git a/clientapi/auth/authtypes/membership.go b/clientapi/auth/authtypes/membership.go
new file mode 100644
index 00000000..ad5312db
--- /dev/null
+++ b/clientapi/auth/authtypes/membership.go
@@ -0,0 +1,23 @@
+// 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 authtypes
+
+// Membership represents the relationship between a user and a room they're a
+// member of
+type Membership struct {
+ Localpart string
+ RoomID string
+ EventID string
+}
diff --git a/clientapi/auth/authtypes/profile.go b/clientapi/auth/authtypes/profile.go
new file mode 100644
index 00000000..6cf508f4
--- /dev/null
+++ b/clientapi/auth/authtypes/profile.go
@@ -0,0 +1,22 @@
+// 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 authtypes
+
+// Profile represents the profile for a Matrix account on this home server.
+type Profile struct {
+ Localpart string
+ DisplayName string
+ AvatarURL string
+}
diff --git a/clientapi/auth/authtypes/threepid.go b/clientapi/auth/authtypes/threepid.go
new file mode 100644
index 00000000..60d77dc6
--- /dev/null
+++ b/clientapi/auth/authtypes/threepid.go
@@ -0,0 +1,21 @@
+// 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 authtypes
+
+// ThreePID represents a third-party identifier
+type ThreePID struct {
+ Address string `json:"address"`
+ Medium string `json:"medium"`
+}
diff --git a/clientapi/auth/storage/accounts/account_data_table.go b/clientapi/auth/storage/accounts/account_data_table.go
new file mode 100644
index 00000000..0d73cb31
--- /dev/null
+++ b/clientapi/auth/storage/accounts/account_data_table.go
@@ -0,0 +1,148 @@
+// 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 accounts
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/matrix-org/gomatrixserverlib"
+)
+
+const accountDataSchema = `
+-- Stores data about accounts data.
+CREATE TABLE IF NOT EXISTS account_data (
+ -- The Matrix user ID localpart for this account
+ localpart TEXT NOT NULL,
+ -- The room ID for this data (empty string if not specific to a room)
+ room_id TEXT,
+ -- The account data type
+ type TEXT NOT NULL,
+ -- The account data content
+ content TEXT NOT NULL,
+
+ PRIMARY KEY(localpart, room_id, type)
+);
+`
+
+const insertAccountDataSQL = `
+ INSERT INTO account_data(localpart, room_id, type, content) VALUES($1, $2, $3, $4)
+ ON CONFLICT (localpart, room_id, type) DO UPDATE SET content = EXCLUDED.content
+`
+
+const selectAccountDataSQL = "" +
+ "SELECT room_id, type, content FROM account_data WHERE localpart = $1"
+
+const selectAccountDataByTypeSQL = "" +
+ "SELECT content FROM account_data WHERE localpart = $1 AND room_id = $2 AND type = $3"
+
+type accountDataStatements struct {
+ insertAccountDataStmt *sql.Stmt
+ selectAccountDataStmt *sql.Stmt
+ selectAccountDataByTypeStmt *sql.Stmt
+}
+
+func (s *accountDataStatements) prepare(db *sql.DB) (err error) {
+ _, err = db.Exec(accountDataSchema)
+ if err != nil {
+ return
+ }
+ if s.insertAccountDataStmt, err = db.Prepare(insertAccountDataSQL); err != nil {
+ return
+ }
+ if s.selectAccountDataStmt, err = db.Prepare(selectAccountDataSQL); err != nil {
+ return
+ }
+ if s.selectAccountDataByTypeStmt, err = db.Prepare(selectAccountDataByTypeSQL); err != nil {
+ return
+ }
+ return
+}
+
+func (s *accountDataStatements) insertAccountData(
+ ctx context.Context, localpart, roomID, dataType, content string,
+) (err error) {
+ stmt := s.insertAccountDataStmt
+ _, err = stmt.ExecContext(ctx, localpart, roomID, dataType, content)
+ return
+}
+
+func (s *accountDataStatements) selectAccountData(
+ ctx context.Context, localpart string,
+) (
+ global []gomatrixserverlib.ClientEvent,
+ rooms map[string][]gomatrixserverlib.ClientEvent,
+ err error,
+) {
+ rows, err := s.selectAccountDataStmt.QueryContext(ctx, localpart)
+ if err != nil {
+ return
+ }
+
+ global = []gomatrixserverlib.ClientEvent{}
+ rooms = make(map[string][]gomatrixserverlib.ClientEvent)
+
+ for rows.Next() {
+ var roomID string
+ var dataType string
+ var content []byte
+
+ if err = rows.Scan(&roomID, &dataType, &content); err != nil {
+ return
+ }
+
+ ac := gomatrixserverlib.ClientEvent{
+ Type: dataType,
+ Content: content,
+ }
+
+ if len(roomID) > 0 {
+ rooms[roomID] = append(rooms[roomID], ac)
+ } else {
+ global = append(global, ac)
+ }
+ }
+
+ return
+}
+
+func (s *accountDataStatements) selectAccountDataByType(
+ ctx context.Context, localpart, roomID, dataType string,
+) (data []gomatrixserverlib.ClientEvent, err error) {
+ data = []gomatrixserverlib.ClientEvent{}
+
+ stmt := s.selectAccountDataByTypeStmt
+ rows, err := stmt.QueryContext(ctx, localpart, roomID, dataType)
+ if err != nil {
+ return
+ }
+
+ for rows.Next() {
+ var content []byte
+
+ if err = rows.Scan(&content); err != nil {
+ return
+ }
+
+ ac := gomatrixserverlib.ClientEvent{
+ Type: dataType,
+ Content: content,
+ }
+
+ data = append(data, ac)
+ }
+
+ return
+}
diff --git a/clientapi/auth/storage/accounts/accounts_table.go b/clientapi/auth/storage/accounts/accounts_table.go
new file mode 100644
index 00000000..e86654ec
--- /dev/null
+++ b/clientapi/auth/storage/accounts/accounts_table.go
@@ -0,0 +1,153 @@
+// 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 accounts
+
+import (
+ "context"
+ "database/sql"
+ "time"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/userutil"
+ "github.com/matrix-org/gomatrixserverlib"
+
+ log "github.com/sirupsen/logrus"
+)
+
+const accountsSchema = `
+-- Stores data about accounts.
+CREATE TABLE IF NOT EXISTS account_accounts (
+ -- The Matrix user ID localpart for this account
+ localpart TEXT NOT NULL PRIMARY KEY,
+ -- When this account was first created, as a unix timestamp (ms resolution).
+ created_ts BIGINT NOT NULL,
+ -- The password hash for this account. Can be NULL if this is a passwordless account.
+ password_hash TEXT,
+ -- Identifies which application service this account belongs to, if any.
+ appservice_id TEXT
+ -- TODO:
+ -- is_guest, is_admin, upgraded_ts, devices, any email reset stuff?
+);
+-- Create sequence for autogenerated numeric usernames
+CREATE SEQUENCE IF NOT EXISTS numeric_username_seq START 1;
+`
+
+const insertAccountSQL = "" +
+ "INSERT INTO account_accounts(localpart, created_ts, password_hash, appservice_id) VALUES ($1, $2, $3, $4)"
+
+const selectAccountByLocalpartSQL = "" +
+ "SELECT localpart, appservice_id FROM account_accounts WHERE localpart = $1"
+
+const selectPasswordHashSQL = "" +
+ "SELECT password_hash FROM account_accounts WHERE localpart = $1"
+
+const selectNewNumericLocalpartSQL = "" +
+ "SELECT nextval('numeric_username_seq')"
+
+// TODO: Update password
+
+type accountsStatements struct {
+ insertAccountStmt *sql.Stmt
+ selectAccountByLocalpartStmt *sql.Stmt
+ selectPasswordHashStmt *sql.Stmt
+ selectNewNumericLocalpartStmt *sql.Stmt
+ serverName gomatrixserverlib.ServerName
+}
+
+func (s *accountsStatements) prepare(db *sql.DB, server gomatrixserverlib.ServerName) (err error) {
+ _, err = db.Exec(accountsSchema)
+ if err != nil {
+ return
+ }
+ if s.insertAccountStmt, err = db.Prepare(insertAccountSQL); err != nil {
+ return
+ }
+ if s.selectAccountByLocalpartStmt, err = db.Prepare(selectAccountByLocalpartSQL); err != nil {
+ return
+ }
+ if s.selectPasswordHashStmt, err = db.Prepare(selectPasswordHashSQL); err != nil {
+ return
+ }
+ if s.selectNewNumericLocalpartStmt, err = db.Prepare(selectNewNumericLocalpartSQL); err != nil {
+ return
+ }
+ s.serverName = server
+ return
+}
+
+// insertAccount creates a new account. 'hash' should be the password hash for this account. If it is missing,
+// this account will be passwordless. Returns an error if this account already exists. Returns the account
+// on success.
+func (s *accountsStatements) insertAccount(
+ ctx context.Context, localpart, hash, appserviceID string,
+) (*authtypes.Account, error) {
+ createdTimeMS := time.Now().UnixNano() / 1000000
+ stmt := s.insertAccountStmt
+
+ var err error
+ if appserviceID == "" {
+ _, err = stmt.ExecContext(ctx, localpart, createdTimeMS, hash, nil)
+ } else {
+ _, err = stmt.ExecContext(ctx, localpart, createdTimeMS, hash, appserviceID)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &authtypes.Account{
+ Localpart: localpart,
+ UserID: userutil.MakeUserID(localpart, s.serverName),
+ ServerName: s.serverName,
+ AppServiceID: appserviceID,
+ }, nil
+}
+
+func (s *accountsStatements) selectPasswordHash(
+ ctx context.Context, localpart string,
+) (hash string, err error) {
+ err = s.selectPasswordHashStmt.QueryRowContext(ctx, localpart).Scan(&hash)
+ return
+}
+
+func (s *accountsStatements) selectAccountByLocalpart(
+ ctx context.Context, localpart string,
+) (*authtypes.Account, error) {
+ var appserviceIDPtr sql.NullString
+ var acc authtypes.Account
+
+ stmt := s.selectAccountByLocalpartStmt
+ err := stmt.QueryRowContext(ctx, localpart).Scan(&acc.Localpart, &appserviceIDPtr)
+ if err != nil {
+ if err != sql.ErrNoRows {
+ log.WithError(err).Error("Unable to retrieve user from the db")
+ }
+ return nil, err
+ }
+ if appserviceIDPtr.Valid {
+ acc.AppServiceID = appserviceIDPtr.String
+ }
+
+ acc.UserID = userutil.MakeUserID(localpart, s.serverName)
+ acc.ServerName = s.serverName
+
+ return &acc, nil
+}
+
+func (s *accountsStatements) selectNewNumericLocalpart(
+ ctx context.Context,
+) (id int64, err error) {
+ err = s.selectNewNumericLocalpartStmt.QueryRowContext(ctx).Scan(&id)
+ return
+}
diff --git a/clientapi/auth/storage/accounts/filter_table.go b/clientapi/auth/storage/accounts/filter_table.go
new file mode 100644
index 00000000..81bae454
--- /dev/null
+++ b/clientapi/auth/storage/accounts/filter_table.go
@@ -0,0 +1,107 @@
+// Copyright 2017 Jan Christian Grünhage
+//
+// 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 accounts
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/matrix-org/gomatrixserverlib"
+)
+
+const filterSchema = `
+-- Stores data about filters
+CREATE TABLE IF NOT EXISTS account_filter (
+ -- The filter
+ filter TEXT NOT NULL,
+ -- The ID
+ id SERIAL UNIQUE,
+ -- The localpart of the Matrix user ID associated to this filter
+ localpart TEXT NOT NULL,
+
+ PRIMARY KEY(id, localpart)
+);
+
+CREATE INDEX IF NOT EXISTS account_filter_localpart ON account_filter(localpart);
+`
+
+const selectFilterSQL = "" +
+ "SELECT filter FROM account_filter WHERE localpart = $1 AND id = $2"
+
+const selectFilterIDByContentSQL = "" +
+ "SELECT id FROM account_filter WHERE localpart = $1 AND filter = $2"
+
+const insertFilterSQL = "" +
+ "INSERT INTO account_filter (filter, id, localpart) VALUES ($1, DEFAULT, $2) RETURNING id"
+
+type filterStatements struct {
+ selectFilterStmt *sql.Stmt
+ selectFilterIDByContentStmt *sql.Stmt
+ insertFilterStmt *sql.Stmt
+}
+
+func (s *filterStatements) prepare(db *sql.DB) (err error) {
+ _, err = db.Exec(filterSchema)
+ if err != nil {
+ return
+ }
+ if s.selectFilterStmt, err = db.Prepare(selectFilterSQL); err != nil {
+ return
+ }
+ if s.selectFilterIDByContentStmt, err = db.Prepare(selectFilterIDByContentSQL); err != nil {
+ return
+ }
+ if s.insertFilterStmt, err = db.Prepare(insertFilterSQL); err != nil {
+ return
+ }
+ return
+}
+
+func (s *filterStatements) selectFilter(
+ ctx context.Context, localpart string, filterID string,
+) (filter []byte, err error) {
+ err = s.selectFilterStmt.QueryRowContext(ctx, localpart, filterID).Scan(&filter)
+ return
+}
+
+func (s *filterStatements) insertFilter(
+ ctx context.Context, filter []byte, localpart string,
+) (filterID string, err error) {
+ var existingFilterID string
+
+ // This can result in a race condition when two clients try to insert the
+ // same filter and localpart at the same time, however this is not a
+ // problem as both calls will result in the same filterID
+ filterJSON, err := gomatrixserverlib.CanonicalJSON(filter)
+ if err != nil {
+ return "", err
+ }
+
+ // Check if filter already exists in the database
+ err = s.selectFilterIDByContentStmt.QueryRowContext(ctx,
+ localpart, filterJSON).Scan(&existingFilterID)
+ if err != nil && err != sql.ErrNoRows {
+ return "", err
+ }
+ // If it does, return the existing ID
+ if existingFilterID != "" {
+ return existingFilterID, err
+ }
+
+ // Otherwise insert the filter and return the new ID
+ err = s.insertFilterStmt.QueryRowContext(ctx, filterJSON, localpart).
+ Scan(&filterID)
+ return
+}
diff --git a/clientapi/auth/storage/accounts/membership_table.go b/clientapi/auth/storage/accounts/membership_table.go
new file mode 100644
index 00000000..6185065c
--- /dev/null
+++ b/clientapi/auth/storage/accounts/membership_table.go
@@ -0,0 +1,132 @@
+// 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 accounts
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/lib/pq"
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+)
+
+const membershipSchema = `
+-- Stores data about users memberships to rooms.
+CREATE TABLE IF NOT EXISTS account_memberships (
+ -- The Matrix user ID localpart for the member
+ localpart TEXT NOT NULL,
+ -- The room this user is a member of
+ room_id TEXT NOT NULL,
+ -- The ID of the join membership event
+ event_id TEXT NOT NULL,
+
+ -- A user can only be member of a room once
+ PRIMARY KEY (localpart, room_id)
+);
+
+-- Use index to process deletion by ID more efficiently
+CREATE UNIQUE INDEX IF NOT EXISTS account_membership_event_id ON account_memberships(event_id);
+`
+
+const insertMembershipSQL = `
+ INSERT INTO account_memberships(localpart, room_id, event_id) VALUES ($1, $2, $3)
+ ON CONFLICT (localpart, room_id) DO UPDATE SET event_id = EXCLUDED.event_id
+`
+
+const selectMembershipsByLocalpartSQL = "" +
+ "SELECT room_id, event_id FROM account_memberships WHERE localpart = $1"
+
+const selectMembershipInRoomByLocalpartSQL = "" +
+ "SELECT event_id FROM account_memberships WHERE localpart = $1 AND room_id = $2"
+
+const deleteMembershipsByEventIDsSQL = "" +
+ "DELETE FROM account_memberships WHERE event_id = ANY($1)"
+
+type membershipStatements struct {
+ deleteMembershipsByEventIDsStmt *sql.Stmt
+ insertMembershipStmt *sql.Stmt
+ selectMembershipInRoomByLocalpartStmt *sql.Stmt
+ selectMembershipsByLocalpartStmt *sql.Stmt
+}
+
+func (s *membershipStatements) prepare(db *sql.DB) (err error) {
+ _, err = db.Exec(membershipSchema)
+ if err != nil {
+ return
+ }
+ if s.deleteMembershipsByEventIDsStmt, err = db.Prepare(deleteMembershipsByEventIDsSQL); err != nil {
+ return
+ }
+ if s.insertMembershipStmt, err = db.Prepare(insertMembershipSQL); err != nil {
+ return
+ }
+ if s.selectMembershipInRoomByLocalpartStmt, err = db.Prepare(selectMembershipInRoomByLocalpartSQL); err != nil {
+ return
+ }
+ if s.selectMembershipsByLocalpartStmt, err = db.Prepare(selectMembershipsByLocalpartSQL); err != nil {
+ return
+ }
+ return
+}
+
+func (s *membershipStatements) insertMembership(
+ ctx context.Context, txn *sql.Tx, localpart, roomID, eventID string,
+) (err error) {
+ stmt := txn.Stmt(s.insertMembershipStmt)
+ _, err = stmt.ExecContext(ctx, localpart, roomID, eventID)
+ return
+}
+
+func (s *membershipStatements) deleteMembershipsByEventIDs(
+ ctx context.Context, txn *sql.Tx, eventIDs []string,
+) (err error) {
+ stmt := txn.Stmt(s.deleteMembershipsByEventIDsStmt)
+ _, err = stmt.ExecContext(ctx, pq.StringArray(eventIDs))
+ return
+}
+
+func (s *membershipStatements) selectMembershipInRoomByLocalpart(
+ ctx context.Context, localpart, roomID string,
+) (authtypes.Membership, error) {
+ membership := authtypes.Membership{Localpart: localpart, RoomID: roomID}
+ stmt := s.selectMembershipInRoomByLocalpartStmt
+ err := stmt.QueryRowContext(ctx, localpart, roomID).Scan(&membership.EventID)
+
+ return membership, err
+}
+
+func (s *membershipStatements) selectMembershipsByLocalpart(
+ ctx context.Context, localpart string,
+) (memberships []authtypes.Membership, err error) {
+ stmt := s.selectMembershipsByLocalpartStmt
+ rows, err := stmt.QueryContext(ctx, localpart)
+ if err != nil {
+ return
+ }
+
+ memberships = []authtypes.Membership{}
+
+ defer rows.Close() // nolint: errcheck
+ for rows.Next() {
+ var m authtypes.Membership
+ m.Localpart = localpart
+ if err := rows.Scan(&m.RoomID, &m.EventID); err != nil {
+ return nil, err
+ }
+ memberships = append(memberships, m)
+ }
+
+ return
+}
diff --git a/clientapi/auth/storage/accounts/profile_table.go b/clientapi/auth/storage/accounts/profile_table.go
new file mode 100644
index 00000000..157bb99b
--- /dev/null
+++ b/clientapi/auth/storage/accounts/profile_table.go
@@ -0,0 +1,107 @@
+// 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 accounts
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+)
+
+const profilesSchema = `
+-- Stores data about accounts profiles.
+CREATE TABLE IF NOT EXISTS account_profiles (
+ -- The Matrix user ID localpart for this account
+ localpart TEXT NOT NULL PRIMARY KEY,
+ -- The display name for this account
+ display_name TEXT,
+ -- The URL of the avatar for this account
+ avatar_url TEXT
+);
+`
+
+const insertProfileSQL = "" +
+ "INSERT INTO account_profiles(localpart, display_name, avatar_url) VALUES ($1, $2, $3)"
+
+const selectProfileByLocalpartSQL = "" +
+ "SELECT localpart, display_name, avatar_url FROM account_profiles WHERE localpart = $1"
+
+const setAvatarURLSQL = "" +
+ "UPDATE account_profiles SET avatar_url = $1 WHERE localpart = $2"
+
+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
+}
+
+func (s *profilesStatements) prepare(db *sql.DB) (err error) {
+ _, err = db.Exec(profilesSchema)
+ if err != nil {
+ return
+ }
+ if s.insertProfileStmt, err = db.Prepare(insertProfileSQL); err != nil {
+ return
+ }
+ if s.selectProfileByLocalpartStmt, err = db.Prepare(selectProfileByLocalpartSQL); err != nil {
+ return
+ }
+ if s.setAvatarURLStmt, err = db.Prepare(setAvatarURLSQL); err != nil {
+ return
+ }
+ if s.setDisplayNameStmt, err = db.Prepare(setDisplayNameSQL); err != nil {
+ return
+ }
+ return
+}
+
+func (s *profilesStatements) insertProfile(
+ ctx context.Context, localpart string,
+) (err error) {
+ _, err = s.insertProfileStmt.ExecContext(ctx, localpart, "", "")
+ return
+}
+
+func (s *profilesStatements) selectProfileByLocalpart(
+ ctx context.Context, localpart string,
+) (*authtypes.Profile, error) {
+ var profile authtypes.Profile
+ err := s.selectProfileByLocalpartStmt.QueryRowContext(ctx, localpart).Scan(
+ &profile.Localpart, &profile.DisplayName, &profile.AvatarURL,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return &profile, nil
+}
+
+func (s *profilesStatements) setAvatarURL(
+ ctx context.Context, localpart string, avatarURL string,
+) (err error) {
+ _, err = s.setAvatarURLStmt.ExecContext(ctx, avatarURL, localpart)
+ return
+}
+
+func (s *profilesStatements) setDisplayName(
+ ctx context.Context, localpart string, displayName string,
+) (err error) {
+ _, err = s.setDisplayNameStmt.ExecContext(ctx, displayName, localpart)
+ return
+}
diff --git a/clientapi/auth/storage/accounts/storage.go b/clientapi/auth/storage/accounts/storage.go
new file mode 100644
index 00000000..2650470b
--- /dev/null
+++ b/clientapi/auth/storage/accounts/storage.go
@@ -0,0 +1,380 @@
+// 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 accounts
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/common"
+ "github.com/matrix-org/gomatrixserverlib"
+ "golang.org/x/crypto/bcrypt"
+ // Import the postgres database driver.
+ _ "github.com/lib/pq"
+)
+
+// Database represents an account database
+type Database struct {
+ db *sql.DB
+ common.PartitionOffsetStatements
+ accounts accountsStatements
+ profiles profilesStatements
+ memberships membershipStatements
+ accountDatas accountDataStatements
+ threepids threepidStatements
+ filter filterStatements
+ serverName gomatrixserverlib.ServerName
+}
+
+// NewDatabase creates a new accounts and profiles database
+func NewDatabase(dataSourceName string, serverName gomatrixserverlib.ServerName) (*Database, error) {
+ var db *sql.DB
+ var err error
+ if db, err = sql.Open("postgres", dataSourceName); err != nil {
+ return nil, err
+ }
+ partitions := common.PartitionOffsetStatements{}
+ if err = partitions.Prepare(db, "account"); err != nil {
+ return nil, err
+ }
+ a := accountsStatements{}
+ if err = a.prepare(db, serverName); err != nil {
+ return nil, err
+ }
+ p := profilesStatements{}
+ if err = p.prepare(db); err != nil {
+ return nil, err
+ }
+ m := membershipStatements{}
+ if err = m.prepare(db); err != nil {
+ return nil, err
+ }
+ ac := accountDataStatements{}
+ if err = ac.prepare(db); err != nil {
+ return nil, err
+ }
+ t := threepidStatements{}
+ if err = t.prepare(db); err != nil {
+ return nil, err
+ }
+ f := filterStatements{}
+ if err = f.prepare(db); err != nil {
+ return nil, err
+ }
+ return &Database{db, partitions, a, p, m, ac, t, f, serverName}, nil
+}
+
+// GetAccountByPassword returns the account associated with the given localpart and password.
+// Returns sql.ErrNoRows if no account exists which matches the given localpart.
+func (d *Database) GetAccountByPassword(
+ ctx context.Context, localpart, plaintextPassword string,
+) (*authtypes.Account, error) {
+ hash, err := d.accounts.selectPasswordHash(ctx, localpart)
+ if err != nil {
+ return nil, err
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintextPassword)); err != nil {
+ return nil, err
+ }
+ return d.accounts.selectAccountByLocalpart(ctx, localpart)
+}
+
+// GetProfileByLocalpart returns the profile associated with the given localpart.
+// Returns sql.ErrNoRows if no profile exists which matches the given localpart.
+func (d *Database) GetProfileByLocalpart(
+ ctx context.Context, localpart string,
+) (*authtypes.Profile, error) {
+ return d.profiles.selectProfileByLocalpart(ctx, localpart)
+}
+
+// SetAvatarURL updates the avatar URL of the profile associated with the given
+// localpart. Returns an error if something went wrong with the SQL query
+func (d *Database) SetAvatarURL(
+ ctx context.Context, localpart string, avatarURL string,
+) error {
+ return d.profiles.setAvatarURL(ctx, localpart, avatarURL)
+}
+
+// SetDisplayName updates the display name of the profile associated with the given
+// localpart. Returns an error if something went wrong with the SQL query
+func (d *Database) SetDisplayName(
+ ctx context.Context, localpart string, displayName string,
+) error {
+ return d.profiles.setDisplayName(ctx, localpart, displayName)
+}
+
+// CreateAccount makes a new account with the given login name and password, and creates an empty profile
+// for this account. If no password is supplied, the account will be a passwordless account. If the
+// account already exists, it will return nil, nil.
+func (d *Database) CreateAccount(
+ ctx context.Context, localpart, plaintextPassword, appserviceID string,
+) (*authtypes.Account, error) {
+ var err error
+
+ // Generate a password hash if this is not a password-less user
+ hash := ""
+ if plaintextPassword != "" {
+ hash, err = hashPassword(plaintextPassword)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if err := d.profiles.insertProfile(ctx, localpart); err != nil {
+ if common.IsUniqueConstraintViolationErr(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ return d.accounts.insertAccount(ctx, localpart, hash, appserviceID)
+}
+
+// SaveMembership saves the user matching a given localpart as a member of a given
+// room. It also stores the ID of the membership event.
+// If a membership already exists between the user and the room, or if the
+// insert fails, returns the SQL error
+func (d *Database) saveMembership(
+ ctx context.Context, txn *sql.Tx, localpart, roomID, eventID string,
+) error {
+ return d.memberships.insertMembership(ctx, txn, localpart, roomID, eventID)
+}
+
+// removeMembershipsByEventIDs removes the memberships corresponding to the
+// `join` membership events IDs in the eventIDs slice.
+// If the removal fails, or if there is no membership to remove, returns an error
+func (d *Database) removeMembershipsByEventIDs(
+ ctx context.Context, txn *sql.Tx, eventIDs []string,
+) error {
+ return d.memberships.deleteMembershipsByEventIDs(ctx, txn, eventIDs)
+}
+
+// UpdateMemberships adds the "join" membership events included in a given state
+// events array, and removes those which ID is included in a given array of events
+// IDs. All of the process is run in a transaction, which commits only once/if every
+// insertion and deletion has been successfully processed.
+// Returns a SQL error if there was an issue with any part of the process
+func (d *Database) UpdateMemberships(
+ ctx context.Context, eventsToAdd []gomatrixserverlib.Event, idsToRemove []string,
+) error {
+ return common.WithTransaction(d.db, func(txn *sql.Tx) error {
+ if err := d.removeMembershipsByEventIDs(ctx, txn, idsToRemove); err != nil {
+ return err
+ }
+
+ for _, event := range eventsToAdd {
+ if err := d.newMembership(ctx, txn, event); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+}
+
+// GetMembershipInRoomByLocalpart returns the membership for an user
+// matching the given localpart if he is a member of the room matching roomID,
+// if not sql.ErrNoRows is returned.
+// If there was an issue during the retrieval, returns the SQL error
+func (d *Database) GetMembershipInRoomByLocalpart(
+ ctx context.Context, localpart, roomID string,
+) (authtypes.Membership, error) {
+ return d.memberships.selectMembershipInRoomByLocalpart(ctx, localpart, roomID)
+}
+
+// GetMembershipsByLocalpart returns an array containing the memberships for all
+// the rooms a user matching a given localpart is a member of
+// If no membership match the given localpart, returns an empty array
+// If there was an issue during the retrieval, returns the SQL error
+func (d *Database) GetMembershipsByLocalpart(
+ ctx context.Context, localpart string,
+) (memberships []authtypes.Membership, err error) {
+ return d.memberships.selectMembershipsByLocalpart(ctx, localpart)
+}
+
+// newMembership saves a new membership in the database.
+// If the event isn't a valid m.room.member event with type `join`, does nothing.
+// If an error occurred, returns the SQL error
+func (d *Database) newMembership(
+ ctx context.Context, txn *sql.Tx, ev gomatrixserverlib.Event,
+) error {
+ if ev.Type() == "m.room.member" && ev.StateKey() != nil {
+ localpart, serverName, err := gomatrixserverlib.SplitID('@', *ev.StateKey())
+ if err != nil {
+ return err
+ }
+
+ // We only want state events from local users
+ if string(serverName) != string(d.serverName) {
+ return nil
+ }
+
+ eventID := ev.EventID()
+ roomID := ev.RoomID()
+ membership, err := ev.Membership()
+ if err != nil {
+ return err
+ }
+
+ // Only "join" membership events can be considered as new memberships
+ if membership == "join" {
+ if err := d.saveMembership(ctx, txn, localpart, roomID, eventID); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+// SaveAccountData saves new account data for a given user and a given room.
+// If the account data is not specific to a room, the room ID should be an empty string
+// If an account data already exists for a given set (user, room, data type), it will
+// update the corresponding row with the new content
+// Returns a SQL error if there was an issue with the insertion/update
+func (d *Database) SaveAccountData(
+ ctx context.Context, localpart, roomID, dataType, content string,
+) error {
+ return d.accountDatas.insertAccountData(ctx, localpart, roomID, dataType, content)
+}
+
+// GetAccountData returns account data related to a given localpart
+// If no account data could be found, returns an empty arrays
+// Returns an error if there was an issue with the retrieval
+func (d *Database) GetAccountData(ctx context.Context, localpart string) (
+ global []gomatrixserverlib.ClientEvent,
+ rooms map[string][]gomatrixserverlib.ClientEvent,
+ err error,
+) {
+ return d.accountDatas.selectAccountData(ctx, localpart)
+}
+
+// GetAccountDataByType returns account data matching a given
+// localpart, room ID and type.
+// If no account data could be found, returns an empty array
+// Returns an error if there was an issue with the retrieval
+func (d *Database) GetAccountDataByType(
+ ctx context.Context, localpart, roomID, dataType string,
+) (data []gomatrixserverlib.ClientEvent, err error) {
+ return d.accountDatas.selectAccountDataByType(
+ ctx, localpart, roomID, dataType,
+ )
+}
+
+// GetNewNumericLocalpart generates and returns a new unused numeric localpart
+func (d *Database) GetNewNumericLocalpart(
+ ctx context.Context,
+) (int64, error) {
+ return d.accounts.selectNewNumericLocalpart(ctx)
+}
+
+func hashPassword(plaintext string) (hash string, err error) {
+ hashBytes, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost)
+ return string(hashBytes), err
+}
+
+// Err3PIDInUse is the error returned when trying to save an association involving
+// a third-party identifier which is already associated to a local user.
+var Err3PIDInUse = errors.New("This third-party identifier is already in use")
+
+// SaveThreePIDAssociation saves the association between a third party identifier
+// and a local Matrix user (identified by the user's ID's local part).
+// If the third-party identifier is already part of an association, returns Err3PIDInUse.
+// Returns an error if there was a problem talking to the database.
+func (d *Database) SaveThreePIDAssociation(
+ ctx context.Context, threepid, localpart, medium string,
+) (err error) {
+ return common.WithTransaction(d.db, func(txn *sql.Tx) error {
+ user, err := d.threepids.selectLocalpartForThreePID(
+ ctx, txn, threepid, medium,
+ )
+ if err != nil {
+ return err
+ }
+
+ if len(user) > 0 {
+ return Err3PIDInUse
+ }
+
+ return d.threepids.insertThreePID(ctx, txn, threepid, medium, localpart)
+ })
+}
+
+// RemoveThreePIDAssociation removes the association involving a given third-party
+// identifier.
+// If no association exists involving this third-party identifier, returns nothing.
+// If there was a problem talking to the database, returns an error.
+func (d *Database) RemoveThreePIDAssociation(
+ ctx context.Context, threepid string, medium string,
+) (err error) {
+ return d.threepids.deleteThreePID(ctx, threepid, medium)
+}
+
+// GetLocalpartForThreePID looks up the localpart associated with a given third-party
+// identifier.
+// If no association involves the given third-party idenfitier, returns an empty
+// string.
+// Returns an error if there was a problem talking to the database.
+func (d *Database) GetLocalpartForThreePID(
+ ctx context.Context, threepid string, medium string,
+) (localpart string, err error) {
+ return d.threepids.selectLocalpartForThreePID(ctx, nil, threepid, medium)
+}
+
+// GetThreePIDsForLocalpart looks up the third-party identifiers associated with
+// a given local user.
+// If no association is known for this user, returns an empty slice.
+// Returns an error if there was an issue talking to the database.
+func (d *Database) GetThreePIDsForLocalpart(
+ ctx context.Context, localpart string,
+) (threepids []authtypes.ThreePID, err error) {
+ return d.threepids.selectThreePIDsForLocalpart(ctx, localpart)
+}
+
+// GetFilter looks up the filter associated with a given local user and filter ID.
+// Returns a filter represented as a byte slice. Otherwise returns an error if
+// no such filter exists or if there was an error talking to the database.
+func (d *Database) GetFilter(
+ ctx context.Context, localpart string, filterID string,
+) ([]byte, error) {
+ return d.filter.selectFilter(ctx, localpart, filterID)
+}
+
+// PutFilter puts the passed filter into the database.
+// Returns the filterID as a string. Otherwise returns an error if something
+// goes wrong.
+func (d *Database) PutFilter(
+ ctx context.Context, localpart string, filter []byte,
+) (string, error) {
+ return d.filter.insertFilter(ctx, filter, localpart)
+}
+
+// CheckAccountAvailability checks if the username/localpart is already present
+// in the database.
+// If the DB returns sql.ErrNoRows the Localpart isn't taken.
+func (d *Database) CheckAccountAvailability(ctx context.Context, localpart string) (bool, error) {
+ _, err := d.accounts.selectAccountByLocalpart(ctx, localpart)
+ if err == sql.ErrNoRows {
+ return true, nil
+ }
+ return false, err
+}
+
+// GetAccountByLocalpart returns the account associated with the given localpart.
+// This function assumes the request is authenticated or the account data is used only internally.
+// Returns sql.ErrNoRows if no account exists which matches the given localpart.
+func (d *Database) GetAccountByLocalpart(ctx context.Context, localpart string,
+) (*authtypes.Account, error) {
+ return d.accounts.selectAccountByLocalpart(ctx, localpart)
+}
diff --git a/clientapi/auth/storage/accounts/threepid_table.go b/clientapi/auth/storage/accounts/threepid_table.go
new file mode 100644
index 00000000..5900260a
--- /dev/null
+++ b/clientapi/auth/storage/accounts/threepid_table.go
@@ -0,0 +1,129 @@
+// 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 accounts
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/matrix-org/dendrite/common"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+)
+
+const threepidSchema = `
+-- Stores data about third party identifiers
+CREATE TABLE IF NOT EXISTS account_threepid (
+ -- The third party identifier
+ threepid TEXT NOT NULL,
+ -- The 3PID medium
+ medium TEXT NOT NULL DEFAULT 'email',
+ -- The localpart of the Matrix user ID associated to this 3PID
+ localpart TEXT NOT NULL,
+
+ PRIMARY KEY(threepid, medium)
+);
+
+CREATE INDEX IF NOT EXISTS account_threepid_localpart ON account_threepid(localpart);
+`
+
+const selectLocalpartForThreePIDSQL = "" +
+ "SELECT localpart FROM account_threepid WHERE threepid = $1 AND medium = $2"
+
+const selectThreePIDsForLocalpartSQL = "" +
+ "SELECT threepid, medium FROM account_threepid WHERE localpart = $1"
+
+const insertThreePIDSQL = "" +
+ "INSERT INTO account_threepid (threepid, medium, localpart) VALUES ($1, $2, $3)"
+
+const deleteThreePIDSQL = "" +
+ "DELETE FROM account_threepid WHERE threepid = $1 AND medium = $2"
+
+type threepidStatements struct {
+ selectLocalpartForThreePIDStmt *sql.Stmt
+ selectThreePIDsForLocalpartStmt *sql.Stmt
+ insertThreePIDStmt *sql.Stmt
+ deleteThreePIDStmt *sql.Stmt
+}
+
+func (s *threepidStatements) prepare(db *sql.DB) (err error) {
+ _, err = db.Exec(threepidSchema)
+ if err != nil {
+ return
+ }
+ if s.selectLocalpartForThreePIDStmt, err = db.Prepare(selectLocalpartForThreePIDSQL); err != nil {
+ return
+ }
+ if s.selectThreePIDsForLocalpartStmt, err = db.Prepare(selectThreePIDsForLocalpartSQL); err != nil {
+ return
+ }
+ if s.insertThreePIDStmt, err = db.Prepare(insertThreePIDSQL); err != nil {
+ return
+ }
+ if s.deleteThreePIDStmt, err = db.Prepare(deleteThreePIDSQL); err != nil {
+ return
+ }
+
+ return
+}
+
+func (s *threepidStatements) selectLocalpartForThreePID(
+ ctx context.Context, txn *sql.Tx, threepid string, medium string,
+) (localpart string, err error) {
+ stmt := common.TxStmt(txn, s.selectLocalpartForThreePIDStmt)
+ err = stmt.QueryRowContext(ctx, threepid, medium).Scan(&localpart)
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ return
+}
+
+func (s *threepidStatements) selectThreePIDsForLocalpart(
+ ctx context.Context, localpart string,
+) (threepids []authtypes.ThreePID, err error) {
+ rows, err := s.selectThreePIDsForLocalpartStmt.QueryContext(ctx, localpart)
+ if err != nil {
+ return
+ }
+
+ threepids = []authtypes.ThreePID{}
+ for rows.Next() {
+ var threepid string
+ var medium string
+ if err = rows.Scan(&threepid, &medium); err != nil {
+ return
+ }
+ threepids = append(threepids, authtypes.ThreePID{
+ Address: threepid,
+ Medium: medium,
+ })
+ }
+
+ return
+}
+
+func (s *threepidStatements) insertThreePID(
+ ctx context.Context, txn *sql.Tx, threepid, medium, localpart string,
+) (err error) {
+ stmt := common.TxStmt(txn, s.insertThreePIDStmt)
+ _, err = stmt.ExecContext(ctx, threepid, medium, localpart)
+ return
+}
+
+func (s *threepidStatements) deleteThreePID(
+ ctx context.Context, threepid string, medium string) (err error) {
+ _, err = s.deleteThreePIDStmt.ExecContext(ctx, threepid, medium)
+ return
+}
diff --git a/clientapi/auth/storage/devices/devices_table.go b/clientapi/auth/storage/devices/devices_table.go
new file mode 100644
index 00000000..96d6521d
--- /dev/null
+++ b/clientapi/auth/storage/devices/devices_table.go
@@ -0,0 +1,208 @@
+// 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 devices
+
+import (
+ "context"
+ "database/sql"
+ "time"
+
+ "github.com/matrix-org/dendrite/common"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/userutil"
+ "github.com/matrix-org/gomatrixserverlib"
+)
+
+const devicesSchema = `
+-- Stores data about devices.
+CREATE TABLE IF NOT EXISTS device_devices (
+ -- The access token granted to this device. This has to be the primary key
+ -- so we can distinguish which device is making a given request.
+ access_token TEXT NOT NULL PRIMARY KEY,
+ -- The device identifier. This only needs to uniquely identify a device for a given user, not globally.
+ -- access_tokens will be clobbered based on the device ID for a user.
+ device_id TEXT NOT NULL,
+ -- The Matrix user ID localpart for this device. This is preferable to storing the full user_id
+ -- as it is smaller, makes it clearer that we only manage devices for our own users, and may make
+ -- migration to different domain names easier.
+ localpart TEXT NOT NULL,
+ -- When this devices was first recognised on the network, as a unix timestamp (ms resolution).
+ created_ts BIGINT NOT NULL,
+ -- The display name, human friendlier than device_id and updatable
+ display_name TEXT
+ -- TODO: device keys, device display names, last used ts and IP address?, token restrictions (if 3rd-party OAuth app)
+);
+
+-- Device IDs must be unique for a given user.
+CREATE UNIQUE INDEX IF NOT EXISTS device_localpart_id_idx ON device_devices(localpart, device_id);
+`
+
+const insertDeviceSQL = "" +
+ "INSERT INTO device_devices(device_id, localpart, access_token, created_ts, display_name) VALUES ($1, $2, $3, $4, $5)"
+
+const selectDeviceByTokenSQL = "" +
+ "SELECT device_id, localpart FROM device_devices WHERE access_token = $1"
+
+const selectDeviceByIDSQL = "" +
+ "SELECT display_name FROM device_devices WHERE localpart = $1 and device_id = $2"
+
+const selectDevicesByLocalpartSQL = "" +
+ "SELECT device_id, display_name FROM device_devices WHERE localpart = $1"
+
+const updateDeviceNameSQL = "" +
+ "UPDATE device_devices SET display_name = $1 WHERE localpart = $2 AND device_id = $3"
+
+const deleteDeviceSQL = "" +
+ "DELETE FROM device_devices WHERE device_id = $1 AND localpart = $2"
+
+const deleteDevicesByLocalpartSQL = "" +
+ "DELETE FROM device_devices WHERE localpart = $1"
+
+type devicesStatements struct {
+ insertDeviceStmt *sql.Stmt
+ selectDeviceByTokenStmt *sql.Stmt
+ selectDeviceByIDStmt *sql.Stmt
+ selectDevicesByLocalpartStmt *sql.Stmt
+ updateDeviceNameStmt *sql.Stmt
+ deleteDeviceStmt *sql.Stmt
+ deleteDevicesByLocalpartStmt *sql.Stmt
+ serverName gomatrixserverlib.ServerName
+}
+
+func (s *devicesStatements) prepare(db *sql.DB, server gomatrixserverlib.ServerName) (err error) {
+ _, err = db.Exec(devicesSchema)
+ if err != nil {
+ return
+ }
+ if s.insertDeviceStmt, err = db.Prepare(insertDeviceSQL); err != nil {
+ return
+ }
+ if s.selectDeviceByTokenStmt, err = db.Prepare(selectDeviceByTokenSQL); err != nil {
+ return
+ }
+ if s.selectDeviceByIDStmt, err = db.Prepare(selectDeviceByIDSQL); err != nil {
+ return
+ }
+ if s.selectDevicesByLocalpartStmt, err = db.Prepare(selectDevicesByLocalpartSQL); err != nil {
+ return
+ }
+ if s.updateDeviceNameStmt, err = db.Prepare(updateDeviceNameSQL); err != nil {
+ return
+ }
+ if s.deleteDeviceStmt, err = db.Prepare(deleteDeviceSQL); err != nil {
+ return
+ }
+ if s.deleteDevicesByLocalpartStmt, err = db.Prepare(deleteDevicesByLocalpartSQL); err != nil {
+ return
+ }
+ s.serverName = server
+ return
+}
+
+// insertDevice creates a new device. Returns an error if any device with the same access token already exists.
+// Returns an error if the user already has a device with the given device ID.
+// Returns the device on success.
+func (s *devicesStatements) insertDevice(
+ ctx context.Context, txn *sql.Tx, id, localpart, accessToken string,
+ displayName *string,
+) (*authtypes.Device, error) {
+ createdTimeMS := time.Now().UnixNano() / 1000000
+ stmt := common.TxStmt(txn, s.insertDeviceStmt)
+ if _, err := stmt.ExecContext(ctx, id, localpart, accessToken, createdTimeMS, displayName); err != nil {
+ return nil, err
+ }
+ return &authtypes.Device{
+ ID: id,
+ UserID: userutil.MakeUserID(localpart, s.serverName),
+ AccessToken: accessToken,
+ }, nil
+}
+
+func (s *devicesStatements) deleteDevice(
+ ctx context.Context, txn *sql.Tx, id, localpart string,
+) error {
+ stmt := common.TxStmt(txn, s.deleteDeviceStmt)
+ _, err := stmt.ExecContext(ctx, id, localpart)
+ return err
+}
+
+func (s *devicesStatements) deleteDevicesByLocalpart(
+ ctx context.Context, txn *sql.Tx, localpart string,
+) error {
+ stmt := common.TxStmt(txn, s.deleteDevicesByLocalpartStmt)
+ _, err := stmt.ExecContext(ctx, localpart)
+ return err
+}
+
+func (s *devicesStatements) updateDeviceName(
+ ctx context.Context, txn *sql.Tx, localpart, deviceID string, displayName *string,
+) error {
+ stmt := common.TxStmt(txn, s.updateDeviceNameStmt)
+ _, err := stmt.ExecContext(ctx, displayName, localpart, deviceID)
+ return err
+}
+
+func (s *devicesStatements) selectDeviceByToken(
+ ctx context.Context, accessToken string,
+) (*authtypes.Device, error) {
+ var dev authtypes.Device
+ var localpart string
+ stmt := s.selectDeviceByTokenStmt
+ err := stmt.QueryRowContext(ctx, accessToken).Scan(&dev.ID, &localpart)
+ if err == nil {
+ dev.UserID = userutil.MakeUserID(localpart, s.serverName)
+ dev.AccessToken = accessToken
+ }
+ return &dev, err
+}
+
+func (s *devicesStatements) selectDeviceByID(
+ ctx context.Context, localpart, deviceID string,
+) (*authtypes.Device, error) {
+ var dev authtypes.Device
+ var created sql.NullInt64
+ stmt := s.selectDeviceByIDStmt
+ err := stmt.QueryRowContext(ctx, localpart, deviceID).Scan(&created)
+ if err == nil {
+ dev.ID = deviceID
+ dev.UserID = userutil.MakeUserID(localpart, s.serverName)
+ }
+ return &dev, err
+}
+
+func (s *devicesStatements) selectDevicesByLocalpart(
+ ctx context.Context, localpart string,
+) ([]authtypes.Device, error) {
+ devices := []authtypes.Device{}
+
+ rows, err := s.selectDevicesByLocalpartStmt.QueryContext(ctx, localpart)
+
+ if err != nil {
+ return devices, err
+ }
+
+ for rows.Next() {
+ var dev authtypes.Device
+ err = rows.Scan(&dev.ID)
+ if err != nil {
+ return devices, err
+ }
+ dev.UserID = userutil.MakeUserID(localpart, s.serverName)
+ devices = append(devices, dev)
+ }
+
+ return devices, nil
+}
diff --git a/clientapi/auth/storage/devices/storage.go b/clientapi/auth/storage/devices/storage.go
new file mode 100644
index 00000000..7032fe7b
--- /dev/null
+++ b/clientapi/auth/storage/devices/storage.go
@@ -0,0 +1,167 @@
+// 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 devices
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/base64"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/common"
+ "github.com/matrix-org/gomatrixserverlib"
+)
+
+// The length of generated device IDs
+var deviceIDByteLength = 6
+
+// Database represents a device database.
+type Database struct {
+ db *sql.DB
+ devices devicesStatements
+}
+
+// NewDatabase creates a new device database
+func NewDatabase(dataSourceName string, serverName gomatrixserverlib.ServerName) (*Database, error) {
+ var db *sql.DB
+ var err error
+ if db, err = sql.Open("postgres", dataSourceName); err != nil {
+ return nil, err
+ }
+ d := devicesStatements{}
+ if err = d.prepare(db, serverName); err != nil {
+ return nil, err
+ }
+ return &Database{db, d}, nil
+}
+
+// GetDeviceByAccessToken returns the device matching the given access token.
+// Returns sql.ErrNoRows if no matching device was found.
+func (d *Database) GetDeviceByAccessToken(
+ ctx context.Context, token string,
+) (*authtypes.Device, error) {
+ return d.devices.selectDeviceByToken(ctx, token)
+}
+
+// GetDeviceByID returns the device matching the given ID.
+// Returns sql.ErrNoRows if no matching device was found.
+func (d *Database) GetDeviceByID(
+ ctx context.Context, localpart, deviceID string,
+) (*authtypes.Device, error) {
+ return d.devices.selectDeviceByID(ctx, localpart, deviceID)
+}
+
+// GetDevicesByLocalpart returns the devices matching the given localpart.
+func (d *Database) GetDevicesByLocalpart(
+ ctx context.Context, localpart string,
+) ([]authtypes.Device, error) {
+ return d.devices.selectDevicesByLocalpart(ctx, localpart)
+}
+
+// CreateDevice makes a new device associated with the given user ID localpart.
+// If there is already a device with the same device ID for this user, that access token will be revoked
+// and replaced with the given accessToken. If the given accessToken is already in use for another device,
+// an error will be returned.
+// If no device ID is given one is generated.
+// Returns the device on success.
+func (d *Database) CreateDevice(
+ ctx context.Context, localpart string, deviceID *string, accessToken string,
+ displayName *string,
+) (dev *authtypes.Device, returnErr error) {
+ if deviceID != nil {
+ returnErr = common.WithTransaction(d.db, func(txn *sql.Tx) error {
+ var err error
+ // Revoke existing token for this device
+ if err = d.devices.deleteDevice(ctx, txn, *deviceID, localpart); err != nil {
+ return err
+ }
+
+ dev, err = d.devices.insertDevice(ctx, txn, *deviceID, localpart, accessToken, displayName)
+ return err
+ })
+ } else {
+ // We generate device IDs in a loop in case its already taken.
+ // We cap this at going round 5 times to ensure we don't spin forever
+ var newDeviceID string
+ for i := 1; i <= 5; i++ {
+ newDeviceID, returnErr = generateDeviceID()
+ if returnErr != nil {
+ return
+ }
+
+ returnErr = common.WithTransaction(d.db, func(txn *sql.Tx) error {
+ var err error
+ dev, err = d.devices.insertDevice(ctx, txn, newDeviceID, localpart, accessToken, displayName)
+ return err
+ })
+ if returnErr == nil {
+ return
+ }
+ }
+ }
+ return
+}
+
+// generateDeviceID creates a new device id. Returns an error if failed to generate
+// random bytes.
+func generateDeviceID() (string, error) {
+ b := make([]byte, deviceIDByteLength)
+ _, err := rand.Read(b)
+ if err != nil {
+ return "", err
+ }
+ // url-safe no padding
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// UpdateDevice updates the given device with the display name.
+// Returns SQL error if there are problems and nil on success.
+func (d *Database) UpdateDevice(
+ ctx context.Context, localpart, deviceID string, displayName *string,
+) error {
+ return common.WithTransaction(d.db, func(txn *sql.Tx) error {
+ return d.devices.updateDeviceName(ctx, txn, localpart, deviceID, displayName)
+ })
+}
+
+// RemoveDevice revokes a device by deleting the entry in the database
+// matching with the given device ID and user ID localpart.
+// If the device doesn't exist, it will not return an error
+// If something went wrong during the deletion, it will return the SQL error.
+func (d *Database) RemoveDevice(
+ ctx context.Context, deviceID, localpart string,
+) error {
+ return common.WithTransaction(d.db, func(txn *sql.Tx) error {
+ if err := d.devices.deleteDevice(ctx, txn, deviceID, localpart); err != sql.ErrNoRows {
+ return err
+ }
+ return nil
+ })
+}
+
+// RemoveAllDevices revokes devices by deleting the entry in the
+// database matching the given user ID localpart.
+// If something went wrong during the deletion, it will return the SQL error.
+func (d *Database) RemoveAllDevices(
+ ctx context.Context, localpart string,
+) error {
+ return common.WithTransaction(d.db, func(txn *sql.Tx) error {
+ if err := d.devices.deleteDevicesByLocalpart(ctx, txn, localpart); err != sql.ErrNoRows {
+ return err
+ }
+ return nil
+ })
+}
diff --git a/clientapi/clientapi.go b/clientapi/clientapi.go
new file mode 100644
index 00000000..5b6e21c8
--- /dev/null
+++ b/clientapi/clientapi.go
@@ -0,0 +1,72 @@
+// 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 clientapi
+
+import (
+ appserviceAPI "github.com/matrix-org/dendrite/appservice/api"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/devices"
+ "github.com/matrix-org/dendrite/clientapi/consumers"
+ "github.com/matrix-org/dendrite/clientapi/producers"
+ "github.com/matrix-org/dendrite/clientapi/routing"
+ "github.com/matrix-org/dendrite/common/basecomponent"
+ "github.com/matrix-org/dendrite/common/transactions"
+ roomserverAPI "github.com/matrix-org/dendrite/roomserver/api"
+ typingServerAPI "github.com/matrix-org/dendrite/typingserver/api"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/sirupsen/logrus"
+)
+
+// SetupClientAPIComponent sets up and registers HTTP handlers for the ClientAPI
+// component.
+func SetupClientAPIComponent(
+ base *basecomponent.BaseDendrite,
+ deviceDB *devices.Database,
+ accountsDB *accounts.Database,
+ federation *gomatrixserverlib.FederationClient,
+ keyRing *gomatrixserverlib.KeyRing,
+ aliasAPI roomserverAPI.RoomserverAliasAPI,
+ inputAPI roomserverAPI.RoomserverInputAPI,
+ queryAPI roomserverAPI.RoomserverQueryAPI,
+ typingInputAPI typingServerAPI.TypingServerInputAPI,
+ asAPI appserviceAPI.AppServiceQueryAPI,
+ transactionsCache *transactions.Cache,
+) {
+ roomserverProducer := producers.NewRoomserverProducer(inputAPI)
+ typingProducer := producers.NewTypingServerProducer(typingInputAPI)
+
+ userUpdateProducer := &producers.UserUpdateProducer{
+ Producer: base.KafkaProducer,
+ Topic: string(base.Cfg.Kafka.Topics.UserUpdates),
+ }
+
+ syncProducer := &producers.SyncAPIProducer{
+ Producer: base.KafkaProducer,
+ Topic: string(base.Cfg.Kafka.Topics.OutputClientData),
+ }
+
+ consumer := consumers.NewOutputRoomEventConsumer(
+ base.Cfg, base.KafkaConsumer, accountsDB, queryAPI,
+ )
+ if err := consumer.Start(); err != nil {
+ logrus.WithError(err).Panicf("failed to start room server consumer")
+ }
+
+ routing.Setup(
+ base.APIMux, *base.Cfg, roomserverProducer, queryAPI, aliasAPI, asAPI,
+ accountsDB, deviceDB, federation, *keyRing, userUpdateProducer,
+ syncProducer, typingProducer, transactionsCache,
+ )
+}
diff --git a/clientapi/consumers/roomserver.go b/clientapi/consumers/roomserver.go
new file mode 100644
index 00000000..0ee7c6bf
--- /dev/null
+++ b/clientapi/consumers/roomserver.go
@@ -0,0 +1,144 @@
+// 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 consumers
+
+import (
+ "context"
+ "encoding/json"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/common"
+ "github.com/matrix-org/dendrite/common/config"
+ "github.com/matrix-org/dendrite/roomserver/api"
+ "github.com/matrix-org/gomatrixserverlib"
+
+ log "github.com/sirupsen/logrus"
+ sarama "gopkg.in/Shopify/sarama.v1"
+)
+
+// OutputRoomEventConsumer consumes events that originated in the room server.
+type OutputRoomEventConsumer struct {
+ roomServerConsumer *common.ContinualConsumer
+ db *accounts.Database
+ query api.RoomserverQueryAPI
+ serverName string
+}
+
+// NewOutputRoomEventConsumer creates a new OutputRoomEventConsumer. Call Start() to begin consuming from room servers.
+func NewOutputRoomEventConsumer(
+ cfg *config.Dendrite,
+ kafkaConsumer sarama.Consumer,
+ store *accounts.Database,
+ queryAPI api.RoomserverQueryAPI,
+) *OutputRoomEventConsumer {
+
+ consumer := common.ContinualConsumer{
+ Topic: string(cfg.Kafka.Topics.OutputRoomEvent),
+ Consumer: kafkaConsumer,
+ PartitionStore: store,
+ }
+ s := &OutputRoomEventConsumer{
+ roomServerConsumer: &consumer,
+ db: store,
+ query: queryAPI,
+ serverName: string(cfg.Matrix.ServerName),
+ }
+ consumer.ProcessMessage = s.onMessage
+
+ return s
+}
+
+// Start consuming from room servers
+func (s *OutputRoomEventConsumer) Start() error {
+ return s.roomServerConsumer.Start()
+}
+
+// onMessage is called when the sync server receives a new event from the room server output log.
+// It is not safe for this function to be called from multiple goroutines, or else the
+// sync stream position may race and be incorrectly calculated.
+func (s *OutputRoomEventConsumer) onMessage(msg *sarama.ConsumerMessage) error {
+ // Parse out the event JSON
+ var output api.OutputEvent
+ if err := json.Unmarshal(msg.Value, &output); err != nil {
+ // If the message was invalid, log it and move on to the next message in the stream
+ log.WithError(err).Errorf("roomserver output log: message parse failure")
+ return nil
+ }
+
+ if output.Type != api.OutputTypeNewRoomEvent {
+ log.WithField("type", output.Type).Debug(
+ "roomserver output log: ignoring unknown output type",
+ )
+ return nil
+ }
+
+ ev := output.NewRoomEvent.Event
+ log.WithFields(log.Fields{
+ "event_id": ev.EventID(),
+ "room_id": ev.RoomID(),
+ "type": ev.Type(),
+ }).Info("received event from roomserver")
+
+ events, err := s.lookupStateEvents(output.NewRoomEvent.AddsStateEventIDs, ev)
+ if err != nil {
+ return err
+ }
+
+ return s.db.UpdateMemberships(context.TODO(), events, output.NewRoomEvent.RemovesStateEventIDs)
+}
+
+// lookupStateEvents looks up the state events that are added by a new event.
+func (s *OutputRoomEventConsumer) lookupStateEvents(
+ addsStateEventIDs []string, event gomatrixserverlib.Event,
+) ([]gomatrixserverlib.Event, error) {
+ // Fast path if there aren't any new state events.
+ if len(addsStateEventIDs) == 0 {
+ // If the event is a membership update (e.g. for a profile update), it won't
+ // show up in AddsStateEventIDs, so we need to add it manually
+ if event.Type() == "m.room.member" {
+ return []gomatrixserverlib.Event{event}, nil
+ }
+ return nil, nil
+ }
+
+ // Fast path if the only state event added is the event itself.
+ if len(addsStateEventIDs) == 1 && addsStateEventIDs[0] == event.EventID() {
+ return []gomatrixserverlib.Event{event}, nil
+ }
+
+ result := []gomatrixserverlib.Event{}
+ missing := []string{}
+ for _, id := range addsStateEventIDs {
+ // Append the current event in the results if its ID is in the events list
+ if id == event.EventID() {
+ result = append(result, event)
+ } else {
+ // If the event isn't the current one, add it to the list of events
+ // to retrieve from the roomserver
+ missing = append(missing, id)
+ }
+ }
+
+ // Request the missing events from the roomserver
+ eventReq := api.QueryEventsByIDRequest{EventIDs: missing}
+ var eventResp api.QueryEventsByIDResponse
+ if err := s.query.QueryEventsByID(context.TODO(), &eventReq, &eventResp); err != nil {
+ return nil, err
+ }
+
+ result = append(result, eventResp.Events...)
+
+ return result, nil
+}
diff --git a/clientapi/httputil/httputil.go b/clientapi/httputil/httputil.go
new file mode 100644
index 00000000..11785f51
--- /dev/null
+++ b/clientapi/httputil/httputil.go
@@ -0,0 +1,46 @@
+// 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 httputil
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/util"
+)
+
+// UnmarshalJSONRequest into the given interface pointer. Returns an error JSON response if
+// there was a problem unmarshalling. Calling this function consumes the request body.
+func UnmarshalJSONRequest(req *http.Request, iface interface{}) *util.JSONResponse {
+ if err := json.NewDecoder(req.Body).Decode(iface); err != nil {
+ // TODO: We may want to suppress the Error() return in production? It's useful when
+ // debugging because an error will be produced for both invalid/malformed JSON AND
+ // valid JSON with incorrect types for values.
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("The request body could not be decoded into valid JSON. " + err.Error()),
+ }
+ }
+ return nil
+}
+
+// LogThenError logs the given error then returns a matrix-compliant 500 internal server error response.
+// This should be used to log fatal errors which require investigation. It should not be used
+// to log client validation errors, etc.
+func LogThenError(req *http.Request, err error) util.JSONResponse {
+ util.GetLogger(req.Context()).WithError(err).Error("request failed")
+ return jsonerror.InternalServerError()
+}
diff --git a/clientapi/httputil/parse.go b/clientapi/httputil/parse.go
new file mode 100644
index 00000000..ee603341
--- /dev/null
+++ b/clientapi/httputil/parse.go
@@ -0,0 +1,39 @@
+// 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 httputil
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+// ParseTSParam takes a req (typically from an application service) and parses a Time object
+// from the req if it exists in the query parameters. If it doesn't exist, the
+// current time is returned.
+func ParseTSParam(req *http.Request) (time.Time, error) {
+ // Use the ts parameter's value for event time if present
+ tsStr := req.URL.Query().Get("ts")
+ if tsStr == "" {
+ return time.Now(), nil
+ }
+
+ // The parameter exists, parse into a Time object
+ ts, err := strconv.ParseInt(tsStr, 10, 64)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("Param 'ts' is no valid int (%s)", err.Error())
+ }
+
+ return time.Unix(ts/1000, 0), nil
+}
diff --git a/clientapi/jsonerror/jsonerror.go b/clientapi/jsonerror/jsonerror.go
new file mode 100644
index 00000000..fa15d9d8
--- /dev/null
+++ b/clientapi/jsonerror/jsonerror.go
@@ -0,0 +1,148 @@
+// 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 jsonerror
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/matrix-org/util"
+)
+
+// MatrixError represents the "standard error response" in Matrix.
+// http://matrix.org/docs/spec/client_server/r0.2.0.html#api-standards
+type MatrixError struct {
+ ErrCode string `json:"errcode"`
+ Err string `json:"error"`
+}
+
+func (e MatrixError) Error() string {
+ return fmt.Sprintf("%s: %s", e.ErrCode, e.Err)
+}
+
+// InternalServerError returns a 500 Internal Server Error in a matrix-compliant
+// format.
+func InternalServerError() util.JSONResponse {
+ return util.JSONResponse{
+ Code: http.StatusInternalServerError,
+ JSON: Unknown("Internal Server Error"),
+ }
+}
+
+// Unknown is an unexpected error
+func Unknown(msg string) *MatrixError {
+ return &MatrixError{"M_UNKNOWN", msg}
+}
+
+// Forbidden is an error when the client tries to access a resource
+// they are not allowed to access.
+func Forbidden(msg string) *MatrixError {
+ return &MatrixError{"M_FORBIDDEN", msg}
+}
+
+// BadJSON is an error when the client supplies malformed JSON.
+func BadJSON(msg string) *MatrixError {
+ return &MatrixError{"M_BAD_JSON", msg}
+}
+
+// NotJSON is an error when the client supplies something that is not JSON
+// to a JSON endpoint.
+func NotJSON(msg string) *MatrixError {
+ return &MatrixError{"M_NOT_JSON", msg}
+}
+
+// NotFound is an error when the client tries to access an unknown resource.
+func NotFound(msg string) *MatrixError {
+ return &MatrixError{"M_NOT_FOUND", msg}
+}
+
+// MissingArgument is an error when the client tries to access a resource
+// without providing an argument that is required.
+func MissingArgument(msg string) *MatrixError {
+ return &MatrixError{"M_MISSING_ARGUMENT", msg}
+}
+
+// InvalidArgumentValue is an error when the client tries to provide an
+// invalid value for a valid argument
+func InvalidArgumentValue(msg string) *MatrixError {
+ return &MatrixError{"M_INVALID_ARGUMENT_VALUE", msg}
+}
+
+// MissingToken is an error when the client tries to access a resource which
+// requires authentication without supplying credentials.
+func MissingToken(msg string) *MatrixError {
+ return &MatrixError{"M_MISSING_TOKEN", msg}
+}
+
+// UnknownToken is an error when the client tries to access a resource which
+// requires authentication and supplies an unrecognized token
+func UnknownToken(msg string) *MatrixError {
+ return &MatrixError{"M_UNKNOWN_TOKEN", msg}
+}
+
+// WeakPassword is an error which is returned when the client tries to register
+// using a weak password. http://matrix.org/docs/spec/client_server/r0.2.0.html#password-based
+func WeakPassword(msg string) *MatrixError {
+ return &MatrixError{"M_WEAK_PASSWORD", msg}
+}
+
+// InvalidUsername is an error returned when the client tries to register an
+// invalid username
+func InvalidUsername(msg string) *MatrixError {
+ return &MatrixError{"M_INVALID_USERNAME", msg}
+}
+
+// UserInUse is an error returned when the client tries to register an
+// username that already exists
+func UserInUse(msg string) *MatrixError {
+ return &MatrixError{"M_USER_IN_USE", msg}
+}
+
+// ASExclusive is an error returned when an application service tries to
+// register an username that is outside of its registered namespace, or if a
+// user attempts to register a username or room alias within an exclusive
+// namespace.
+func ASExclusive(msg string) *MatrixError {
+ return &MatrixError{"M_EXCLUSIVE", msg}
+}
+
+// GuestAccessForbidden is an error which is returned when the client is
+// forbidden from accessing a resource as a guest.
+func GuestAccessForbidden(msg string) *MatrixError {
+ return &MatrixError{"M_GUEST_ACCESS_FORBIDDEN", msg}
+}
+
+// LimitExceededError is a rate-limiting error.
+type LimitExceededError struct {
+ MatrixError
+ RetryAfterMS int64 `json:"retry_after_ms,omitempty"`
+}
+
+// LimitExceeded is an error when the client tries to send events too quickly.
+func LimitExceeded(msg string, retryAfterMS int64) *LimitExceededError {
+ return &LimitExceededError{
+ MatrixError: MatrixError{"M_LIMIT_EXCEEDED", msg},
+ RetryAfterMS: retryAfterMS,
+ }
+}
+
+// NotTrusted is an error which is returned when the client asks the server to
+// proxy a request (e.g. 3PID association) to a server that isn't trusted
+func NotTrusted(serverName string) *MatrixError {
+ return &MatrixError{
+ ErrCode: "M_SERVER_NOT_TRUSTED",
+ Err: fmt.Sprintf("Untrusted server '%s'", serverName),
+ }
+}
diff --git a/clientapi/jsonerror/jsonerror_test.go b/clientapi/jsonerror/jsonerror_test.go
new file mode 100644
index 00000000..9f3754cb
--- /dev/null
+++ b/clientapi/jsonerror/jsonerror_test.go
@@ -0,0 +1,44 @@
+// 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 jsonerror
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestLimitExceeded(t *testing.T) {
+ e := LimitExceeded("too fast", 5000)
+ jsonBytes, err := json.Marshal(&e)
+ if err != nil {
+ t.Fatalf("TestLimitExceeded: Failed to marshal LimitExceeded error. %s", err.Error())
+ }
+ want := `{"errcode":"M_LIMIT_EXCEEDED","error":"too fast","retry_after_ms":5000}`
+ if string(jsonBytes) != want {
+ t.Errorf("TestLimitExceeded: want %s, got %s", want, string(jsonBytes))
+ }
+}
+
+func TestForbidden(t *testing.T) {
+ e := Forbidden("you shall not pass")
+ jsonBytes, err := json.Marshal(&e)
+ if err != nil {
+ t.Fatalf("TestForbidden: Failed to marshal Forbidden error. %s", err.Error())
+ }
+ want := `{"errcode":"M_FORBIDDEN","error":"you shall not pass"}`
+ if string(jsonBytes) != want {
+ t.Errorf("TestForbidden: want %s, got %s", want, string(jsonBytes))
+ }
+}
diff --git a/clientapi/producers/roomserver.go b/clientapi/producers/roomserver.go
new file mode 100644
index 00000000..e50561a7
--- /dev/null
+++ b/clientapi/producers/roomserver.go
@@ -0,0 +1,112 @@
+// 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 producers
+
+import (
+ "context"
+
+ "github.com/matrix-org/dendrite/roomserver/api"
+ "github.com/matrix-org/gomatrixserverlib"
+)
+
+// RoomserverProducer produces events for the roomserver to consume.
+type RoomserverProducer struct {
+ InputAPI api.RoomserverInputAPI
+}
+
+// NewRoomserverProducer creates a new RoomserverProducer
+func NewRoomserverProducer(inputAPI api.RoomserverInputAPI) *RoomserverProducer {
+ return &RoomserverProducer{
+ InputAPI: inputAPI,
+ }
+}
+
+// SendEvents writes the given events to the roomserver input log. The events are written with KindNew.
+func (c *RoomserverProducer) SendEvents(
+ ctx context.Context, events []gomatrixserverlib.Event, sendAsServer gomatrixserverlib.ServerName,
+ txnID *api.TransactionID,
+) (string, error) {
+ ires := make([]api.InputRoomEvent, len(events))
+ for i, event := range events {
+ ires[i] = api.InputRoomEvent{
+ Kind: api.KindNew,
+ Event: event,
+ AuthEventIDs: event.AuthEventIDs(),
+ SendAsServer: string(sendAsServer),
+ TransactionID: txnID,
+ }
+ }
+ return c.SendInputRoomEvents(ctx, ires)
+}
+
+// SendEventWithState writes an event with KindNew to the roomserver input log
+// with the state at the event as KindOutlier before it.
+func (c *RoomserverProducer) SendEventWithState(
+ ctx context.Context, state gomatrixserverlib.RespState, event gomatrixserverlib.Event,
+) error {
+ outliers, err := state.Events()
+ if err != nil {
+ return err
+ }
+
+ ires := make([]api.InputRoomEvent, len(outliers)+1)
+ for i, outlier := range outliers {
+ ires[i] = api.InputRoomEvent{
+ Kind: api.KindOutlier,
+ Event: outlier,
+ AuthEventIDs: outlier.AuthEventIDs(),
+ }
+ }
+
+ stateEventIDs := make([]string, len(state.StateEvents))
+ for i := range state.StateEvents {
+ stateEventIDs[i] = state.StateEvents[i].EventID()
+ }
+
+ ires[len(outliers)] = api.InputRoomEvent{
+ Kind: api.KindNew,
+ Event: event,
+ AuthEventIDs: event.AuthEventIDs(),
+ HasState: true,
+ StateEventIDs: stateEventIDs,
+ }
+
+ _, err = c.SendInputRoomEvents(ctx, ires)
+ return err
+}
+
+// SendInputRoomEvents writes the given input room events to the roomserver input API.
+func (c *RoomserverProducer) SendInputRoomEvents(
+ ctx context.Context, ires []api.InputRoomEvent,
+) (eventID string, err error) {
+ request := api.InputRoomEventsRequest{InputRoomEvents: ires}
+ var response api.InputRoomEventsResponse
+ err = c.InputAPI.InputRoomEvents(ctx, &request, &response)
+ eventID = response.EventID
+ return
+}
+
+// SendInvite writes the invite event to the roomserver input API.
+// This should only be needed for invite events that occur outside of a known room.
+// If we are in the room then the event should be sent using the SendEvents method.
+func (c *RoomserverProducer) SendInvite(
+ ctx context.Context, inviteEvent gomatrixserverlib.Event,
+) error {
+ request := api.InputRoomEventsRequest{
+ InputInviteEvents: []api.InputInviteEvent{{Event: inviteEvent}},
+ }
+ var response api.InputRoomEventsResponse
+ return c.InputAPI.InputRoomEvents(ctx, &request, &response)
+}
diff --git a/clientapi/producers/syncapi.go b/clientapi/producers/syncapi.go
new file mode 100644
index 00000000..6bfcd51a
--- /dev/null
+++ b/clientapi/producers/syncapi.go
@@ -0,0 +1,50 @@
+// 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 producers
+
+import (
+ "encoding/json"
+
+ "github.com/matrix-org/dendrite/common"
+
+ sarama "gopkg.in/Shopify/sarama.v1"
+)
+
+// SyncAPIProducer produces events for the sync API server to consume
+type SyncAPIProducer struct {
+ Topic string
+ Producer sarama.SyncProducer
+}
+
+// SendData sends account data to the sync API server
+func (p *SyncAPIProducer) SendData(userID string, roomID string, dataType string) error {
+ var m sarama.ProducerMessage
+
+ data := common.AccountData{
+ RoomID: roomID,
+ Type: dataType,
+ }
+ value, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ m.Topic = string(p.Topic)
+ m.Key = sarama.StringEncoder(userID)
+ m.Value = sarama.ByteEncoder(value)
+
+ _, _, err = p.Producer.SendMessage(&m)
+ return err
+}
diff --git a/clientapi/producers/typingserver.go b/clientapi/producers/typingserver.go
new file mode 100644
index 00000000..f4d0bcba
--- /dev/null
+++ b/clientapi/producers/typingserver.go
@@ -0,0 +1,54 @@
+// 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 producers
+
+import (
+ "context"
+ "time"
+
+ "github.com/matrix-org/dendrite/typingserver/api"
+ "github.com/matrix-org/gomatrixserverlib"
+)
+
+// TypingServerProducer produces events for the typing server to consume
+type TypingServerProducer struct {
+ InputAPI api.TypingServerInputAPI
+}
+
+// NewTypingServerProducer creates a new TypingServerProducer
+func NewTypingServerProducer(inputAPI api.TypingServerInputAPI) *TypingServerProducer {
+ return &TypingServerProducer{
+ InputAPI: inputAPI,
+ }
+}
+
+// Send typing event to typing server
+func (p *TypingServerProducer) Send(
+ ctx context.Context, userID, roomID string,
+ typing bool, timeout int64,
+) error {
+ requestData := api.InputTypingEvent{
+ UserID: userID,
+ RoomID: roomID,
+ Typing: typing,
+ Timeout: timeout,
+ OriginServerTS: gomatrixserverlib.AsTimestamp(time.Now()),
+ }
+
+ var response api.InputTypingEventResponse
+ err := p.InputAPI.InputTypingEvent(
+ ctx, &api.InputTypingEventRequest{InputTypingEvent: requestData}, &response,
+ )
+
+ return err
+}
diff --git a/clientapi/producers/userupdate.go b/clientapi/producers/userupdate.go
new file mode 100644
index 00000000..2a5dfc70
--- /dev/null
+++ b/clientapi/producers/userupdate.go
@@ -0,0 +1,62 @@
+// 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 producers
+
+import (
+ "encoding/json"
+
+ sarama "gopkg.in/Shopify/sarama.v1"
+)
+
+// UserUpdateProducer produces events related to user updates.
+type UserUpdateProducer struct {
+ Topic string
+ Producer sarama.SyncProducer
+}
+
+// TODO: Move this struct to `common` so the components that consume the topic
+// can use it when parsing incoming messages
+type profileUpdate struct {
+ Updated string `json:"updated"` // Which attribute is updated (can be either `avatar_url` or `displayname`)
+ OldValue string `json:"old_value"` // The attribute's value before the update
+ NewValue string `json:"new_value"` // The attribute's value after the update
+}
+
+// SendUpdate sends an update using kafka to notify the roomserver of the
+// profile update. Returns an error if the update failed to send.
+func (p *UserUpdateProducer) SendUpdate(
+ userID string, updatedAttribute string, oldValue string, newValue string,
+) error {
+ var update profileUpdate
+ var m sarama.ProducerMessage
+
+ m.Topic = string(p.Topic)
+ m.Key = sarama.StringEncoder(userID)
+
+ update = profileUpdate{
+ Updated: updatedAttribute,
+ OldValue: oldValue,
+ NewValue: newValue,
+ }
+
+ value, err := json.Marshal(update)
+ if err != nil {
+ return err
+ }
+ m.Value = sarama.ByteEncoder(value)
+
+ _, _, err = p.Producer.SendMessage(&m)
+ return err
+}
diff --git a/clientapi/routing/account_data.go b/clientapi/routing/account_data.go
new file mode 100644
index 00000000..30e00f72
--- /dev/null
+++ b/clientapi/routing/account_data.go
@@ -0,0 +1,76 @@
+// 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 routing
+
+import (
+ "io/ioutil"
+ "net/http"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/producers"
+ "github.com/matrix-org/gomatrixserverlib"
+
+ "github.com/matrix-org/util"
+)
+
+// SaveAccountData implements PUT /user/{userId}/[rooms/{roomId}/]account_data/{type}
+func SaveAccountData(
+ req *http.Request, accountDB *accounts.Database, device *authtypes.Device,
+ userID string, roomID string, dataType string, syncProducer *producers.SyncAPIProducer,
+) util.JSONResponse {
+ if req.Method != http.MethodPut {
+ return util.JSONResponse{
+ Code: http.StatusMethodNotAllowed,
+ JSON: jsonerror.NotFound("Bad method"),
+ }
+ }
+
+ if userID != device.UserID {
+ return util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("userID does not match the current user"),
+ }
+ }
+
+ localpart, _, err := gomatrixserverlib.SplitID('@', userID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ defer req.Body.Close() // nolint: errcheck
+
+ body, err := ioutil.ReadAll(req.Body)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if err := accountDB.SaveAccountData(
+ req.Context(), localpart, roomID, dataType, string(body),
+ ); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if err := syncProducer.SendData(userID, roomID, dataType); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+}
diff --git a/clientapi/routing/createroom.go b/clientapi/routing/createroom.go
new file mode 100644
index 00000000..a7187c49
--- /dev/null
+++ b/clientapi/routing/createroom.go
@@ -0,0 +1,337 @@
+// 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 routing
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ appserviceAPI "github.com/matrix-org/dendrite/appservice/api"
+ roomserverAPI "github.com/matrix-org/dendrite/roomserver/api"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/producers"
+ "github.com/matrix-org/dendrite/common"
+ "github.com/matrix-org/dendrite/common/config"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+ log "github.com/sirupsen/logrus"
+)
+
+// https://matrix.org/docs/spec/client_server/r0.2.0.html#post-matrix-client-r0-createroom
+type createRoomRequest struct {
+ Invite []string `json:"invite"`
+ Name string `json:"name"`
+ Visibility string `json:"visibility"`
+ Topic string `json:"topic"`
+ Preset string `json:"preset"`
+ CreationContent map[string]interface{} `json:"creation_content"`
+ InitialState []fledglingEvent `json:"initial_state"`
+ RoomAliasName string `json:"room_alias_name"`
+ GuestCanJoin bool `json:"guest_can_join"`
+}
+
+const (
+ presetPrivateChat = "private_chat"
+ presetTrustedPrivateChat = "trusted_private_chat"
+ presetPublicChat = "public_chat"
+)
+
+const (
+ joinRulePublic = "public"
+ joinRuleInvite = "invite"
+)
+const (
+ historyVisibilityShared = "shared"
+ // TODO: These should be implemented once history visibility is implemented
+ // historyVisibilityWorldReadable = "world_readable"
+ // historyVisibilityInvited = "invited"
+)
+
+func (r createRoomRequest) Validate() *util.JSONResponse {
+ whitespace := "\t\n\x0b\x0c\r " // https://docs.python.org/2/library/string.html#string.whitespace
+ // https://github.com/matrix-org/synapse/blob/v0.19.2/synapse/handlers/room.py#L81
+ // Synapse doesn't check for ':' but we will else it will break parsers badly which split things into 2 segments.
+ if strings.ContainsAny(r.RoomAliasName, whitespace+":") {
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("room_alias_name cannot contain whitespace or ':'"),
+ }
+ }
+ for _, userID := range r.Invite {
+ // TODO: We should put user ID parsing code into gomatrixserverlib and use that instead
+ // (see https://github.com/matrix-org/gomatrixserverlib/blob/3394e7c7003312043208aa73727d2256eea3d1f6/eventcontent.go#L347 )
+ // It should be a struct (with pointers into a single string to avoid copying) and
+ // we should update all refs to use UserID types rather than strings.
+ // https://github.com/matrix-org/synapse/blob/v0.19.2/synapse/types.py#L92
+ if _, _, err := gomatrixserverlib.SplitID('@', userID); err != nil {
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("user id must be in the form @localpart:domain"),
+ }
+ }
+ }
+ switch r.Preset {
+ case presetPrivateChat, presetTrustedPrivateChat, presetPublicChat, "":
+ default:
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("preset must be any of 'private_chat', 'trusted_private_chat', 'public_chat'"),
+ }
+ }
+
+ return nil
+}
+
+// https://matrix.org/docs/spec/client_server/r0.2.0.html#post-matrix-client-r0-createroom
+type createRoomResponse struct {
+ RoomID string `json:"room_id"`
+ RoomAlias string `json:"room_alias,omitempty"` // in synapse not spec
+}
+
+// fledglingEvent is a helper representation of an event used when creating many events in succession.
+type fledglingEvent struct {
+ Type string `json:"type"`
+ StateKey string `json:"state_key"`
+ Content interface{} `json:"content"`
+}
+
+// CreateRoom implements /createRoom
+func CreateRoom(
+ req *http.Request, device *authtypes.Device,
+ cfg config.Dendrite, producer *producers.RoomserverProducer,
+ accountDB *accounts.Database, aliasAPI roomserverAPI.RoomserverAliasAPI,
+ asAPI appserviceAPI.AppServiceQueryAPI,
+) util.JSONResponse {
+ // TODO (#267): Check room ID doesn't clash with an existing one, and we
+ // probably shouldn't be using pseudo-random strings, maybe GUIDs?
+ roomID := fmt.Sprintf("!%s:%s", util.RandomString(16), cfg.Matrix.ServerName)
+ return createRoom(req, device, cfg, roomID, producer, accountDB, aliasAPI, asAPI)
+}
+
+// createRoom implements /createRoom
+// nolint: gocyclo
+func createRoom(
+ req *http.Request, device *authtypes.Device,
+ cfg config.Dendrite, roomID string, producer *producers.RoomserverProducer,
+ accountDB *accounts.Database, aliasAPI roomserverAPI.RoomserverAliasAPI,
+ asAPI appserviceAPI.AppServiceQueryAPI,
+) util.JSONResponse {
+ logger := util.GetLogger(req.Context())
+ userID := device.UserID
+ var r createRoomRequest
+ resErr := httputil.UnmarshalJSONRequest(req, &r)
+ if resErr != nil {
+ return *resErr
+ }
+ // TODO: apply rate-limit
+
+ if resErr = r.Validate(); resErr != nil {
+ return *resErr
+ }
+
+ evTime, err := httputil.ParseTSParam(req)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.InvalidArgumentValue(err.Error()),
+ }
+ }
+ // TODO: visibility/presets/raw initial state/creation content
+ // TODO: Create room alias association
+ // Make sure this doesn't fall into an application service's namespace though!
+
+ logger.WithFields(log.Fields{
+ "userID": userID,
+ "roomID": roomID,
+ }).Info("Creating new room")
+
+ profile, err := appserviceAPI.RetreiveUserProfile(req.Context(), userID, asAPI, accountDB)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ membershipContent := common.MemberContent{
+ Membership: "join",
+ DisplayName: profile.DisplayName,
+ AvatarURL: profile.AvatarURL,
+ }
+
+ var joinRules, historyVisibility string
+ switch r.Preset {
+ case presetPrivateChat:
+ joinRules = joinRuleInvite
+ historyVisibility = historyVisibilityShared
+ case presetTrustedPrivateChat:
+ joinRules = joinRuleInvite
+ historyVisibility = historyVisibilityShared
+ // TODO If trusted_private_chat, all invitees are given the same power level as the room creator.
+ case presetPublicChat:
+ joinRules = joinRulePublic
+ historyVisibility = historyVisibilityShared
+ default:
+ // Default room rules, r.Preset was previously checked for valid values so
+ // only a request with no preset should end up here.
+ joinRules = joinRuleInvite
+ historyVisibility = historyVisibilityShared
+ }
+
+ var builtEvents []gomatrixserverlib.Event
+
+ // send events into the room in order of:
+ // 1- m.room.create
+ // 2- room creator join member
+ // 3- m.room.power_levels
+ // 4- m.room.canonical_alias (opt) TODO
+ // 5- m.room.join_rules
+ // 6- m.room.history_visibility
+ // 7- m.room.guest_access (opt)
+ // 8- other initial state items
+ // 9- m.room.name (opt)
+ // 10- m.room.topic (opt)
+ // 11- invite events (opt) - with is_direct flag if applicable TODO
+ // 12- 3pid invite events (opt) TODO
+ // 13- m.room.aliases event for HS (if alias specified) TODO
+ // This differs from Synapse slightly. Synapse would vary the ordering of 3-7
+ // depending on if those events were in "initial_state" or not. This made it
+ // harder to reason about, hence sticking to a strict static ordering.
+ // TODO: Synapse has txn/token ID on each event. Do we need to do this here?
+ eventsToMake := []fledglingEvent{
+ {"m.room.create", "", common.CreateContent{Creator: userID}},
+ {"m.room.member", userID, membershipContent},
+ {"m.room.power_levels", "", common.InitialPowerLevelsContent(userID)},
+ // TODO: m.room.canonical_alias
+ {"m.room.join_rules", "", common.JoinRulesContent{JoinRule: joinRules}},
+ {"m.room.history_visibility", "", common.HistoryVisibilityContent{HistoryVisibility: historyVisibility}},
+ }
+ if r.GuestCanJoin {
+ eventsToMake = append(eventsToMake, fledglingEvent{"m.room.guest_access", "", common.GuestAccessContent{GuestAccess: "can_join"}})
+ }
+ eventsToMake = append(eventsToMake, r.InitialState...)
+ if r.Name != "" {
+ eventsToMake = append(eventsToMake, fledglingEvent{"m.room.name", "", common.NameContent{Name: r.Name}})
+ }
+ if r.Topic != "" {
+ eventsToMake = append(eventsToMake, fledglingEvent{"m.room.topic", "", common.TopicContent{Topic: r.Topic}})
+ }
+ // TODO: invite events
+ // TODO: 3pid invite events
+ // TODO: m.room.aliases
+
+ authEvents := gomatrixserverlib.NewAuthEvents(nil)
+ for i, e := range eventsToMake {
+ depth := i + 1 // depth starts at 1
+
+ builder := gomatrixserverlib.EventBuilder{
+ Sender: userID,
+ RoomID: roomID,
+ Type: e.Type,
+ StateKey: &e.StateKey,
+ Depth: int64(depth),
+ }
+ err = builder.SetContent(e.Content)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+ if i > 0 {
+ builder.PrevEvents = []gomatrixserverlib.EventReference{builtEvents[i-1].EventReference()}
+ }
+ var ev *gomatrixserverlib.Event
+ ev, err = buildEvent(&builder, &authEvents, cfg, evTime)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if err = gomatrixserverlib.Allowed(*ev, &authEvents); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ // Add the event to the list of auth events
+ builtEvents = append(builtEvents, *ev)
+ err = authEvents.AddEvent(ev)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+ }
+
+ // send events to the room server
+ _, err = producer.SendEvents(req.Context(), builtEvents, cfg.Matrix.ServerName, nil)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ // TODO(#269): Reserve room alias while we create the room. This stops us
+ // from creating the room but still failing due to the alias having already
+ // been taken.
+ var roomAlias string
+ if r.RoomAliasName != "" {
+ roomAlias = fmt.Sprintf("#%s:%s", r.RoomAliasName, cfg.Matrix.ServerName)
+
+ aliasReq := roomserverAPI.SetRoomAliasRequest{
+ Alias: roomAlias,
+ RoomID: roomID,
+ UserID: userID,
+ }
+
+ var aliasResp roomserverAPI.SetRoomAliasResponse
+ err = aliasAPI.SetRoomAlias(req.Context(), &aliasReq, &aliasResp)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if aliasResp.AliasExists {
+ return util.MessageResponse(400, "Alias already exists")
+ }
+ }
+
+ response := createRoomResponse{
+ RoomID: roomID,
+ RoomAlias: roomAlias,
+ }
+
+ return util.JSONResponse{
+ Code: 200,
+ JSON: response,
+ }
+}
+
+// buildEvent fills out auth_events for the builder then builds the event
+func buildEvent(
+ builder *gomatrixserverlib.EventBuilder,
+ provider gomatrixserverlib.AuthEventProvider,
+ cfg config.Dendrite,
+ evTime time.Time,
+) (*gomatrixserverlib.Event, error) {
+ eventsNeeded, err := gomatrixserverlib.StateNeededForEventBuilder(builder)
+ if err != nil {
+ return nil, err
+ }
+ refs, err := eventsNeeded.AuthEventReferences(provider)
+ if err != nil {
+ return nil, err
+ }
+ builder.AuthEvents = refs
+ eventID := fmt.Sprintf("$%s:%s", util.RandomString(16), cfg.Matrix.ServerName)
+ event, err := builder.Build(eventID, evTime, cfg.Matrix.ServerName, cfg.Matrix.KeyID, cfg.Matrix.PrivateKey)
+ if err != nil {
+ return nil, fmt.Errorf("cannot build event %s : Builder failed to build. %s", builder.Type, err)
+ }
+ return &event, nil
+}
diff --git a/clientapi/routing/device.go b/clientapi/routing/device.go
new file mode 100644
index 00000000..cf6f24a7
--- /dev/null
+++ b/clientapi/routing/device.go
@@ -0,0 +1,155 @@
+// Copyright 2017 Paul Tötterman <paul.totterman@iki.fi>
+//
+// 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 (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/devices"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+)
+
+type deviceJSON struct {
+ DeviceID string `json:"device_id"`
+ UserID string `json:"user_id"`
+}
+
+type devicesJSON struct {
+ Devices []deviceJSON `json:"devices"`
+}
+
+type deviceUpdateJSON struct {
+ DisplayName *string `json:"display_name"`
+}
+
+// GetDeviceByID handles /devices/{deviceID}
+func GetDeviceByID(
+ req *http.Request, deviceDB *devices.Database, device *authtypes.Device,
+ deviceID string,
+) util.JSONResponse {
+ localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ ctx := req.Context()
+ dev, err := deviceDB.GetDeviceByID(ctx, localpart, deviceID)
+ if err == sql.ErrNoRows {
+ return util.JSONResponse{
+ Code: http.StatusNotFound,
+ JSON: jsonerror.NotFound("Unknown device"),
+ }
+ } else if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: deviceJSON{
+ DeviceID: dev.ID,
+ UserID: dev.UserID,
+ },
+ }
+}
+
+// GetDevicesByLocalpart handles /devices
+func GetDevicesByLocalpart(
+ req *http.Request, deviceDB *devices.Database, device *authtypes.Device,
+) util.JSONResponse {
+ localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ ctx := req.Context()
+ deviceList, err := deviceDB.GetDevicesByLocalpart(ctx, localpart)
+
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ res := devicesJSON{}
+
+ for _, dev := range deviceList {
+ res.Devices = append(res.Devices, deviceJSON{
+ DeviceID: dev.ID,
+ UserID: dev.UserID,
+ })
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: res,
+ }
+}
+
+// UpdateDeviceByID handles PUT on /devices/{deviceID}
+func UpdateDeviceByID(
+ req *http.Request, deviceDB *devices.Database, device *authtypes.Device,
+ deviceID string,
+) util.JSONResponse {
+ if req.Method != http.MethodPut {
+ return util.JSONResponse{
+ Code: http.StatusMethodNotAllowed,
+ JSON: jsonerror.NotFound("Bad Method"),
+ }
+ }
+
+ localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ ctx := req.Context()
+ dev, err := deviceDB.GetDeviceByID(ctx, localpart, deviceID)
+ if err == sql.ErrNoRows {
+ return util.JSONResponse{
+ Code: http.StatusNotFound,
+ JSON: jsonerror.NotFound("Unknown device"),
+ }
+ } else if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if dev.UserID != device.UserID {
+ return util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("device not owned by current user"),
+ }
+ }
+
+ defer req.Body.Close() // nolint: errcheck
+
+ payload := deviceUpdateJSON{}
+
+ if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if err := deviceDB.UpdateDevice(ctx, localpart, deviceID, payload.DisplayName); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+}
diff --git a/clientapi/routing/directory.go b/clientapi/routing/directory.go
new file mode 100644
index 00000000..b23dfbfb
--- /dev/null
+++ b/clientapi/routing/directory.go
@@ -0,0 +1,183 @@
+// 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 routing
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/common/config"
+ roomserverAPI "github.com/matrix-org/dendrite/roomserver/api"
+ "github.com/matrix-org/gomatrix"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+)
+
+// DirectoryRoom looks up a room alias
+func DirectoryRoom(
+ req *http.Request,
+ roomAlias string,
+ federation *gomatrixserverlib.FederationClient,
+ cfg *config.Dendrite,
+ rsAPI roomserverAPI.RoomserverAliasAPI,
+) util.JSONResponse {
+ _, domain, err := gomatrixserverlib.SplitID('#', roomAlias)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("Room alias must be in the form '#localpart:domain'"),
+ }
+ }
+
+ if domain == cfg.Matrix.ServerName {
+ // Query the roomserver API to check if the alias exists locally
+ queryReq := roomserverAPI.GetRoomIDForAliasRequest{Alias: roomAlias}
+ var queryRes roomserverAPI.GetRoomIDForAliasResponse
+ if err = rsAPI.GetRoomIDForAlias(req.Context(), &queryReq, &queryRes); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ // List any roomIDs found associated with this alias
+ if len(queryRes.RoomID) > 0 {
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: queryRes,
+ }
+ }
+ } else {
+ // Query the federation for this room alias
+ resp, err := federation.LookupRoomAlias(req.Context(), domain, roomAlias)
+ if err != nil {
+ switch err.(type) {
+ case gomatrix.HTTPError:
+ default:
+ // TODO: Return 502 if the remote server errored.
+ // TODO: Return 504 if the remote server timed out.
+ return httputil.LogThenError(req, err)
+ }
+ }
+ if len(resp.RoomID) > 0 {
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: resp,
+ }
+ }
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusNotFound,
+ JSON: jsonerror.NotFound(
+ fmt.Sprintf("Room alias %s not found", roomAlias),
+ ),
+ }
+}
+
+// SetLocalAlias implements PUT /directory/room/{roomAlias}
+// TODO: Check if the user has the power level to set an alias
+func SetLocalAlias(
+ req *http.Request,
+ device *authtypes.Device,
+ alias string,
+ cfg *config.Dendrite,
+ aliasAPI roomserverAPI.RoomserverAliasAPI,
+) util.JSONResponse {
+ _, domain, err := gomatrixserverlib.SplitID('#', alias)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("Room alias must be in the form '#localpart:domain'"),
+ }
+ }
+
+ if domain != cfg.Matrix.ServerName {
+ return util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("Alias must be on local homeserver"),
+ }
+ }
+
+ // Check that the alias does not fall within an exclusive namespace of an
+ // application service
+ // TODO: This code should eventually be refactored with:
+ // 1. The new method for checking for things matching an AS's namespace
+ // 2. Using an overall Regex object for all AS's just like we did for usernames
+ for _, appservice := range cfg.Derived.ApplicationServices {
+ if aliasNamespaces, ok := appservice.NamespaceMap["aliases"]; ok {
+ for _, namespace := range aliasNamespaces {
+ if namespace.Exclusive && namespace.RegexpObject.MatchString(alias) {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.ASExclusive("Alias is reserved by an application service"),
+ }
+ }
+ }
+ }
+ }
+
+ var r struct {
+ RoomID string `json:"room_id"`
+ }
+ if resErr := httputil.UnmarshalJSONRequest(req, &r); resErr != nil {
+ return *resErr
+ }
+
+ queryReq := roomserverAPI.SetRoomAliasRequest{
+ UserID: device.UserID,
+ RoomID: r.RoomID,
+ Alias: alias,
+ }
+ var queryRes roomserverAPI.SetRoomAliasResponse
+ if err := aliasAPI.SetRoomAlias(req.Context(), &queryReq, &queryRes); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if queryRes.AliasExists {
+ return util.JSONResponse{
+ Code: http.StatusConflict,
+ JSON: jsonerror.Unknown("The alias " + alias + " already exists."),
+ }
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+}
+
+// RemoveLocalAlias implements DELETE /directory/room/{roomAlias}
+// TODO: Check if the user has the power level to remove an alias
+func RemoveLocalAlias(
+ req *http.Request,
+ device *authtypes.Device,
+ alias string,
+ aliasAPI roomserverAPI.RoomserverAliasAPI,
+) util.JSONResponse {
+ queryReq := roomserverAPI.RemoveRoomAliasRequest{
+ Alias: alias,
+ UserID: device.UserID,
+ }
+ var queryRes roomserverAPI.RemoveRoomAliasResponse
+ if err := aliasAPI.RemoveRoomAlias(req.Context(), &queryReq, &queryRes); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+}
diff --git a/clientapi/routing/filter.go b/clientapi/routing/filter.go
new file mode 100644
index 00000000..109c55da
--- /dev/null
+++ b/clientapi/routing/filter.go
@@ -0,0 +1,123 @@
+// Copyright 2017 Jan Christian Grünhage
+//
+// 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"
+
+ "encoding/json"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/gomatrix"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+)
+
+// GetFilter implements GET /_matrix/client/r0/user/{userId}/filter/{filterId}
+func GetFilter(
+ req *http.Request, device *authtypes.Device, accountDB *accounts.Database, userID string, filterID string,
+) util.JSONResponse {
+ if req.Method != http.MethodGet {
+ return util.JSONResponse{
+ Code: http.StatusMethodNotAllowed,
+ JSON: jsonerror.NotFound("Bad method"),
+ }
+ }
+ if userID != device.UserID {
+ return util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("Cannot get filters for other users"),
+ }
+ }
+ localpart, _, err := gomatrixserverlib.SplitID('@', userID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ res, err := accountDB.GetFilter(req.Context(), localpart, filterID)
+ if err != nil {
+ //TODO better error handling. This error message is *probably* right,
+ // but if there are obscure db errors, this will also be returned,
+ // even though it is not correct.
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.NotFound("No such filter"),
+ }
+ }
+ filter := gomatrix.Filter{}
+ err = json.Unmarshal(res, &filter)
+ if err != nil {
+ httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: filter,
+ }
+}
+
+type filterResponse struct {
+ FilterID string `json:"filter_id"`
+}
+
+//PutFilter implements POST /_matrix/client/r0/user/{userId}/filter
+func PutFilter(
+ req *http.Request, device *authtypes.Device, accountDB *accounts.Database, userID string,
+) util.JSONResponse {
+ if req.Method != http.MethodPost {
+ return util.JSONResponse{
+ Code: http.StatusMethodNotAllowed,
+ JSON: jsonerror.NotFound("Bad method"),
+ }
+ }
+ if userID != device.UserID {
+ return util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("Cannot create filters for other users"),
+ }
+ }
+
+ localpart, _, err := gomatrixserverlib.SplitID('@', userID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ var filter gomatrix.Filter
+
+ if reqErr := httputil.UnmarshalJSONRequest(req, &filter); reqErr != nil {
+ return *reqErr
+ }
+
+ filterArray, err := json.Marshal(filter)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("Filter is malformed"),
+ }
+ }
+
+ filterID, err := accountDB.PutFilter(req.Context(), localpart, filterArray)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: filterResponse{FilterID: filterID},
+ }
+}
diff --git a/clientapi/routing/joinroom.go b/clientapi/routing/joinroom.go
new file mode 100644
index 00000000..c98688de
--- /dev/null
+++ b/clientapi/routing/joinroom.go
@@ -0,0 +1,333 @@
+// 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 routing
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/producers"
+ "github.com/matrix-org/dendrite/common"
+ "github.com/matrix-org/dendrite/common/config"
+ roomserverAPI "github.com/matrix-org/dendrite/roomserver/api"
+ "github.com/matrix-org/gomatrix"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+)
+
+// JoinRoomByIDOrAlias implements the "/join/{roomIDOrAlias}" API.
+// https://matrix.org/docs/spec/client_server/r0.2.0.html#post-matrix-client-r0-join-roomidoralias
+func JoinRoomByIDOrAlias(
+ req *http.Request,
+ device *authtypes.Device,
+ roomIDOrAlias string,
+ cfg config.Dendrite,
+ federation *gomatrixserverlib.FederationClient,
+ producer *producers.RoomserverProducer,
+ queryAPI roomserverAPI.RoomserverQueryAPI,
+ aliasAPI roomserverAPI.RoomserverAliasAPI,
+ keyRing gomatrixserverlib.KeyRing,
+ accountDB *accounts.Database,
+) util.JSONResponse {
+ var content map[string]interface{} // must be a JSON object
+ if resErr := httputil.UnmarshalJSONRequest(req, &content); resErr != nil {
+ return *resErr
+ }
+
+ evTime, err := httputil.ParseTSParam(req)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.InvalidArgumentValue(err.Error()),
+ }
+ }
+
+ localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ profile, err := accountDB.GetProfileByLocalpart(req.Context(), localpart)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ content["membership"] = "join"
+ content["displayname"] = profile.DisplayName
+ content["avatar_url"] = profile.AvatarURL
+
+ r := joinRoomReq{
+ req, evTime, content, device.UserID, cfg, federation, producer, queryAPI, aliasAPI, keyRing,
+ }
+
+ if strings.HasPrefix(roomIDOrAlias, "!") {
+ return r.joinRoomByID(roomIDOrAlias)
+ }
+ if strings.HasPrefix(roomIDOrAlias, "#") {
+ return r.joinRoomByAlias(roomIDOrAlias)
+ }
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("Invalid first character for room ID or alias"),
+ }
+}
+
+type joinRoomReq struct {
+ req *http.Request
+ evTime time.Time
+ content map[string]interface{}
+ userID string
+ cfg config.Dendrite
+ federation *gomatrixserverlib.FederationClient
+ producer *producers.RoomserverProducer
+ queryAPI roomserverAPI.RoomserverQueryAPI
+ aliasAPI roomserverAPI.RoomserverAliasAPI
+ keyRing gomatrixserverlib.KeyRing
+}
+
+// joinRoomByID joins a room by room ID
+func (r joinRoomReq) joinRoomByID(roomID string) util.JSONResponse {
+ // A client should only join a room by room ID when it has an invite
+ // to the room. If the server is already in the room then we can
+ // lookup the invite and process the request as a normal state event.
+ // If the server is not in the room the we will need to look up the
+ // remote server the invite came from in order to request a join event
+ // from that server.
+ queryReq := roomserverAPI.QueryInvitesForUserRequest{
+ RoomID: roomID, TargetUserID: r.userID,
+ }
+ var queryRes roomserverAPI.QueryInvitesForUserResponse
+ if err := r.queryAPI.QueryInvitesForUser(r.req.Context(), &queryReq, &queryRes); err != nil {
+ return httputil.LogThenError(r.req, err)
+ }
+
+ servers := []gomatrixserverlib.ServerName{}
+ seenInInviterIDs := map[gomatrixserverlib.ServerName]bool{}
+ for _, userID := range queryRes.InviteSenderUserIDs {
+ _, domain, err := gomatrixserverlib.SplitID('@', userID)
+ if err != nil {
+ return httputil.LogThenError(r.req, err)
+ }
+ if !seenInInviterIDs[domain] {
+ servers = append(servers, domain)
+ seenInInviterIDs[domain] = true
+ }
+ }
+
+ // Also add the domain extracted from the roomID as a last resort to join
+ // in case the client is erroneously trying to join by ID without an invite
+ // or all previous attempts at domains extracted from the inviter IDs fail
+ // Note: It's no guarantee we'll succeed because a room isn't bound to the domain in its ID
+ _, domain, err := gomatrixserverlib.SplitID('!', roomID)
+ if err != nil {
+ return httputil.LogThenError(r.req, err)
+ }
+ if domain != r.cfg.Matrix.ServerName && !seenInInviterIDs[domain] {
+ servers = append(servers, domain)
+ }
+
+ return r.joinRoomUsingServers(roomID, servers)
+
+}
+
+// joinRoomByAlias joins a room using a room alias.
+func (r joinRoomReq) joinRoomByAlias(roomAlias string) util.JSONResponse {
+ _, domain, err := gomatrixserverlib.SplitID('#', roomAlias)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("Room alias must be in the form '#localpart:domain'"),
+ }
+ }
+ if domain == r.cfg.Matrix.ServerName {
+ queryReq := roomserverAPI.GetRoomIDForAliasRequest{Alias: roomAlias}
+ var queryRes roomserverAPI.GetRoomIDForAliasResponse
+ if err = r.aliasAPI.GetRoomIDForAlias(r.req.Context(), &queryReq, &queryRes); err != nil {
+ return httputil.LogThenError(r.req, err)
+ }
+
+ if len(queryRes.RoomID) > 0 {
+ return r.joinRoomUsingServers(queryRes.RoomID, []gomatrixserverlib.ServerName{r.cfg.Matrix.ServerName})
+ }
+ // If the response doesn't contain a non-empty string, return an error
+ return util.JSONResponse{
+ Code: http.StatusNotFound,
+ JSON: jsonerror.NotFound("Room alias " + roomAlias + " not found."),
+ }
+ }
+ // If the room isn't local, use federation to join
+ return r.joinRoomByRemoteAlias(domain, roomAlias)
+}
+
+func (r joinRoomReq) joinRoomByRemoteAlias(
+ domain gomatrixserverlib.ServerName, roomAlias string,
+) util.JSONResponse {
+ resp, err := r.federation.LookupRoomAlias(r.req.Context(), domain, roomAlias)
+ if err != nil {
+ switch x := err.(type) {
+ case gomatrix.HTTPError:
+ if x.Code == http.StatusNotFound {
+ return util.JSONResponse{
+ Code: http.StatusNotFound,
+ JSON: jsonerror.NotFound("Room alias not found"),
+ }
+ }
+ }
+ return httputil.LogThenError(r.req, err)
+ }
+
+ return r.joinRoomUsingServers(resp.RoomID, resp.Servers)
+}
+
+func (r joinRoomReq) writeToBuilder(eb *gomatrixserverlib.EventBuilder, roomID string) error {
+ eb.Type = "m.room.member"
+
+ err := eb.SetContent(r.content)
+ if err != nil {
+ return err
+ }
+
+ err = eb.SetUnsigned(struct{}{})
+ if err != nil {
+ return err
+ }
+
+ eb.Sender = r.userID
+ eb.StateKey = &r.userID
+ eb.RoomID = roomID
+ eb.Redacts = ""
+
+ return nil
+}
+
+func (r joinRoomReq) joinRoomUsingServers(
+ roomID string, servers []gomatrixserverlib.ServerName,
+) util.JSONResponse {
+ var eb gomatrixserverlib.EventBuilder
+ err := r.writeToBuilder(&eb, roomID)
+ if err != nil {
+ return httputil.LogThenError(r.req, err)
+ }
+
+ var queryRes roomserverAPI.QueryLatestEventsAndStateResponse
+ event, err := common.BuildEvent(r.req.Context(), &eb, r.cfg, r.evTime, r.queryAPI, &queryRes)
+ if err == nil {
+ if _, err = r.producer.SendEvents(r.req.Context(), []gomatrixserverlib.Event{*event}, r.cfg.Matrix.ServerName, nil); err != nil {
+ return httputil.LogThenError(r.req, err)
+ }
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct {
+ RoomID string `json:"room_id"`
+ }{roomID},
+ }
+ }
+ if err != common.ErrRoomNoExists {
+ return httputil.LogThenError(r.req, err)
+ }
+
+ if len(servers) == 0 {
+ return util.JSONResponse{
+ Code: http.StatusNotFound,
+ JSON: jsonerror.NotFound("No candidate servers found for room"),
+ }
+ }
+
+ var lastErr error
+ for _, server := range servers {
+ var response *util.JSONResponse
+ response, lastErr = r.joinRoomUsingServer(roomID, server)
+ if lastErr != nil {
+ // There was a problem talking to one of the servers.
+ util.GetLogger(r.req.Context()).WithError(lastErr).WithField("server", server).Warn("Failed to join room using server")
+ // Try the next server.
+ continue
+ }
+ return *response
+ }
+
+ // Every server we tried to join through resulted in an error.
+ // We return the error from the last server.
+
+ // TODO: Generate the correct HTTP status code for all different
+ // kinds of errors that could have happened.
+ // The possible errors include:
+ // 1) We can't connect to the remote servers.
+ // 2) None of the servers we could connect to think we are allowed
+ // to join the room.
+ // 3) The remote server returned something invalid.
+ // 4) We couldn't fetch the public keys needed to verify the
+ // signatures on the state events.
+ // 5) ...
+ return httputil.LogThenError(r.req, lastErr)
+}
+
+// joinRoomUsingServer tries to join a remote room using a given matrix server.
+// If there was a failure communicating with the server or the response from the
+// server was invalid this returns an error.
+// Otherwise this returns a JSONResponse.
+func (r joinRoomReq) joinRoomUsingServer(roomID string, server gomatrixserverlib.ServerName) (*util.JSONResponse, error) {
+ respMakeJoin, err := r.federation.MakeJoin(r.req.Context(), server, roomID, r.userID)
+ if err != nil {
+ // TODO: Check if the user was not allowed to join the room.
+ return nil, err
+ }
+
+ // Set all the fields to be what they should be, this should be a no-op
+ // but it's possible that the remote server returned us something "odd"
+ err = r.writeToBuilder(&respMakeJoin.JoinEvent, roomID)
+ if err != nil {
+ return nil, err
+ }
+
+ eventID := fmt.Sprintf("$%s:%s", util.RandomString(16), r.cfg.Matrix.ServerName)
+ event, err := respMakeJoin.JoinEvent.Build(
+ eventID, r.evTime, r.cfg.Matrix.ServerName, r.cfg.Matrix.KeyID, r.cfg.Matrix.PrivateKey,
+ )
+ if err != nil {
+ res := httputil.LogThenError(r.req, err)
+ return &res, nil
+ }
+
+ respSendJoin, err := r.federation.SendJoin(r.req.Context(), server, event)
+ if err != nil {
+ return nil, err
+ }
+
+ if err = respSendJoin.Check(r.req.Context(), r.keyRing, event); err != nil {
+ return nil, err
+ }
+
+ if err = r.producer.SendEventWithState(
+ r.req.Context(), gomatrixserverlib.RespState(respSendJoin), event,
+ ); err != nil {
+ res := httputil.LogThenError(r.req, err)
+ return &res, nil
+ }
+
+ return &util.JSONResponse{
+ Code: http.StatusOK,
+ // TODO: Put the response struct somewhere common.
+ JSON: struct {
+ RoomID string `json:"room_id"`
+ }{roomID},
+ }, nil
+}
diff --git a/clientapi/routing/login.go b/clientapi/routing/login.go
new file mode 100644
index 00000000..cb221880
--- /dev/null
+++ b/clientapi/routing/login.go
@@ -0,0 +1,152 @@
+// 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 routing
+
+import (
+ "net/http"
+
+ "context"
+ "database/sql"
+ "github.com/matrix-org/dendrite/clientapi/auth"
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/devices"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/userutil"
+ "github.com/matrix-org/dendrite/common/config"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+)
+
+type loginFlows struct {
+ Flows []flow `json:"flows"`
+}
+
+type flow struct {
+ Type string `json:"type"`
+ Stages []string `json:"stages"`
+}
+
+type passwordRequest struct {
+ User string `json:"user"`
+ Password string `json:"password"`
+ InitialDisplayName *string `json:"initial_device_display_name"`
+ DeviceID string `json:"device_id"`
+}
+
+type loginResponse struct {
+ UserID string `json:"user_id"`
+ AccessToken string `json:"access_token"`
+ HomeServer gomatrixserverlib.ServerName `json:"home_server"`
+ DeviceID string `json:"device_id"`
+}
+
+func passwordLogin() loginFlows {
+ f := loginFlows{}
+ s := flow{"m.login.password", []string{"m.login.password"}}
+ f.Flows = append(f.Flows, s)
+ return f
+}
+
+// Login implements GET and POST /login
+func Login(
+ req *http.Request, accountDB *accounts.Database, deviceDB *devices.Database,
+ cfg config.Dendrite,
+) util.JSONResponse {
+ if req.Method == http.MethodGet { // TODO: support other forms of login other than password, depending on config options
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: passwordLogin(),
+ }
+ } else if req.Method == http.MethodPost {
+ var r passwordRequest
+ resErr := httputil.UnmarshalJSONRequest(req, &r)
+ if resErr != nil {
+ return *resErr
+ }
+ if r.User == "" {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("'user' must be supplied."),
+ }
+ }
+
+ util.GetLogger(req.Context()).WithField("user", r.User).Info("Processing login request")
+
+ localpart, err := userutil.ParseUsernameParam(r.User, &cfg.Matrix.ServerName)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.InvalidUsername(err.Error()),
+ }
+ }
+
+ acc, err := accountDB.GetAccountByPassword(req.Context(), localpart, r.Password)
+ if err != nil {
+ // Technically we could tell them if the user does not exist by checking if err == sql.ErrNoRows
+ // but that would leak the existence of the user.
+ return util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("username or password was incorrect, or the account does not exist"),
+ }
+ }
+
+ token, err := auth.GenerateAccessToken()
+ if err != nil {
+ httputil.LogThenError(req, err)
+ }
+
+ dev, err := getDevice(req.Context(), r, deviceDB, acc, localpart, token)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusInternalServerError,
+ JSON: jsonerror.Unknown("failed to create device: " + err.Error()),
+ }
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: loginResponse{
+ UserID: dev.UserID,
+ AccessToken: dev.AccessToken,
+ HomeServer: cfg.Matrix.ServerName,
+ DeviceID: dev.ID,
+ },
+ }
+ }
+ return util.JSONResponse{
+ Code: http.StatusMethodNotAllowed,
+ JSON: jsonerror.NotFound("Bad method"),
+ }
+}
+
+// check if device exists else create one
+func getDevice(
+ ctx context.Context,
+ r passwordRequest,
+ deviceDB *devices.Database,
+ acc *authtypes.Account,
+ localpart, token string,
+) (dev *authtypes.Device, err error) {
+ dev, err = deviceDB.GetDeviceByID(ctx, localpart, r.DeviceID)
+ if err == sql.ErrNoRows {
+ // device doesn't exist, create one
+ dev, err = deviceDB.CreateDevice(
+ ctx, acc.Localpart, nil, token, r.InitialDisplayName,
+ )
+ }
+ return
+}
diff --git a/clientapi/routing/logout.go b/clientapi/routing/logout.go
new file mode 100644
index 00000000..d2013853
--- /dev/null
+++ b/clientapi/routing/logout.go
@@ -0,0 +1,71 @@
+// 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 routing
+
+import (
+ "net/http"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/devices"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+)
+
+// Logout handles POST /logout
+func Logout(
+ req *http.Request, deviceDB *devices.Database, device *authtypes.Device,
+) util.JSONResponse {
+ if req.Method != http.MethodPost {
+ return util.JSONResponse{
+ Code: http.StatusMethodNotAllowed,
+ JSON: jsonerror.NotFound("Bad method"),
+ }
+ }
+
+ localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if err := deviceDB.RemoveDevice(req.Context(), device.ID, localpart); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+}
+
+// LogoutAll handles POST /logout/all
+func LogoutAll(
+ req *http.Request, deviceDB *devices.Database, device *authtypes.Device,
+) util.JSONResponse {
+ localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if err := deviceDB.RemoveAllDevices(req.Context(), localpart); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+}
diff --git a/clientapi/routing/membership.go b/clientapi/routing/membership.go
new file mode 100644
index 00000000..b308de79
--- /dev/null
+++ b/clientapi/routing/membership.go
@@ -0,0 +1,217 @@
+// 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 routing
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "time"
+
+ appserviceAPI "github.com/matrix-org/dendrite/appservice/api"
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/producers"
+ "github.com/matrix-org/dendrite/clientapi/threepid"
+ "github.com/matrix-org/dendrite/common"
+ "github.com/matrix-org/dendrite/common/config"
+ roomserverAPI "github.com/matrix-org/dendrite/roomserver/api"
+ "github.com/matrix-org/gomatrixserverlib"
+
+ "github.com/matrix-org/util"
+)
+
+var errMissingUserID = errors.New("'user_id' must be supplied")
+
+// SendMembership implements PUT /rooms/{roomID}/(join|kick|ban|unban|leave|invite)
+// by building a m.room.member event then sending it to the room server
+func SendMembership(
+ req *http.Request, accountDB *accounts.Database, device *authtypes.Device,
+ roomID string, membership string, cfg config.Dendrite,
+ queryAPI roomserverAPI.RoomserverQueryAPI, asAPI appserviceAPI.AppServiceQueryAPI,
+ producer *producers.RoomserverProducer,
+) util.JSONResponse {
+ var body threepid.MembershipRequest
+ if reqErr := httputil.UnmarshalJSONRequest(req, &body); reqErr != nil {
+ return *reqErr
+ }
+
+ evTime, err := httputil.ParseTSParam(req)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.InvalidArgumentValue(err.Error()),
+ }
+ }
+
+ inviteStored, err := threepid.CheckAndProcessInvite(
+ req.Context(), device, &body, cfg, queryAPI, accountDB, producer,
+ membership, roomID, evTime,
+ )
+ if err == threepid.ErrMissingParameter {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON(err.Error()),
+ }
+ } else if err == threepid.ErrNotTrusted {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.NotTrusted(body.IDServer),
+ }
+ } else if err == common.ErrRoomNoExists {
+ return util.JSONResponse{
+ Code: http.StatusNotFound,
+ JSON: jsonerror.NotFound(err.Error()),
+ }
+ } else if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ // If an invite has been stored on an identity server, it means that a
+ // m.room.third_party_invite event has been emitted and that we shouldn't
+ // emit a m.room.member one.
+ if inviteStored {
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+ }
+
+ event, err := buildMembershipEvent(
+ req.Context(), body, accountDB, device, membership, roomID, cfg, evTime, queryAPI, asAPI,
+ )
+ if err == errMissingUserID {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON(err.Error()),
+ }
+ } else if err == common.ErrRoomNoExists {
+ return util.JSONResponse{
+ Code: http.StatusNotFound,
+ JSON: jsonerror.NotFound(err.Error()),
+ }
+ } else if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if _, err := producer.SendEvents(
+ req.Context(), []gomatrixserverlib.Event{*event}, cfg.Matrix.ServerName, nil,
+ ); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+}
+
+func buildMembershipEvent(
+ ctx context.Context,
+ body threepid.MembershipRequest, accountDB *accounts.Database,
+ device *authtypes.Device,
+ membership, roomID string,
+ cfg config.Dendrite, evTime time.Time,
+ queryAPI roomserverAPI.RoomserverQueryAPI, asAPI appserviceAPI.AppServiceQueryAPI,
+) (*gomatrixserverlib.Event, error) {
+ stateKey, reason, err := getMembershipStateKey(body, device, membership)
+ if err != nil {
+ return nil, err
+ }
+
+ profile, err := loadProfile(ctx, stateKey, cfg, accountDB, asAPI)
+ if err != nil {
+ return nil, err
+ }
+
+ builder := gomatrixserverlib.EventBuilder{
+ Sender: device.UserID,
+ RoomID: roomID,
+ Type: "m.room.member",
+ StateKey: &stateKey,
+ }
+
+ // "unban" or "kick" isn't a valid membership value, change it to "leave"
+ if membership == "unban" || membership == "kick" {
+ membership = "leave"
+ }
+
+ content := common.MemberContent{
+ Membership: membership,
+ DisplayName: profile.DisplayName,
+ AvatarURL: profile.AvatarURL,
+ Reason: reason,
+ }
+
+ if err = builder.SetContent(content); err != nil {
+ return nil, err
+ }
+
+ return common.BuildEvent(ctx, &builder, cfg, evTime, queryAPI, nil)
+}
+
+// loadProfile lookups the profile of a given user from the database and returns
+// it if the user is local to this server, or returns an empty profile if not.
+// Returns an error if the retrieval failed or if the first parameter isn't a
+// valid Matrix ID.
+func loadProfile(
+ ctx context.Context,
+ userID string,
+ cfg config.Dendrite,
+ accountDB *accounts.Database,
+ asAPI appserviceAPI.AppServiceQueryAPI,
+) (*authtypes.Profile, error) {
+ _, serverName, err := gomatrixserverlib.SplitID('@', userID)
+ if err != nil {
+ return nil, err
+ }
+
+ var profile *authtypes.Profile
+ if serverName == cfg.Matrix.ServerName {
+ profile, err = appserviceAPI.RetreiveUserProfile(ctx, userID, asAPI, accountDB)
+ } else {
+ profile = &authtypes.Profile{}
+ }
+
+ return profile, err
+}
+
+// getMembershipStateKey extracts the target user ID of a membership change.
+// For "join" and "leave" this will be the ID of the user making the change.
+// For "ban", "unban", "kick" and "invite" the target user ID will be in the JSON request body.
+// In the latter case, if there was an issue retrieving the user ID from the request body,
+// returns a JSONResponse with a corresponding error code and message.
+func getMembershipStateKey(
+ body threepid.MembershipRequest, device *authtypes.Device, membership string,
+) (stateKey string, reason string, err error) {
+ if membership == "ban" || membership == "unban" || membership == "kick" || membership == "invite" {
+ // If we're in this case, the state key is contained in the request body,
+ // possibly along with a reason (for "kick" and "ban") so we need to parse
+ // it
+ if body.UserID == "" {
+ err = errMissingUserID
+ return
+ }
+
+ stateKey = body.UserID
+ reason = body.Reason
+ } else {
+ stateKey = device.UserID
+ }
+
+ return
+}
diff --git a/clientapi/routing/memberships.go b/clientapi/routing/memberships.go
new file mode 100644
index 00000000..5b890328
--- /dev/null
+++ b/clientapi/routing/memberships.go
@@ -0,0 +1,60 @@
+// 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 routing
+
+import (
+ "net/http"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/common/config"
+ "github.com/matrix-org/dendrite/roomserver/api"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+)
+
+type response struct {
+ Chunk []gomatrixserverlib.ClientEvent `json:"chunk"`
+}
+
+// GetMemberships implements GET /rooms/{roomId}/members
+func GetMemberships(
+ req *http.Request, device *authtypes.Device, roomID string, joinedOnly bool,
+ _ config.Dendrite,
+ queryAPI api.RoomserverQueryAPI,
+) util.JSONResponse {
+ queryReq := api.QueryMembershipsForRoomRequest{
+ JoinedOnly: joinedOnly,
+ RoomID: roomID,
+ Sender: device.UserID,
+ }
+ var queryRes api.QueryMembershipsForRoomResponse
+ if err := queryAPI.QueryMembershipsForRoom(req.Context(), &queryReq, &queryRes); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if !queryRes.HasBeenInRoom {
+ return util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("You aren't a member of the room and weren't previously a member of the room."),
+ }
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: response{queryRes.JoinEvents},
+ }
+}
diff --git a/clientapi/routing/profile.go b/clientapi/routing/profile.go
new file mode 100644
index 00000000..e57d16fb
--- /dev/null
+++ b/clientapi/routing/profile.go
@@ -0,0 +1,292 @@
+// 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 routing
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ appserviceAPI "github.com/matrix-org/dendrite/appservice/api"
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/producers"
+ "github.com/matrix-org/dendrite/common"
+ "github.com/matrix-org/dendrite/common/config"
+ "github.com/matrix-org/dendrite/roomserver/api"
+ "github.com/matrix-org/gomatrixserverlib"
+
+ "github.com/matrix-org/util"
+)
+
+// GetProfile implements GET /profile/{userID}
+func GetProfile(
+ req *http.Request, accountDB *accounts.Database, userID string, asAPI appserviceAPI.AppServiceQueryAPI,
+) util.JSONResponse {
+ if req.Method != http.MethodGet {
+ return util.JSONResponse{
+ Code: http.StatusMethodNotAllowed,
+ JSON: jsonerror.NotFound("Bad method"),
+ }
+ }
+ profile, err := appserviceAPI.RetreiveUserProfile(req.Context(), userID, asAPI, accountDB)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ res := common.ProfileResponse{
+ AvatarURL: profile.AvatarURL,
+ DisplayName: profile.DisplayName,
+ }
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: res,
+ }
+}
+
+// GetAvatarURL implements GET /profile/{userID}/avatar_url
+func GetAvatarURL(
+ req *http.Request, accountDB *accounts.Database, userID string, asAPI appserviceAPI.AppServiceQueryAPI,
+) util.JSONResponse {
+ profile, err := appserviceAPI.RetreiveUserProfile(req.Context(), userID, asAPI, accountDB)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ res := common.AvatarURL{
+ AvatarURL: profile.AvatarURL,
+ }
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: res,
+ }
+}
+
+// SetAvatarURL implements PUT /profile/{userID}/avatar_url
+func SetAvatarURL(
+ req *http.Request, accountDB *accounts.Database, device *authtypes.Device,
+ userID string, producer *producers.UserUpdateProducer, cfg *config.Dendrite,
+ rsProducer *producers.RoomserverProducer, queryAPI api.RoomserverQueryAPI,
+) util.JSONResponse {
+ if userID != device.UserID {
+ return util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("userID does not match the current user"),
+ }
+ }
+
+ changedKey := "avatar_url"
+
+ var r common.AvatarURL
+ if resErr := httputil.UnmarshalJSONRequest(req, &r); resErr != nil {
+ return *resErr
+ }
+ if r.AvatarURL == "" {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("'avatar_url' must be supplied."),
+ }
+ }
+
+ localpart, _, err := gomatrixserverlib.SplitID('@', userID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ evTime, err := httputil.ParseTSParam(req)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.InvalidArgumentValue(err.Error()),
+ }
+ }
+
+ oldProfile, err := accountDB.GetProfileByLocalpart(req.Context(), localpart)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if err = accountDB.SetAvatarURL(req.Context(), localpart, r.AvatarURL); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ memberships, err := accountDB.GetMembershipsByLocalpart(req.Context(), localpart)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ newProfile := authtypes.Profile{
+ Localpart: localpart,
+ DisplayName: oldProfile.DisplayName,
+ AvatarURL: r.AvatarURL,
+ }
+
+ events, err := buildMembershipEvents(
+ req.Context(), memberships, newProfile, userID, cfg, evTime, queryAPI,
+ )
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if _, err := rsProducer.SendEvents(req.Context(), events, cfg.Matrix.ServerName, nil); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if err := producer.SendUpdate(userID, changedKey, oldProfile.AvatarURL, r.AvatarURL); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+}
+
+// GetDisplayName implements GET /profile/{userID}/displayname
+func GetDisplayName(
+ req *http.Request, accountDB *accounts.Database, userID string, asAPI appserviceAPI.AppServiceQueryAPI,
+) util.JSONResponse {
+ profile, err := appserviceAPI.RetreiveUserProfile(req.Context(), userID, asAPI, accountDB)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+ res := common.DisplayName{
+ DisplayName: profile.DisplayName,
+ }
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: res,
+ }
+}
+
+// SetDisplayName implements PUT /profile/{userID}/displayname
+func SetDisplayName(
+ req *http.Request, accountDB *accounts.Database, device *authtypes.Device,
+ userID string, producer *producers.UserUpdateProducer, cfg *config.Dendrite,
+ rsProducer *producers.RoomserverProducer, queryAPI api.RoomserverQueryAPI,
+) util.JSONResponse {
+ if userID != device.UserID {
+ return util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("userID does not match the current user"),
+ }
+ }
+
+ changedKey := "displayname"
+
+ var r common.DisplayName
+ if resErr := httputil.UnmarshalJSONRequest(req, &r); resErr != nil {
+ return *resErr
+ }
+ if r.DisplayName == "" {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("'displayname' must be supplied."),
+ }
+ }
+
+ localpart, _, err := gomatrixserverlib.SplitID('@', userID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ evTime, err := httputil.ParseTSParam(req)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.InvalidArgumentValue(err.Error()),
+ }
+ }
+
+ oldProfile, err := accountDB.GetProfileByLocalpart(req.Context(), localpart)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if err = accountDB.SetDisplayName(req.Context(), localpart, r.DisplayName); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ memberships, err := accountDB.GetMembershipsByLocalpart(req.Context(), localpart)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ newProfile := authtypes.Profile{
+ Localpart: localpart,
+ DisplayName: r.DisplayName,
+ AvatarURL: oldProfile.AvatarURL,
+ }
+
+ events, err := buildMembershipEvents(
+ req.Context(), memberships, newProfile, userID, cfg, evTime, queryAPI,
+ )
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if _, err := rsProducer.SendEvents(req.Context(), events, cfg.Matrix.ServerName, nil); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if err := producer.SendUpdate(userID, changedKey, oldProfile.DisplayName, r.DisplayName); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+}
+
+func buildMembershipEvents(
+ ctx context.Context,
+ memberships []authtypes.Membership,
+ newProfile authtypes.Profile, userID string, cfg *config.Dendrite,
+ evTime time.Time, queryAPI api.RoomserverQueryAPI,
+) ([]gomatrixserverlib.Event, error) {
+ evs := []gomatrixserverlib.Event{}
+
+ for _, membership := range memberships {
+ builder := gomatrixserverlib.EventBuilder{
+ Sender: userID,
+ RoomID: membership.RoomID,
+ Type: "m.room.member",
+ StateKey: &userID,
+ }
+
+ content := common.MemberContent{
+ Membership: "join",
+ }
+
+ content.DisplayName = newProfile.DisplayName
+ content.AvatarURL = newProfile.AvatarURL
+
+ if err := builder.SetContent(content); err != nil {
+ return nil, err
+ }
+
+ event, err := common.BuildEvent(ctx, &builder, *cfg, evTime, queryAPI, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ evs = append(evs, *event)
+ }
+
+ return evs, nil
+}
diff --git a/clientapi/routing/register.go b/clientapi/routing/register.go
new file mode 100644
index 00000000..b1522e82
--- /dev/null
+++ b/clientapi/routing/register.go
@@ -0,0 +1,958 @@
+// Copyright 2017 Vector Creations Ltd
+// Copyright 2017 New Vector 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 routing
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha1"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/matrix-org/dendrite/common/config"
+
+ "github.com/matrix-org/dendrite/clientapi/auth"
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/devices"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/userutil"
+ "github.com/matrix-org/dendrite/common"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+ "github.com/prometheus/client_golang/prometheus"
+ log "github.com/sirupsen/logrus"
+)
+
+var (
+ // Prometheus metrics
+ amtRegUsers = prometheus.NewCounter(
+ prometheus.CounterOpts{
+ Name: "dendrite_clientapi_reg_users_total",
+ Help: "Total number of registered users",
+ },
+ )
+)
+
+const (
+ minPasswordLength = 8 // http://matrix.org/docs/spec/client_server/r0.2.0.html#password-based
+ maxPasswordLength = 512 // https://github.com/matrix-org/synapse/blob/v0.20.0/synapse/rest/client/v2_alpha/register.py#L161
+ maxUsernameLength = 254 // http://matrix.org/speculator/spec/HEAD/intro.html#user-identifiers TODO account for domain
+ sessionIDLength = 24
+)
+
+func init() {
+ // Register prometheus metrics. They must be registered to be exposed.
+ prometheus.MustRegister(amtRegUsers)
+}
+
+// sessionsDict keeps track of completed auth stages for each session.
+type sessionsDict struct {
+ sessions map[string][]authtypes.LoginType
+}
+
+// GetCompletedStages returns the completed stages for a session.
+func (d sessionsDict) GetCompletedStages(sessionID string) []authtypes.LoginType {
+ if completedStages, ok := d.sessions[sessionID]; ok {
+ return completedStages
+ }
+ // Ensure that a empty slice is returned and not nil. See #399.
+ return make([]authtypes.LoginType, 0)
+}
+
+// AddCompletedStage records that a session has completed an auth stage.
+func (d *sessionsDict) AddCompletedStage(sessionID string, stage authtypes.LoginType) {
+ d.sessions[sessionID] = append(d.GetCompletedStages(sessionID), stage)
+}
+
+func newSessionsDict() *sessionsDict {
+ return &sessionsDict{
+ sessions: make(map[string][]authtypes.LoginType),
+ }
+}
+
+var (
+ // TODO: Remove old sessions. Need to do so on a session-specific timeout.
+ // sessions stores the completed flow stages for all sessions. Referenced using their sessionID.
+ sessions = newSessionsDict()
+ validUsernameRegex = regexp.MustCompile(`^[0-9a-z_\-./]+$`)
+)
+
+// registerRequest represents the submitted registration request.
+// It can be broken down into 2 sections: the auth dictionary and registration parameters.
+// Registration parameters vary depending on the request, and will need to remembered across
+// sessions. If no parameters are supplied, the server should use the parameters previously
+// remembered. If ANY parameters are supplied, the server should REPLACE all knowledge of
+// previous parameters with the ones supplied. This mean you cannot "build up" request params.
+type registerRequest struct {
+ // registration parameters
+ Password string `json:"password"`
+ Username string `json:"username"`
+ Admin bool `json:"admin"`
+ // user-interactive auth params
+ Auth authDict `json:"auth"`
+
+ InitialDisplayName *string `json:"initial_device_display_name"`
+
+ // Prevent this user from logging in
+ InhibitLogin common.WeakBoolean `json:"inhibit_login"`
+
+ // Application Services place Type in the root of their registration
+ // request, whereas clients place it in the authDict struct.
+ Type authtypes.LoginType `json:"type"`
+}
+
+type authDict struct {
+ Type authtypes.LoginType `json:"type"`
+ Session string `json:"session"`
+ Mac gomatrixserverlib.HexString `json:"mac"`
+
+ // Recaptcha
+ Response string `json:"response"`
+ // TODO: Lots of custom keys depending on the type
+}
+
+// http://matrix.org/speculator/spec/HEAD/client_server/unstable.html#user-interactive-authentication-api
+type userInteractiveResponse struct {
+ Flows []authtypes.Flow `json:"flows"`
+ Completed []authtypes.LoginType `json:"completed"`
+ Params map[string]interface{} `json:"params"`
+ Session string `json:"session"`
+}
+
+// legacyRegisterRequest represents the submitted registration request for v1 API.
+type legacyRegisterRequest struct {
+ Password string `json:"password"`
+ Username string `json:"user"`
+ Admin bool `json:"admin"`
+ Type authtypes.LoginType `json:"type"`
+ Mac gomatrixserverlib.HexString `json:"mac"`
+}
+
+// newUserInteractiveResponse will return a struct to be sent back to the client
+// during registration.
+func newUserInteractiveResponse(
+ sessionID string,
+ fs []authtypes.Flow,
+ params map[string]interface{},
+) userInteractiveResponse {
+ return userInteractiveResponse{
+ fs, sessions.GetCompletedStages(sessionID), params, sessionID,
+ }
+}
+
+// http://matrix.org/speculator/spec/HEAD/client_server/unstable.html#post-matrix-client-unstable-register
+type registerResponse struct {
+ UserID string `json:"user_id"`
+ AccessToken string `json:"access_token,omitempty"`
+ HomeServer gomatrixserverlib.ServerName `json:"home_server"`
+ DeviceID string `json:"device_id,omitempty"`
+}
+
+// recaptchaResponse represents the HTTP response from a Google Recaptcha server
+type recaptchaResponse struct {
+ Success bool `json:"success"`
+ ChallengeTS time.Time `json:"challenge_ts"`
+ Hostname string `json:"hostname"`
+ ErrorCodes []int `json:"error-codes"`
+}
+
+// validateUsername returns an error response if the username is invalid
+func validateUsername(username string) *util.JSONResponse {
+ // https://github.com/matrix-org/synapse/blob/v0.20.0/synapse/rest/client/v2_alpha/register.py#L161
+ if len(username) > maxUsernameLength {
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON(fmt.Sprintf("'username' >%d characters", maxUsernameLength)),
+ }
+ } else if !validUsernameRegex.MatchString(username) {
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.InvalidUsername("Username can only contain characters a-z, 0-9, or '_-./'"),
+ }
+ } else if username[0] == '_' { // Regex checks its not a zero length string
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.InvalidUsername("Username cannot start with a '_'"),
+ }
+ }
+ return nil
+}
+
+// validateApplicationServiceUsername returns an error response if the username is invalid for an application service
+func validateApplicationServiceUsername(username string) *util.JSONResponse {
+ if len(username) > maxUsernameLength {
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON(fmt.Sprintf("'username' >%d characters", maxUsernameLength)),
+ }
+ } else if !validUsernameRegex.MatchString(username) {
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.InvalidUsername("Username can only contain characters a-z, 0-9, or '_-./'"),
+ }
+ }
+ return nil
+}
+
+// validatePassword returns an error response if the password is invalid
+func validatePassword(password string) *util.JSONResponse {
+ // https://github.com/matrix-org/synapse/blob/v0.20.0/synapse/rest/client/v2_alpha/register.py#L161
+ if len(password) > maxPasswordLength {
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON(fmt.Sprintf("'password' >%d characters", maxPasswordLength)),
+ }
+ } else if len(password) > 0 && len(password) < minPasswordLength {
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.WeakPassword(fmt.Sprintf("password too weak: min %d chars", minPasswordLength)),
+ }
+ }
+ return nil
+}
+
+// validateRecaptcha returns an error response if the captcha response is invalid
+func validateRecaptcha(
+ cfg *config.Dendrite,
+ response string,
+ clientip string,
+) *util.JSONResponse {
+ if !cfg.Matrix.RecaptchaEnabled {
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("Captcha registration is disabled"),
+ }
+ }
+
+ if response == "" {
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("Captcha response is required"),
+ }
+ }
+
+ // Make a POST request to Google's API to check the captcha response
+ resp, err := http.PostForm(cfg.Matrix.RecaptchaSiteVerifyAPI,
+ url.Values{
+ "secret": {cfg.Matrix.RecaptchaPrivateKey},
+ "response": {response},
+ "remoteip": {clientip},
+ },
+ )
+
+ if err != nil {
+ return &util.JSONResponse{
+ Code: http.StatusInternalServerError,
+ JSON: jsonerror.BadJSON("Error in requesting validation of captcha response"),
+ }
+ }
+
+ // Close the request once we're finishing reading from it
+ defer resp.Body.Close() // nolint: errcheck
+
+ // Grab the body of the response from the captcha server
+ var r recaptchaResponse
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return &util.JSONResponse{
+ Code: http.StatusInternalServerError,
+ JSON: jsonerror.BadJSON("Error in contacting captcha server" + err.Error()),
+ }
+ }
+ err = json.Unmarshal(body, &r)
+ if err != nil {
+ return &util.JSONResponse{
+ Code: http.StatusInternalServerError,
+ JSON: jsonerror.BadJSON("Error in unmarshaling captcha server's response: " + err.Error()),
+ }
+ }
+
+ // Check that we received a "success"
+ if !r.Success {
+ return &util.JSONResponse{
+ Code: http.StatusUnauthorized,
+ JSON: jsonerror.BadJSON("Invalid captcha response. Please try again."),
+ }
+ }
+ return nil
+}
+
+// UserIDIsWithinApplicationServiceNamespace checks to see if a given userID
+// falls within any of the namespaces of a given Application Service. If no
+// Application Service is given, it will check to see if it matches any
+// Application Service's namespace.
+func UserIDIsWithinApplicationServiceNamespace(
+ cfg *config.Dendrite,
+ userID string,
+ appservice *config.ApplicationService,
+) bool {
+ if appservice != nil {
+ // Loop through given application service's namespaces and see if any match
+ for _, namespace := range appservice.NamespaceMap["users"] {
+ // AS namespaces are checked for validity in config
+ if namespace.RegexpObject.MatchString(userID) {
+ return true
+ }
+ }
+ return false
+ }
+
+ // Loop through all known application service's namespaces and see if any match
+ for _, knownAppService := range cfg.Derived.ApplicationServices {
+ for _, namespace := range knownAppService.NamespaceMap["users"] {
+ // AS namespaces are checked for validity in config
+ if namespace.RegexpObject.MatchString(userID) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// UsernameMatchesMultipleExclusiveNamespaces will check if a given username matches
+// more than one exclusive namespace. More than one is not allowed
+func UsernameMatchesMultipleExclusiveNamespaces(
+ cfg *config.Dendrite,
+ username string,
+) bool {
+ userID := userutil.MakeUserID(username, cfg.Matrix.ServerName)
+
+ // Check namespaces and see if more than one match
+ matchCount := 0
+ for _, appservice := range cfg.Derived.ApplicationServices {
+ if appservice.IsInterestedInUserID(userID) {
+ if matchCount++; matchCount > 1 {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// UsernameMatchesExclusiveNamespaces will check if a given username matches any
+// application service's exclusive users namespace
+func UsernameMatchesExclusiveNamespaces(
+ cfg *config.Dendrite,
+ username string,
+) bool {
+ userID := userutil.MakeUserID(username, cfg.Matrix.ServerName)
+ return cfg.Derived.ExclusiveApplicationServicesUsernameRegexp.MatchString(userID)
+}
+
+// validateApplicationService checks if a provided application service token
+// corresponds to one that is registered. If so, then it checks if the desired
+// username is within that application service's namespace. As long as these
+// two requirements are met, no error will be returned.
+func validateApplicationService(
+ cfg *config.Dendrite,
+ username string,
+ accessToken string,
+) (string, *util.JSONResponse) {
+ // Check if the token if the application service is valid with one we have
+ // registered in the config.
+ var matchedApplicationService *config.ApplicationService
+ for _, appservice := range cfg.Derived.ApplicationServices {
+ if appservice.ASToken == accessToken {
+ matchedApplicationService = &appservice
+ break
+ }
+ }
+ if matchedApplicationService == nil {
+ return "", &util.JSONResponse{
+ Code: http.StatusUnauthorized,
+ JSON: jsonerror.UnknownToken("Supplied access_token does not match any known application service"),
+ }
+ }
+
+ userID := userutil.MakeUserID(username, cfg.Matrix.ServerName)
+
+ // Ensure the desired username is within at least one of the application service's namespaces.
+ if !UserIDIsWithinApplicationServiceNamespace(cfg, userID, matchedApplicationService) {
+ // If we didn't find any matches, return M_EXCLUSIVE
+ return "", &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.ASExclusive(fmt.Sprintf(
+ "Supplied username %s did not match any namespaces for application service ID: %s", username, matchedApplicationService.ID)),
+ }
+ }
+
+ // Check this user does not fit multiple application service namespaces
+ if UsernameMatchesMultipleExclusiveNamespaces(cfg, userID) {
+ return "", &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.ASExclusive(fmt.Sprintf(
+ "Supplied username %s matches multiple exclusive application service namespaces. Only 1 match allowed", username)),
+ }
+ }
+
+ // Check username application service is trying to register is valid
+ if err := validateApplicationServiceUsername(username); err != nil {
+ return "", err
+ }
+
+ // No errors, registration valid
+ return matchedApplicationService.ID, nil
+}
+
+// Register processes a /register request.
+// http://matrix.org/speculator/spec/HEAD/client_server/unstable.html#post-matrix-client-unstable-register
+func Register(
+ req *http.Request,
+ accountDB *accounts.Database,
+ deviceDB *devices.Database,
+ cfg *config.Dendrite,
+) util.JSONResponse {
+
+ var r registerRequest
+ resErr := httputil.UnmarshalJSONRequest(req, &r)
+ if resErr != nil {
+ return *resErr
+ }
+
+ // Retrieve or generate the sessionID
+ sessionID := r.Auth.Session
+ if sessionID == "" {
+ // Generate a new, random session ID
+ sessionID = util.RandomString(sessionIDLength)
+ }
+
+ // Don't allow numeric usernames less than MAX_INT64.
+ if _, err := strconv.ParseInt(r.Username, 10, 64); err == nil {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.InvalidUsername("Numeric user IDs are reserved"),
+ }
+ }
+ // Auto generate a numeric username if r.Username is empty
+ if r.Username == "" {
+ id, err := accountDB.GetNewNumericLocalpart(req.Context())
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ r.Username = strconv.FormatInt(id, 10)
+ }
+
+ // Squash username to all lowercase letters
+ r.Username = strings.ToLower(r.Username)
+
+ if resErr = validateUsername(r.Username); resErr != nil {
+ return *resErr
+ }
+ if resErr = validatePassword(r.Password); resErr != nil {
+ return *resErr
+ }
+
+ // Make sure normal user isn't registering under an exclusive application
+ // service namespace. Skip this check if no app services are registered.
+ if r.Auth.Type != authtypes.LoginTypeApplicationService &&
+ len(cfg.Derived.ApplicationServices) != 0 &&
+ UsernameMatchesExclusiveNamespaces(cfg, r.Username) {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.ASExclusive("This username is reserved by an application service."),
+ }
+ }
+
+ logger := util.GetLogger(req.Context())
+ logger.WithFields(log.Fields{
+ "username": r.Username,
+ "auth.type": r.Auth.Type,
+ "session_id": r.Auth.Session,
+ }).Info("Processing registration request")
+
+ return handleRegistrationFlow(req, r, sessionID, cfg, accountDB, deviceDB)
+}
+
+// handleRegistrationFlow will direct and complete registration flow stages
+// that the client has requested.
+// nolint: gocyclo
+func handleRegistrationFlow(
+ req *http.Request,
+ r registerRequest,
+ sessionID string,
+ cfg *config.Dendrite,
+ accountDB *accounts.Database,
+ 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.
+ // TODO: Handle mapping registrationRequest parameters into session parameters
+
+ // TODO: email / msisdn auth types.
+
+ if cfg.Matrix.RegistrationDisabled && r.Auth.Type != authtypes.LoginTypeSharedSecret {
+ return util.MessageResponse(http.StatusForbidden, "Registration has been disabled")
+ }
+
+ switch r.Auth.Type {
+ case authtypes.LoginTypeRecaptcha:
+ // Check given captcha response
+ resErr := validateRecaptcha(cfg, r.Auth.Response, req.RemoteAddr)
+ if resErr != nil {
+ return *resErr
+ }
+
+ // Add Recaptcha to the list of completed registration stages
+ sessions.AddCompletedStage(sessionID, authtypes.LoginTypeRecaptcha)
+
+ case authtypes.LoginTypeSharedSecret:
+ // Check shared secret against config
+ valid, err := isValidMacLogin(cfg, r.Username, r.Password, r.Admin, r.Auth.Mac)
+
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ } else if !valid {
+ return util.MessageResponse(http.StatusForbidden, "HMAC incorrect")
+ }
+
+ // Add SharedSecret to the list of completed registration stages
+ sessions.AddCompletedStage(sessionID, authtypes.LoginTypeSharedSecret)
+
+ case "":
+ // Extract the access token from the request, if there's one to extract
+ // (which we can know by checking whether the error is nil or not).
+ accessToken, err := auth.ExtractAccessToken(req)
+
+ // A missing auth type can mean either the registration is performed by
+ // an AS or the request is made as the first step of a registration
+ // using the User-Interactive Authentication API. This can be determined
+ // by whether the request contains an access token.
+ if err == nil {
+ return handleApplicationServiceRegistration(
+ accessToken, err, req, r, cfg, accountDB, deviceDB,
+ )
+ }
+
+ case authtypes.LoginTypeApplicationService:
+ // Extract the access token from the request.
+ accessToken, err := auth.ExtractAccessToken(req)
+ // Let the AS registration handler handle the process from here. We
+ // don't need a condition on that call since the registration is clearly
+ // stated as being AS-related.
+ return handleApplicationServiceRegistration(
+ accessToken, err, req, r, cfg, accountDB, deviceDB,
+ )
+
+ case authtypes.LoginTypeDummy:
+ // there is nothing to do
+ // Add Dummy to the list of completed registration stages
+ sessions.AddCompletedStage(sessionID, authtypes.LoginTypeDummy)
+
+ default:
+ return util.JSONResponse{
+ Code: http.StatusNotImplemented,
+ JSON: jsonerror.Unknown("unknown/unimplemented auth type"),
+ }
+ }
+
+ // Check if the user's registration flow has been completed successfully
+ // A response with current registration flow and remaining available methods
+ // will be returned if a flow has not been successfully completed yet
+ return checkAndCompleteFlow(sessions.GetCompletedStages(sessionID),
+ req, r, sessionID, cfg, accountDB, deviceDB)
+}
+
+// handleApplicationServiceRegistration handles the registration of an
+// application service's user by validating the AS from its access token and
+// registering the user. Its two first parameters must be the two return values
+// of the auth.ExtractAccessToken function.
+// Returns an error if the access token couldn't be extracted from the request
+// at an earlier step of the registration workflow, or if the provided access
+// token doesn't belong to a valid AS, or if there was an issue completing the
+// registration process.
+func handleApplicationServiceRegistration(
+ accessToken string,
+ tokenErr error,
+ req *http.Request,
+ r registerRequest,
+ cfg *config.Dendrite,
+ accountDB *accounts.Database,
+ deviceDB *devices.Database,
+) util.JSONResponse {
+ // Check if we previously had issues extracting the access token from the
+ // request.
+ if tokenErr != nil {
+ return util.JSONResponse{
+ Code: http.StatusUnauthorized,
+ JSON: jsonerror.MissingToken(tokenErr.Error()),
+ }
+ }
+
+ // Check application service register user request is valid.
+ // The application service's ID is returned if so.
+ appserviceID, err := validateApplicationService(
+ cfg, r.Username, accessToken,
+ )
+ if err != nil {
+ return *err
+ }
+
+ // If no error, application service was successfully validated.
+ // Don't need to worry about appending to registration stages as
+ // application service registration is entirely separate.
+ return completeRegistration(
+ req.Context(), accountDB, deviceDB, r.Username, "", appserviceID,
+ r.InhibitLogin, r.InitialDisplayName,
+ )
+}
+
+// checkAndCompleteFlow checks if a given registration flow is completed given
+// a set of allowed flows. If so, registration is completed, otherwise a
+// response with
+func checkAndCompleteFlow(
+ flow []authtypes.LoginType,
+ req *http.Request,
+ r registerRequest,
+ sessionID string,
+ cfg *config.Dendrite,
+ accountDB *accounts.Database,
+ deviceDB *devices.Database,
+) util.JSONResponse {
+ if checkFlowCompleted(flow, cfg.Derived.Registration.Flows) {
+ // This flow was completed, registration can continue
+ return completeRegistration(
+ req.Context(), accountDB, deviceDB, r.Username, r.Password, "",
+ r.InhibitLogin, r.InitialDisplayName,
+ )
+ }
+
+ // There are still more stages to complete.
+ // Return the flows and those that have been completed.
+ return util.JSONResponse{
+ Code: http.StatusUnauthorized,
+ JSON: newUserInteractiveResponse(sessionID,
+ cfg.Derived.Registration.Flows, cfg.Derived.Registration.Params),
+ }
+}
+
+// LegacyRegister process register requests from the legacy v1 API
+func LegacyRegister(
+ req *http.Request,
+ accountDB *accounts.Database,
+ deviceDB *devices.Database,
+ cfg *config.Dendrite,
+) util.JSONResponse {
+ var r legacyRegisterRequest
+ resErr := parseAndValidateLegacyLogin(req, &r)
+ if resErr != nil {
+ return *resErr
+ }
+
+ logger := util.GetLogger(req.Context())
+ logger.WithFields(log.Fields{
+ "username": r.Username,
+ "auth.type": r.Type,
+ }).Info("Processing registration request")
+
+ if cfg.Matrix.RegistrationDisabled && r.Type != authtypes.LoginTypeSharedSecret {
+ return util.MessageResponse(http.StatusForbidden, "Registration has been disabled")
+ }
+
+ switch r.Type {
+ case authtypes.LoginTypeSharedSecret:
+ if cfg.Matrix.RegistrationSharedSecret == "" {
+ return util.MessageResponse(http.StatusBadRequest, "Shared secret registration is disabled")
+ }
+
+ valid, err := isValidMacLogin(cfg, r.Username, r.Password, r.Admin, r.Mac)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if !valid {
+ return util.MessageResponse(http.StatusForbidden, "HMAC incorrect")
+ }
+
+ return completeRegistration(req.Context(), accountDB, deviceDB, r.Username, r.Password, "", false, nil)
+ case authtypes.LoginTypeDummy:
+ // there is nothing to do
+ return completeRegistration(req.Context(), accountDB, deviceDB, r.Username, r.Password, "", false, nil)
+ default:
+ return util.JSONResponse{
+ Code: http.StatusNotImplemented,
+ JSON: jsonerror.Unknown("unknown/unimplemented auth type"),
+ }
+ }
+}
+
+// parseAndValidateLegacyLogin parses the request into r and checks that the
+// request is valid (e.g. valid user names, etc)
+func parseAndValidateLegacyLogin(req *http.Request, r *legacyRegisterRequest) *util.JSONResponse {
+ resErr := httputil.UnmarshalJSONRequest(req, &r)
+ if resErr != nil {
+ return resErr
+ }
+
+ // Squash username to all lowercase letters
+ r.Username = strings.ToLower(r.Username)
+
+ if resErr = validateUsername(r.Username); resErr != nil {
+ return resErr
+ }
+ if resErr = validatePassword(r.Password); resErr != nil {
+ return resErr
+ }
+
+ // All registration requests must specify what auth they are using to perform this request
+ if r.Type == "" {
+ return &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("invalid type"),
+ }
+ }
+
+ return nil
+}
+
+func completeRegistration(
+ ctx context.Context,
+ accountDB *accounts.Database,
+ deviceDB *devices.Database,
+ username, password, appserviceID string,
+ inhibitLogin common.WeakBoolean,
+ displayName *string,
+) util.JSONResponse {
+ if username == "" {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("missing username"),
+ }
+ }
+ // Blank passwords are only allowed by registered application services
+ if password == "" && appserviceID == "" {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("missing password"),
+ }
+ }
+
+ acc, err := accountDB.CreateAccount(ctx, username, password, appserviceID)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusInternalServerError,
+ JSON: jsonerror.Unknown("failed to create account: " + err.Error()),
+ }
+ } else if acc == nil {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.UserInUse("Desired user ID is already taken."),
+ }
+ }
+
+ // Check whether inhibit_login option is set. If so, don't create an access
+ // token or a device for this user
+ if inhibitLogin {
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: registerResponse{
+ UserID: userutil.MakeUserID(username, acc.ServerName),
+ HomeServer: acc.ServerName,
+ },
+ }
+ }
+
+ token, err := auth.GenerateAccessToken()
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusInternalServerError,
+ JSON: jsonerror.Unknown("Failed to generate access token"),
+ }
+ }
+
+ // TODO: Use the device ID in the request.
+ dev, err := deviceDB.CreateDevice(ctx, username, nil, token, displayName)
+ if err != nil {
+ return util.JSONResponse{
+ Code: http.StatusInternalServerError,
+ JSON: jsonerror.Unknown("failed to create device: " + err.Error()),
+ }
+ }
+
+ // Increment prometheus counter for created users
+ amtRegUsers.Inc()
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: registerResponse{
+ UserID: dev.UserID,
+ AccessToken: dev.AccessToken,
+ HomeServer: acc.ServerName,
+ DeviceID: dev.ID,
+ },
+ }
+}
+
+// Used for shared secret registration.
+// Checks if the username, password and isAdmin flag matches the given mac.
+func isValidMacLogin(
+ cfg *config.Dendrite,
+ username, password string,
+ isAdmin bool,
+ givenMac []byte,
+) (bool, error) {
+ sharedSecret := cfg.Matrix.RegistrationSharedSecret
+
+ // Check that shared secret registration isn't disabled.
+ if cfg.Matrix.RegistrationSharedSecret == "" {
+ return false, errors.New("Shared secret registration is disabled")
+ }
+
+ // Double check that username/password don't contain the HMAC delimiters. We should have
+ // already checked this.
+ if strings.Contains(username, "\x00") {
+ return false, errors.New("Username contains invalid character")
+ }
+ if strings.Contains(password, "\x00") {
+ return false, errors.New("Password contains invalid character")
+ }
+ if sharedSecret == "" {
+ return false, errors.New("Shared secret registration is disabled")
+ }
+
+ adminString := "notadmin"
+ if isAdmin {
+ adminString = "admin"
+ }
+ joined := strings.Join([]string{username, password, adminString}, "\x00")
+
+ mac := hmac.New(sha1.New, []byte(sharedSecret))
+ _, err := mac.Write([]byte(joined))
+ if err != nil {
+ return false, err
+ }
+ expectedMAC := mac.Sum(nil)
+
+ return hmac.Equal(givenMac, expectedMAC), nil
+}
+
+// checkFlows checks a single completed flow against another required one. If
+// one contains at least all of the stages that the other does, checkFlows
+// returns true.
+func checkFlows(
+ completedStages []authtypes.LoginType,
+ requiredStages []authtypes.LoginType,
+) bool {
+ // Create temporary slices so they originals will not be modified on sorting
+ completed := make([]authtypes.LoginType, len(completedStages))
+ required := make([]authtypes.LoginType, len(requiredStages))
+ copy(completed, completedStages)
+ copy(required, requiredStages)
+
+ // Sort the slices for simple comparison
+ sort.Slice(completed, func(i, j int) bool { return completed[i] < completed[j] })
+ sort.Slice(required, func(i, j int) bool { return required[i] < required[j] })
+
+ // Iterate through each slice, going to the next required slice only once
+ // we've found a match.
+ i, j := 0, 0
+ for j < len(required) {
+ // Exit if we've reached the end of our input without being able to
+ // match all of the required stages.
+ if i >= len(completed) {
+ return false
+ }
+
+ // If we've found a stage we want, move on to the next required stage.
+ if completed[i] == required[j] {
+ j++
+ }
+ i++
+ }
+ return true
+}
+
+// checkFlowCompleted checks if a registration flow complies with any allowed flow
+// dictated by the server. Order of stages does not matter. A user may complete
+// extra stages as long as the required stages of at least one flow is met.
+func checkFlowCompleted(
+ flow []authtypes.LoginType,
+ allowedFlows []authtypes.Flow,
+) bool {
+ // Iterate through possible flows to check whether any have been fully completed.
+ for _, allowedFlow := range allowedFlows {
+ if checkFlows(flow, allowedFlow.Stages) {
+ return true
+ }
+ }
+ return false
+}
+
+type availableResponse struct {
+ Available bool `json:"available"`
+}
+
+// RegisterAvailable checks if the username is already taken or invalid.
+func RegisterAvailable(
+ req *http.Request,
+ cfg config.Dendrite,
+ accountDB *accounts.Database,
+) util.JSONResponse {
+ username := req.URL.Query().Get("username")
+
+ // Squash username to all lowercase letters
+ username = strings.ToLower(username)
+
+ if err := validateUsername(username); err != nil {
+ return *err
+ }
+
+ // Check if this username is reserved by an application service
+ userID := userutil.MakeUserID(username, cfg.Matrix.ServerName)
+ for _, appservice := range cfg.Derived.ApplicationServices {
+ if appservice.IsInterestedInUserID(userID) {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.UserInUse("Desired user ID is reserved by an application service."),
+ }
+ }
+ }
+
+ availability, availabilityErr := accountDB.CheckAccountAvailability(req.Context(), username)
+ if availabilityErr != nil {
+ return util.JSONResponse{
+ Code: http.StatusInternalServerError,
+ JSON: jsonerror.Unknown("failed to check availability: " + availabilityErr.Error()),
+ }
+ }
+ if !availability {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.UserInUse("Desired User ID is already taken."),
+ }
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: availableResponse{
+ Available: true,
+ },
+ }
+}
diff --git a/clientapi/routing/register_test.go b/clientapi/routing/register_test.go
new file mode 100644
index 00000000..6fcf0bc3
--- /dev/null
+++ b/clientapi/routing/register_test.go
@@ -0,0 +1,209 @@
+// Copyright 2017 Andrew Morgan <andrew@amorgan.xyz>
+//
+// 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 (
+ "regexp"
+ "testing"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/common/config"
+)
+
+var (
+ // Registration Flows that the server allows.
+ allowedFlows = []authtypes.Flow{
+ {
+ Stages: []authtypes.LoginType{
+ authtypes.LoginType("stage1"),
+ authtypes.LoginType("stage2"),
+ },
+ },
+ {
+ Stages: []authtypes.LoginType{
+ authtypes.LoginType("stage1"),
+ authtypes.LoginType("stage3"),
+ },
+ },
+ }
+)
+
+// Should return true as we're completing all the stages of a single flow in
+// order.
+func TestFlowCheckingCompleteFlowOrdered(t *testing.T) {
+ testFlow := []authtypes.LoginType{
+ authtypes.LoginType("stage1"),
+ authtypes.LoginType("stage3"),
+ }
+
+ if !checkFlowCompleted(testFlow, allowedFlows) {
+ t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be true.")
+ }
+}
+
+// Should return false as all stages in a single flow need to be completed.
+func TestFlowCheckingStagesFromDifferentFlows(t *testing.T) {
+ testFlow := []authtypes.LoginType{
+ authtypes.LoginType("stage2"),
+ authtypes.LoginType("stage3"),
+ }
+
+ if checkFlowCompleted(testFlow, allowedFlows) {
+ t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be false.")
+ }
+}
+
+// Should return true as we're completing all the stages from a single flow, as
+// well as some extraneous stages.
+func TestFlowCheckingCompleteOrderedExtraneous(t *testing.T) {
+ testFlow := []authtypes.LoginType{
+ authtypes.LoginType("stage1"),
+ authtypes.LoginType("stage3"),
+ authtypes.LoginType("stage4"),
+ authtypes.LoginType("stage5"),
+ }
+ if !checkFlowCompleted(testFlow, allowedFlows) {
+ t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be true.")
+ }
+}
+
+// Should return false as we're submitting an empty flow.
+func TestFlowCheckingEmptyFlow(t *testing.T) {
+ testFlow := []authtypes.LoginType{}
+ if checkFlowCompleted(testFlow, allowedFlows) {
+ t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be false.")
+ }
+}
+
+// Should return false as we've completed a stage that isn't in any allowed flow.
+func TestFlowCheckingInvalidStage(t *testing.T) {
+ testFlow := []authtypes.LoginType{
+ authtypes.LoginType("stage8"),
+ }
+ if checkFlowCompleted(testFlow, allowedFlows) {
+ t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be false.")
+ }
+}
+
+// Should return true as we complete all stages of an allowed flow, though out
+// of order, as well as extraneous stages.
+func TestFlowCheckingExtraneousUnordered(t *testing.T) {
+ testFlow := []authtypes.LoginType{
+ authtypes.LoginType("stage5"),
+ authtypes.LoginType("stage4"),
+ authtypes.LoginType("stage3"),
+ authtypes.LoginType("stage2"),
+ authtypes.LoginType("stage1"),
+ }
+ if !checkFlowCompleted(testFlow, allowedFlows) {
+ t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be true.")
+ }
+}
+
+// Should return false as we're providing fewer stages than are required.
+func TestFlowCheckingShortIncorrectInput(t *testing.T) {
+ testFlow := []authtypes.LoginType{
+ authtypes.LoginType("stage8"),
+ }
+ if checkFlowCompleted(testFlow, allowedFlows) {
+ t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be false.")
+ }
+}
+
+// Should return false as we're providing different stages than are required.
+func TestFlowCheckingExtraneousIncorrectInput(t *testing.T) {
+ testFlow := []authtypes.LoginType{
+ authtypes.LoginType("stage8"),
+ authtypes.LoginType("stage9"),
+ authtypes.LoginType("stage10"),
+ authtypes.LoginType("stage11"),
+ }
+ if checkFlowCompleted(testFlow, allowedFlows) {
+ t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be false.")
+ }
+}
+
+// Completed flows stages should always be a valid slice header.
+// TestEmptyCompletedFlows checks that sessionsDict returns a slice & not nil.
+func TestEmptyCompletedFlows(t *testing.T) {
+ fakeEmptySessions := newSessionsDict()
+ fakeSessionID := "aRandomSessionIDWhichDoesNotExist"
+ ret := fakeEmptySessions.GetCompletedStages(fakeSessionID)
+
+ // check for []
+ if ret == nil || len(ret) != 0 {
+ t.Error("Empty Completed Flow Stages should be a empty slice: returned ", ret, ". Should be []")
+ }
+}
+
+// This method tests validation of the provided Application Service token and
+// username that they're registering
+func TestValidationOfApplicationServices(t *testing.T) {
+ // Set up application service namespaces
+ regex := "@_appservice_.*"
+ regexp, err := regexp.Compile(regex)
+ if err != nil {
+ t.Errorf("Error compiling regex: %s", regex)
+ }
+
+ fakeNamespace := config.ApplicationServiceNamespace{
+ Exclusive: true,
+ Regex: regex,
+ RegexpObject: regexp,
+ }
+
+ // Create a fake application service
+ fakeID := "FakeAS"
+ fakeSenderLocalpart := "_appservice_bot"
+ fakeApplicationService := config.ApplicationService{
+ ID: fakeID,
+ URL: "null",
+ ASToken: "1234",
+ HSToken: "4321",
+ SenderLocalpart: fakeSenderLocalpart,
+ NamespaceMap: map[string][]config.ApplicationServiceNamespace{
+ "users": {fakeNamespace},
+ },
+ }
+
+ // Set up a config
+ fakeConfig := config.Dendrite{}
+ fakeConfig.Matrix.ServerName = "localhost"
+ fakeConfig.Derived.ApplicationServices = []config.ApplicationService{fakeApplicationService}
+
+ // Access token is correct, user_id omitted so we are acting as SenderLocalpart
+ asID, resp := validateApplicationService(&fakeConfig, fakeSenderLocalpart, "1234")
+ if resp != nil || asID != fakeID {
+ t.Errorf("appservice should have validated and returned correct ID: %s", resp.JSON)
+ }
+
+ // Access token is incorrect, user_id omitted so we are acting as SenderLocalpart
+ asID, resp = validateApplicationService(&fakeConfig, fakeSenderLocalpart, "xxxx")
+ if resp == nil || asID == fakeID {
+ t.Errorf("access_token should have been marked as invalid")
+ }
+
+ // Access token is correct, acting as valid user_id
+ asID, resp = validateApplicationService(&fakeConfig, "_appservice_bob", "1234")
+ if resp != nil || asID != fakeID {
+ t.Errorf("access_token and user_id should've been valid: %s", resp.JSON)
+ }
+
+ // Access token is correct, acting as invalid user_id
+ asID, resp = validateApplicationService(&fakeConfig, "_something_else", "1234")
+ if resp == nil || asID == fakeID {
+ t.Errorf("user_id should not have been valid: @_something_else:localhost")
+ }
+}
diff --git a/clientapi/routing/routing.go b/clientapi/routing/routing.go
new file mode 100644
index 00000000..b0ced79e
--- /dev/null
+++ b/clientapi/routing/routing.go
@@ -0,0 +1,413 @@
+// 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 routing
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+
+ "github.com/gorilla/mux"
+ appserviceAPI "github.com/matrix-org/dendrite/appservice/api"
+ "github.com/matrix-org/dendrite/clientapi/auth"
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/devices"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/producers"
+ "github.com/matrix-org/dendrite/common"
+ "github.com/matrix-org/dendrite/common/config"
+ "github.com/matrix-org/dendrite/common/transactions"
+ roomserverAPI "github.com/matrix-org/dendrite/roomserver/api"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+)
+
+const pathPrefixV1 = "/_matrix/client/api/v1"
+const pathPrefixR0 = "/_matrix/client/r0"
+const pathPrefixUnstable = "/_matrix/client/unstable"
+
+// Setup registers HTTP handlers with the given ServeMux. It also supplies the given http.Client
+// to clients which need to make outbound HTTP requests.
+func Setup(
+ apiMux *mux.Router, cfg config.Dendrite,
+ producer *producers.RoomserverProducer,
+ queryAPI roomserverAPI.RoomserverQueryAPI,
+ aliasAPI roomserverAPI.RoomserverAliasAPI,
+ asAPI appserviceAPI.AppServiceQueryAPI,
+ accountDB *accounts.Database,
+ deviceDB *devices.Database,
+ federation *gomatrixserverlib.FederationClient,
+ keyRing gomatrixserverlib.KeyRing,
+ userUpdateProducer *producers.UserUpdateProducer,
+ syncProducer *producers.SyncAPIProducer,
+ typingProducer *producers.TypingServerProducer,
+ transactionsCache *transactions.Cache,
+) {
+
+ apiMux.Handle("/_matrix/client/versions",
+ common.MakeExternalAPI("versions", func(req *http.Request) util.JSONResponse {
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct {
+ Versions []string `json:"versions"`
+ }{[]string{
+ "r0.0.1",
+ "r0.1.0",
+ "r0.2.0",
+ "r0.3.0",
+ }},
+ }
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux := apiMux.PathPrefix(pathPrefixR0).Subrouter()
+ v1mux := apiMux.PathPrefix(pathPrefixV1).Subrouter()
+ unstableMux := apiMux.PathPrefix(pathPrefixUnstable).Subrouter()
+
+ authData := auth.Data{
+ AccountDB: accountDB,
+ DeviceDB: deviceDB,
+ AppServices: cfg.Derived.ApplicationServices,
+ }
+
+ r0mux.Handle("/createRoom",
+ common.MakeAuthAPI("createRoom", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ return CreateRoom(req, device, cfg, producer, accountDB, aliasAPI, asAPI)
+ }),
+ ).Methods(http.MethodPost, http.MethodOptions)
+ r0mux.Handle("/join/{roomIDOrAlias}",
+ common.MakeAuthAPI("join", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return JoinRoomByIDOrAlias(
+ req, device, vars["roomIDOrAlias"], cfg, federation, producer, queryAPI, aliasAPI, keyRing, accountDB,
+ )
+ }),
+ ).Methods(http.MethodPost, http.MethodOptions)
+ r0mux.Handle("/rooms/{roomID}/{membership:(?:join|kick|ban|unban|leave|invite)}",
+ common.MakeAuthAPI("membership", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return SendMembership(req, accountDB, device, vars["roomID"], vars["membership"], cfg, queryAPI, asAPI, producer)
+ }),
+ ).Methods(http.MethodPost, http.MethodOptions)
+ r0mux.Handle("/rooms/{roomID}/send/{eventType}",
+ common.MakeAuthAPI("send_message", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return SendEvent(req, device, vars["roomID"], vars["eventType"], nil, nil, cfg, queryAPI, producer, nil)
+ }),
+ ).Methods(http.MethodPost, http.MethodOptions)
+ r0mux.Handle("/rooms/{roomID}/send/{eventType}/{txnID}",
+ common.MakeAuthAPI("send_message", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ txnID := vars["txnID"]
+ return SendEvent(req, device, vars["roomID"], vars["eventType"], &txnID,
+ nil, cfg, queryAPI, producer, transactionsCache)
+ }),
+ ).Methods(http.MethodPut, http.MethodOptions)
+ r0mux.Handle("/rooms/{roomID}/state/{eventType:[^/]+/?}",
+ common.MakeAuthAPI("send_message", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ emptyString := ""
+ eventType := vars["eventType"]
+ // If there's a trailing slash, remove it
+ if strings.HasSuffix(eventType, "/") {
+ eventType = eventType[:len(eventType)-1]
+ }
+ return SendEvent(req, device, vars["roomID"], eventType, nil, &emptyString, cfg, queryAPI, producer, nil)
+ }),
+ ).Methods(http.MethodPut, http.MethodOptions)
+ r0mux.Handle("/rooms/{roomID}/state/{eventType}/{stateKey}",
+ common.MakeAuthAPI("send_message", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ stateKey := vars["stateKey"]
+ return SendEvent(req, device, vars["roomID"], vars["eventType"], nil, &stateKey, cfg, queryAPI, producer, nil)
+ }),
+ ).Methods(http.MethodPut, http.MethodOptions)
+
+ r0mux.Handle("/register", common.MakeExternalAPI("register", func(req *http.Request) util.JSONResponse {
+ return Register(req, accountDB, deviceDB, &cfg)
+ })).Methods(http.MethodPost, http.MethodOptions)
+
+ v1mux.Handle("/register", common.MakeExternalAPI("register", func(req *http.Request) util.JSONResponse {
+ return LegacyRegister(req, accountDB, deviceDB, &cfg)
+ })).Methods(http.MethodPost, http.MethodOptions)
+
+ r0mux.Handle("/register/available", common.MakeExternalAPI("registerAvailable", func(req *http.Request) util.JSONResponse {
+ return RegisterAvailable(req, cfg, accountDB)
+ })).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/directory/room/{roomAlias}",
+ common.MakeExternalAPI("directory_room", func(req *http.Request) util.JSONResponse {
+ vars := mux.Vars(req)
+ return DirectoryRoom(req, vars["roomAlias"], federation, &cfg, aliasAPI)
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/directory/room/{roomAlias}",
+ common.MakeAuthAPI("directory_room", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return SetLocalAlias(req, device, vars["roomAlias"], &cfg, aliasAPI)
+ }),
+ ).Methods(http.MethodPut, http.MethodOptions)
+
+ r0mux.Handle("/directory/room/{roomAlias}",
+ common.MakeAuthAPI("directory_room", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return RemoveLocalAlias(req, device, vars["roomAlias"], aliasAPI)
+ }),
+ ).Methods(http.MethodDelete, http.MethodOptions)
+
+ r0mux.Handle("/logout",
+ common.MakeAuthAPI("logout", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ return Logout(req, deviceDB, device)
+ }),
+ ).Methods(http.MethodPost, http.MethodOptions)
+
+ r0mux.Handle("/logout/all",
+ common.MakeAuthAPI("logout", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ return LogoutAll(req, deviceDB, device)
+ }),
+ ).Methods(http.MethodPost, http.MethodOptions)
+
+ r0mux.Handle("/rooms/{roomID}/typing/{userID}",
+ common.MakeAuthAPI("rooms_typing", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return SendTyping(req, device, vars["roomID"], vars["userID"], accountDB, typingProducer)
+ }),
+ ).Methods(http.MethodPut, http.MethodOptions)
+
+ r0mux.Handle("/account/whoami",
+ common.MakeAuthAPI("whoami", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ return Whoami(req, device)
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ // Stub endpoints required by Riot
+
+ r0mux.Handle("/login",
+ common.MakeExternalAPI("login", func(req *http.Request) util.JSONResponse {
+ return Login(req, accountDB, deviceDB, cfg)
+ }),
+ ).Methods(http.MethodGet, http.MethodPost, http.MethodOptions)
+
+ r0mux.Handle("/pushrules/",
+ common.MakeExternalAPI("push_rules", func(req *http.Request) util.JSONResponse {
+ // TODO: Implement push rules API
+ res := json.RawMessage(`{
+ "global": {
+ "content": [],
+ "override": [],
+ "room": [],
+ "sender": [],
+ "underride": []
+ }
+ }`)
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: &res,
+ }
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/user/{userId}/filter",
+ common.MakeAuthAPI("put_filter", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return PutFilter(req, device, accountDB, vars["userId"])
+ }),
+ ).Methods(http.MethodPost, http.MethodOptions)
+
+ r0mux.Handle("/user/{userId}/filter/{filterId}",
+ common.MakeAuthAPI("get_filter", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return GetFilter(req, device, accountDB, vars["userId"], vars["filterId"])
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ // Riot user settings
+
+ r0mux.Handle("/profile/{userID}",
+ common.MakeExternalAPI("profile", func(req *http.Request) util.JSONResponse {
+ vars := mux.Vars(req)
+ return GetProfile(req, accountDB, vars["userID"], asAPI)
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/profile/{userID}/avatar_url",
+ common.MakeExternalAPI("profile_avatar_url", func(req *http.Request) util.JSONResponse {
+ vars := mux.Vars(req)
+ return GetAvatarURL(req, accountDB, vars["userID"], asAPI)
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/profile/{userID}/avatar_url",
+ common.MakeAuthAPI("profile_avatar_url", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return SetAvatarURL(req, accountDB, device, vars["userID"], userUpdateProducer, &cfg, producer, queryAPI)
+ }),
+ ).Methods(http.MethodPut, http.MethodOptions)
+ // Browsers use the OPTIONS HTTP method to check if the CORS policy allows
+ // PUT requests, so we need to allow this method
+
+ r0mux.Handle("/profile/{userID}/displayname",
+ common.MakeExternalAPI("profile_displayname", func(req *http.Request) util.JSONResponse {
+ vars := mux.Vars(req)
+ return GetDisplayName(req, accountDB, vars["userID"], asAPI)
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/profile/{userID}/displayname",
+ common.MakeAuthAPI("profile_displayname", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return SetDisplayName(req, accountDB, device, vars["userID"], userUpdateProducer, &cfg, producer, queryAPI)
+ }),
+ ).Methods(http.MethodPut, http.MethodOptions)
+ // Browsers use the OPTIONS HTTP method to check if the CORS policy allows
+ // PUT requests, so we need to allow this method
+
+ r0mux.Handle("/account/3pid",
+ common.MakeAuthAPI("account_3pid", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ return GetAssociated3PIDs(req, accountDB, device)
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/account/3pid",
+ common.MakeAuthAPI("account_3pid", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ return CheckAndSave3PIDAssociation(req, accountDB, device, cfg)
+ }),
+ ).Methods(http.MethodPost, http.MethodOptions)
+
+ unstableMux.Handle("/account/3pid/delete",
+ common.MakeAuthAPI("account_3pid", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ return Forget3PID(req, accountDB)
+ }),
+ ).Methods(http.MethodPost, http.MethodOptions)
+
+ r0mux.Handle("/{path:(?:account/3pid|register)}/email/requestToken",
+ common.MakeExternalAPI("account_3pid_request_token", func(req *http.Request) util.JSONResponse {
+ return RequestEmailToken(req, accountDB, cfg)
+ }),
+ ).Methods(http.MethodPost, http.MethodOptions)
+
+ // Riot logs get flooded unless this is handled
+ r0mux.Handle("/presence/{userID}/status",
+ common.MakeExternalAPI("presence", func(req *http.Request) util.JSONResponse {
+ // TODO: Set presence (probably the responsibility of a presence server not clientapi)
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+ }),
+ ).Methods(http.MethodPut, http.MethodOptions)
+
+ r0mux.Handle("/voip/turnServer",
+ common.MakeAuthAPI("turn_server", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ return RequestTurnServer(req, device, cfg)
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ unstableMux.Handle("/thirdparty/protocols",
+ common.MakeExternalAPI("thirdparty_protocols", func(req *http.Request) util.JSONResponse {
+ // TODO: Return the third party protcols
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/rooms/{roomID}/initialSync",
+ common.MakeExternalAPI("rooms_initial_sync", func(req *http.Request) util.JSONResponse {
+ // TODO: Allow people to peek into rooms.
+ return util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.GuestAccessForbidden("Guest access not implemented"),
+ }
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/user/{userID}/account_data/{type}",
+ common.MakeAuthAPI("user_account_data", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return SaveAccountData(req, accountDB, device, vars["userID"], "", vars["type"], syncProducer)
+ }),
+ ).Methods(http.MethodPut, http.MethodOptions)
+
+ r0mux.Handle("/user/{userID}/rooms/{roomID}/account_data/{type}",
+ common.MakeAuthAPI("user_account_data", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return SaveAccountData(req, accountDB, device, vars["userID"], vars["roomID"], vars["type"], syncProducer)
+ }),
+ ).Methods(http.MethodPut, http.MethodOptions)
+
+ r0mux.Handle("/rooms/{roomID}/members",
+ common.MakeAuthAPI("rooms_members", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return GetMemberships(req, device, vars["roomID"], false, cfg, queryAPI)
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/rooms/{roomID}/joined_members",
+ common.MakeAuthAPI("rooms_members", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return GetMemberships(req, device, vars["roomID"], true, cfg, queryAPI)
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/rooms/{roomID}/read_markers",
+ common.MakeExternalAPI("rooms_read_markers", func(req *http.Request) util.JSONResponse {
+ // TODO: return the read_markers.
+ return util.JSONResponse{Code: http.StatusOK, JSON: struct{}{}}
+ }),
+ ).Methods(http.MethodPost, http.MethodOptions)
+
+ r0mux.Handle("/devices",
+ common.MakeAuthAPI("get_devices", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ return GetDevicesByLocalpart(req, deviceDB, device)
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/devices/{deviceID}",
+ common.MakeAuthAPI("get_device", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return GetDeviceByID(req, deviceDB, device, vars["deviceID"])
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/devices/{deviceID}",
+ common.MakeAuthAPI("device_data", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ vars := mux.Vars(req)
+ return UpdateDeviceByID(req, deviceDB, device, vars["deviceID"])
+ }),
+ ).Methods(http.MethodPut, http.MethodOptions)
+
+ // Stub implementations for sytest
+ r0mux.Handle("/events",
+ common.MakeExternalAPI("events", func(req *http.Request) util.JSONResponse {
+ return util.JSONResponse{Code: http.StatusOK, JSON: map[string]interface{}{
+ "chunk": []interface{}{},
+ "start": "",
+ "end": "",
+ }}
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+
+ r0mux.Handle("/initialSync",
+ common.MakeExternalAPI("initial_sync", func(req *http.Request) util.JSONResponse {
+ return util.JSONResponse{Code: http.StatusOK, JSON: map[string]interface{}{
+ "end": "",
+ }}
+ }),
+ ).Methods(http.MethodGet, http.MethodOptions)
+}
diff --git a/clientapi/routing/sendevent.go b/clientapi/routing/sendevent.go
new file mode 100644
index 00000000..e916e451
--- /dev/null
+++ b/clientapi/routing/sendevent.go
@@ -0,0 +1,153 @@
+// 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 routing
+
+import (
+ "net/http"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/producers"
+ "github.com/matrix-org/dendrite/common"
+ "github.com/matrix-org/dendrite/common/config"
+ "github.com/matrix-org/dendrite/common/transactions"
+ "github.com/matrix-org/dendrite/roomserver/api"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+)
+
+// http://matrix.org/docs/spec/client_server/r0.2.0.html#put-matrix-client-r0-rooms-roomid-send-eventtype-txnid
+// http://matrix.org/docs/spec/client_server/r0.2.0.html#put-matrix-client-r0-rooms-roomid-state-eventtype-statekey
+type sendEventResponse struct {
+ EventID string `json:"event_id"`
+}
+
+// SendEvent implements:
+// /rooms/{roomID}/send/{eventType}
+// /rooms/{roomID}/send/{eventType}/{txnID}
+// /rooms/{roomID}/state/{eventType}/{stateKey}
+func SendEvent(
+ req *http.Request,
+ device *authtypes.Device,
+ roomID, eventType string, txnID, stateKey *string,
+ cfg config.Dendrite,
+ queryAPI api.RoomserverQueryAPI,
+ producer *producers.RoomserverProducer,
+ txnCache *transactions.Cache,
+) util.JSONResponse {
+ if txnID != nil {
+ // Try to fetch response from transactionsCache
+ if res, ok := txnCache.FetchTransaction(*txnID); ok {
+ return *res
+ }
+ }
+
+ e, resErr := generateSendEvent(req, device, roomID, eventType, stateKey, cfg, queryAPI)
+ if resErr != nil {
+ return *resErr
+ }
+
+ var txnAndDeviceID *api.TransactionID
+ if txnID != nil {
+ txnAndDeviceID = &api.TransactionID{
+ TransactionID: *txnID,
+ DeviceID: device.ID,
+ }
+ }
+
+ // pass the new event to the roomserver and receive the correct event ID
+ // event ID in case of duplicate transaction is discarded
+ eventID, err := producer.SendEvents(
+ req.Context(), []gomatrixserverlib.Event{*e}, cfg.Matrix.ServerName, txnAndDeviceID,
+ )
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ res := util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: sendEventResponse{eventID},
+ }
+ // Add response to transactionsCache
+ if txnID != nil {
+ txnCache.AddTransaction(*txnID, &res)
+ }
+
+ return res
+}
+
+func generateSendEvent(
+ req *http.Request,
+ device *authtypes.Device,
+ roomID, eventType string, stateKey *string,
+ cfg config.Dendrite,
+ queryAPI api.RoomserverQueryAPI,
+) (*gomatrixserverlib.Event, *util.JSONResponse) {
+ // parse the incoming http request
+ userID := device.UserID
+ var r map[string]interface{} // must be a JSON object
+ resErr := httputil.UnmarshalJSONRequest(req, &r)
+ if resErr != nil {
+ return nil, resErr
+ }
+
+ evTime, err := httputil.ParseTSParam(req)
+ if err != nil {
+ return nil, &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.InvalidArgumentValue(err.Error()),
+ }
+ }
+
+ // create the new event and set all the fields we can
+ builder := gomatrixserverlib.EventBuilder{
+ Sender: userID,
+ RoomID: roomID,
+ Type: eventType,
+ StateKey: stateKey,
+ }
+ err = builder.SetContent(r)
+ if err != nil {
+ resErr := httputil.LogThenError(req, err)
+ return nil, &resErr
+ }
+
+ var queryRes api.QueryLatestEventsAndStateResponse
+ e, err := common.BuildEvent(req.Context(), &builder, cfg, evTime, queryAPI, &queryRes)
+ if err == common.ErrRoomNoExists {
+ return nil, &util.JSONResponse{
+ Code: http.StatusNotFound,
+ JSON: jsonerror.NotFound("Room does not exist"),
+ }
+ } else if err != nil {
+ resErr := httputil.LogThenError(req, err)
+ return nil, &resErr
+ }
+
+ // check to see if this user can perform this operation
+ stateEvents := make([]*gomatrixserverlib.Event, len(queryRes.StateEvents))
+ for i := range queryRes.StateEvents {
+ stateEvents[i] = &queryRes.StateEvents[i]
+ }
+ provider := gomatrixserverlib.NewAuthEvents(stateEvents)
+ if err = gomatrixserverlib.Allowed(*e, &provider); err != nil {
+ return nil, &util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden(err.Error()), // TODO: Is this error string comprehensible to the client?
+ }
+ }
+ return e, nil
+}
diff --git a/clientapi/routing/sendtyping.go b/clientapi/routing/sendtyping.go
new file mode 100644
index 00000000..561a2d89
--- /dev/null
+++ b/clientapi/routing/sendtyping.go
@@ -0,0 +1,80 @@
+// 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 (
+ "database/sql"
+ "net/http"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/producers"
+ "github.com/matrix-org/dendrite/clientapi/userutil"
+ "github.com/matrix-org/util"
+)
+
+type typingContentJSON struct {
+ Typing bool `json:"typing"`
+ Timeout int64 `json:"timeout"`
+}
+
+// SendTyping handles PUT /rooms/{roomID}/typing/{userID}
+// sends the typing events to client API typingProducer
+func SendTyping(
+ req *http.Request, device *authtypes.Device, roomID string,
+ userID string, accountDB *accounts.Database,
+ typingProducer *producers.TypingServerProducer,
+) util.JSONResponse {
+ if device.UserID != userID {
+ return util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("Cannot set another user's typing state"),
+ }
+ }
+
+ localpart, err := userutil.ParseUsernameParam(userID, nil)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ // Verify that the user is a member of this room
+ _, err = accountDB.GetMembershipInRoomByLocalpart(req.Context(), localpart, roomID)
+ if err == sql.ErrNoRows {
+ return util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("User not in this room"),
+ }
+ } else if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ // parse the incoming http request
+ var r typingContentJSON
+ resErr := httputil.UnmarshalJSONRequest(req, &r)
+ if resErr != nil {
+ return *resErr
+ }
+
+ if err = typingProducer.Send(
+ req.Context(), userID, roomID, r.Typing, r.Timeout,
+ ); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+}
diff --git a/clientapi/routing/threepid.go b/clientapi/routing/threepid.go
new file mode 100644
index 00000000..897d13b6
--- /dev/null
+++ b/clientapi/routing/threepid.go
@@ -0,0 +1,178 @@
+// 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 routing
+
+import (
+ "net/http"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/threepid"
+ "github.com/matrix-org/dendrite/common/config"
+
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+)
+
+type reqTokenResponse struct {
+ SID string `json:"sid"`
+}
+
+type threePIDsResponse struct {
+ ThreePIDs []authtypes.ThreePID `json:"threepids"`
+}
+
+// RequestEmailToken implements:
+// POST /account/3pid/email/requestToken
+// POST /register/email/requestToken
+func RequestEmailToken(req *http.Request, accountDB *accounts.Database, cfg config.Dendrite) util.JSONResponse {
+ var body threepid.EmailAssociationRequest
+ if reqErr := httputil.UnmarshalJSONRequest(req, &body); reqErr != nil {
+ return *reqErr
+ }
+
+ var resp reqTokenResponse
+ var err error
+
+ // Check if the 3PID is already in use locally
+ localpart, err := accountDB.GetLocalpartForThreePID(req.Context(), body.Email, "email")
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if len(localpart) > 0 {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.MatrixError{
+ ErrCode: "M_THREEPID_IN_USE",
+ Err: accounts.Err3PIDInUse.Error(),
+ },
+ }
+ }
+
+ resp.SID, err = threepid.CreateSession(req.Context(), body, cfg)
+ if err == threepid.ErrNotTrusted {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.NotTrusted(body.IDServer),
+ }
+ } else if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: resp,
+ }
+}
+
+// CheckAndSave3PIDAssociation implements POST /account/3pid
+func CheckAndSave3PIDAssociation(
+ req *http.Request, accountDB *accounts.Database, device *authtypes.Device,
+ cfg config.Dendrite,
+) util.JSONResponse {
+ var body threepid.EmailAssociationCheckRequest
+ if reqErr := httputil.UnmarshalJSONRequest(req, &body); reqErr != nil {
+ return *reqErr
+ }
+
+ // Check if the association has been validated
+ verified, address, medium, err := threepid.CheckAssociation(req.Context(), body.Creds, cfg)
+ if err == threepid.ErrNotTrusted {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.NotTrusted(body.Creds.IDServer),
+ }
+ } else if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if !verified {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.MatrixError{
+ ErrCode: "M_THREEPID_AUTH_FAILED",
+ Err: "Failed to auth 3pid",
+ },
+ }
+ }
+
+ if body.Bind {
+ // Publish the association on the identity server if requested
+ err = threepid.PublishAssociation(body.Creds, device.UserID, cfg)
+ if err == threepid.ErrNotTrusted {
+ return util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.NotTrusted(body.Creds.IDServer),
+ }
+ } else if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+ }
+
+ // Save the association in the database
+ localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ if err = accountDB.SaveThreePIDAssociation(req.Context(), address, localpart, medium); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+}
+
+// GetAssociated3PIDs implements GET /account/3pid
+func GetAssociated3PIDs(
+ req *http.Request, accountDB *accounts.Database, device *authtypes.Device,
+) util.JSONResponse {
+ localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ threepids, err := accountDB.GetThreePIDsForLocalpart(req.Context(), localpart)
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: threePIDsResponse{threepids},
+ }
+}
+
+// Forget3PID implements POST /account/3pid/delete
+func Forget3PID(req *http.Request, accountDB *accounts.Database) util.JSONResponse {
+ var body authtypes.ThreePID
+ if reqErr := httputil.UnmarshalJSONRequest(req, &body); reqErr != nil {
+ return *reqErr
+ }
+
+ if err := accountDB.RemoveThreePIDAssociation(req.Context(), body.Address, body.Medium); err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+}
diff --git a/clientapi/routing/voip.go b/clientapi/routing/voip.go
new file mode 100644
index 00000000..b9121633
--- /dev/null
+++ b/clientapi/routing/voip.go
@@ -0,0 +1,78 @@
+// Copyright 2017 Michael Telatysnki <7t3chguy@gmail.com>
+//
+// 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 (
+ "crypto/hmac"
+ "crypto/sha1"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/httputil"
+ "github.com/matrix-org/dendrite/common/config"
+ "github.com/matrix-org/gomatrix"
+ "github.com/matrix-org/util"
+)
+
+// RequestTurnServer implements:
+// GET /voip/turnServer
+func RequestTurnServer(req *http.Request, device *authtypes.Device, cfg config.Dendrite) util.JSONResponse {
+ turnConfig := cfg.TURN
+
+ // TODO Guest Support
+ if len(turnConfig.URIs) == 0 || turnConfig.UserLifetime == "" {
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+ }
+
+ // Duration checked at startup, err not possible
+ duration, _ := time.ParseDuration(turnConfig.UserLifetime)
+
+ resp := gomatrix.RespTurnServer{
+ URIs: turnConfig.URIs,
+ TTL: int(duration.Seconds()),
+ }
+
+ if turnConfig.SharedSecret != "" {
+ expiry := time.Now().Add(duration).Unix()
+ mac := hmac.New(sha1.New, []byte(turnConfig.SharedSecret))
+ _, err := mac.Write([]byte(resp.Username))
+
+ if err != nil {
+ return httputil.LogThenError(req, err)
+ }
+
+ resp.Username = fmt.Sprintf("%d:%s", expiry, device.UserID)
+ resp.Password = base64.StdEncoding.EncodeToString(mac.Sum(nil))
+ } else if turnConfig.Username != "" && turnConfig.Password != "" {
+ resp.Username = turnConfig.Username
+ resp.Password = turnConfig.Password
+ } else {
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: struct{}{},
+ }
+ }
+
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: resp,
+ }
+}
diff --git a/clientapi/routing/whoami.go b/clientapi/routing/whoami.go
new file mode 100644
index 00000000..840bcb5f
--- /dev/null
+++ b/clientapi/routing/whoami.go
@@ -0,0 +1,34 @@
+// 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/clientapi/auth/authtypes"
+ "github.com/matrix-org/util"
+)
+
+// whoamiResponse represents an response for a `whoami` request
+type whoamiResponse struct {
+ UserID string `json:"user_id"`
+}
+
+// Whoami implements `/account/whoami` which enables client to query their account user id.
+// https://matrix.org/docs/spec/client_server/r0.3.0.html#get-matrix-client-r0-account-whoami
+func Whoami(req *http.Request, device *authtypes.Device) util.JSONResponse {
+ return util.JSONResponse{
+ Code: http.StatusOK,
+ JSON: whoamiResponse{UserID: device.UserID},
+ }
+}
diff --git a/clientapi/threepid/invites.go b/clientapi/threepid/invites.go
new file mode 100644
index 00000000..2538577f
--- /dev/null
+++ b/clientapi/threepid/invites.go
@@ -0,0 +1,364 @@
+// 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 threepid
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
+ "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts"
+ "github.com/matrix-org/dendrite/clientapi/producers"
+ "github.com/matrix-org/dendrite/common"
+ "github.com/matrix-org/dendrite/common/config"
+ "github.com/matrix-org/dendrite/roomserver/api"
+ "github.com/matrix-org/gomatrixserverlib"
+)
+
+// MembershipRequest represents the body of an incoming POST request
+// on /rooms/{roomID}/(join|kick|ban|unban|leave|invite)
+type MembershipRequest struct {
+ UserID string `json:"user_id"`
+ Reason string `json:"reason"`
+ IDServer string `json:"id_server"`
+ Medium string `json:"medium"`
+ Address string `json:"address"`
+}
+
+// idServerLookupResponse represents the response described at https://matrix.org/docs/spec/client_server/r0.2.0.html#get-matrix-identity-api-v1-lookup
+type idServerLookupResponse struct {
+ TS int64 `json:"ts"`
+ NotBefore int64 `json:"not_before"`
+ NotAfter int64 `json:"not_after"`
+ Medium string `json:"medium"`
+ Address string `json:"address"`
+ MXID string `json:"mxid"`
+ Signatures map[string]map[string]string `json:"signatures"`
+}
+
+// idServerLookupResponse represents the response described at https://matrix.org/docs/spec/client_server/r0.2.0.html#invitation-storage
+type idServerStoreInviteResponse struct {
+ PublicKey string `json:"public_key"`
+ Token string `json:"token"`
+ DisplayName string `json:"display_name"`
+ PublicKeys []common.PublicKey `json:"public_keys"`
+}
+
+var (
+ // ErrMissingParameter is the error raised if a request for 3PID invite has
+ // an incomplete body
+ ErrMissingParameter = errors.New("'address', 'id_server' and 'medium' must all be supplied")
+ // ErrNotTrusted is the error raised if an identity server isn't in the list
+ // of trusted servers in the configuration file.
+ ErrNotTrusted = errors.New("untrusted server")
+)
+
+// CheckAndProcessInvite analyses the body of an incoming membership request.
+// If the fields relative to a third-party-invite are all supplied, lookups the
+// matching Matrix ID from the given identity server. If no Matrix ID is
+// associated to the given 3PID, asks the identity server to store the invite
+// and emit a "m.room.third_party_invite" event.
+// Returns a representation of the HTTP response to send to the user.
+// Returns a representation of a non-200 HTTP response if something went wrong
+// in the process, or if some 3PID fields aren't supplied but others are.
+// If none of the 3PID-specific fields are supplied, or if a Matrix ID is
+// supplied by the identity server, returns nil to indicate that the request
+// must be processed as a non-3PID membership request. In the latter case,
+// fills the Matrix ID in the request body so a normal invite membership event
+// can be emitted.
+func CheckAndProcessInvite(
+ ctx context.Context,
+ device *authtypes.Device, body *MembershipRequest, cfg config.Dendrite,
+ queryAPI api.RoomserverQueryAPI, db *accounts.Database,
+ producer *producers.RoomserverProducer, membership string, roomID string,
+ evTime time.Time,
+) (inviteStoredOnIDServer bool, err error) {
+ if membership != "invite" || (body.Address == "" && body.IDServer == "" && body.Medium == "") {
+ // If none of the 3PID-specific fields are supplied, it's a standard invite
+ // so return nil for it to be processed as such
+ return
+ } else if body.Address == "" || body.IDServer == "" || body.Medium == "" {
+ // If at least one of the 3PID-specific fields is supplied but not all
+ // of them, return an error
+ err = ErrMissingParameter
+ return
+ }
+
+ lookupRes, storeInviteRes, err := queryIDServer(ctx, db, cfg, device, body, roomID)
+ if err != nil {
+ return
+ }
+
+ if lookupRes.MXID == "" {
+ // No Matrix ID could be found for this 3PID, meaning that a
+ // "m.room.third_party_invite" have to be emitted from the data in
+ // storeInviteRes.
+ err = emit3PIDInviteEvent(
+ ctx, body, storeInviteRes, device, roomID, cfg, queryAPI, producer, evTime,
+ )
+ inviteStoredOnIDServer = err == nil
+
+ return
+ }
+
+ // A Matrix ID have been found: set it in the body request and let the process
+ // continue to create a "m.room.member" event with an "invite" membership
+ body.UserID = lookupRes.MXID
+
+ return
+}
+
+// queryIDServer handles all the requests to the identity server, starting by
+// looking up the given 3PID on the given identity server.
+// If the lookup returned a Matrix ID, checks if the current time is within the
+// time frame in which the 3PID-MXID association is known to be valid, and checks
+// the response's signatures. If one of the checks fails, returns an error.
+// If the lookup didn't return a Matrix ID, asks the identity server to store
+// the invite and to respond with a token.
+// Returns a representation of the response for both cases.
+// Returns an error if a check or a request failed.
+func queryIDServer(
+ ctx context.Context,
+ db *accounts.Database, cfg config.Dendrite, device *authtypes.Device,
+ body *MembershipRequest, roomID string,
+) (lookupRes *idServerLookupResponse, storeInviteRes *idServerStoreInviteResponse, err error) {
+ if err = isTrusted(body.IDServer, cfg); err != nil {
+ return
+ }
+
+ // Lookup the 3PID
+ lookupRes, err = queryIDServerLookup(ctx, body)
+ if err != nil {
+ return
+ }
+
+ if lookupRes.MXID == "" {
+ // No Matrix ID matches with the given 3PID, ask the server to store the
+ // invite and return a token
+ storeInviteRes, err = queryIDServerStoreInvite(ctx, db, cfg, device, body, roomID)
+ return
+ }
+
+ // A Matrix ID matches with the given 3PID
+ // Get timestamp in milliseconds to compare it with the timestamps provided
+ // by the identity server
+ now := time.Now().UnixNano() / 1000000
+ if lookupRes.NotBefore > now || now > lookupRes.NotAfter {
+ // If the current timestamp isn't in the time frame in which the association
+ // is known to be valid, re-run the query
+ return queryIDServer(ctx, db, cfg, device, body, roomID)
+ }
+
+ // Check the request signatures and send an error if one isn't valid
+ if err = checkIDServerSignatures(ctx, body, lookupRes); err != nil {
+ return
+ }
+
+ return
+}
+
+// queryIDServerLookup sends a response to the identity server on /_matrix/identity/api/v1/lookup
+// and returns the response as a structure.
+// Returns an error if the request failed to send or if the response couldn't be parsed.
+func queryIDServerLookup(ctx context.Context, body *MembershipRequest) (*idServerLookupResponse, error) {
+ address := url.QueryEscape(body.Address)
+ requestURL := fmt.Sprintf("https://%s/_matrix/identity/api/v1/lookup?medium=%s&address=%s", body.IDServer, body.Medium, address)
+ req, err := http.NewRequest(http.MethodGet, requestURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := http.DefaultClient.Do(req.WithContext(ctx))
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ // TODO: Log the error supplied with the identity server?
+ errMgs := fmt.Sprintf("Failed to ask %s to store an invite for %s", body.IDServer, body.Address)
+ return nil, errors.New(errMgs)
+ }
+
+ var res idServerLookupResponse
+ err = json.NewDecoder(resp.Body).Decode(&res)
+ return &res, err
+}
+
+// queryIDServerStoreInvite sends a response to the identity server on /_matrix/identity/api/v1/store-invite
+// and returns the response as a structure.
+// Returns an error if the request failed to send or if the response couldn't be parsed.
+func queryIDServerStoreInvite(
+ ctx context.Context,
+ db *accounts.Database, cfg config.Dendrite, device *authtypes.Device,
+ body *MembershipRequest, roomID string,
+) (*idServerStoreInviteResponse, error) {
+ // Retrieve the sender's profile to get their display name
+ localpart, serverName, err := gomatrixserverlib.SplitID('@', device.UserID)
+ if err != nil {
+ return nil, err
+ }
+
+ var profile *authtypes.Profile
+ if serverName == cfg.Matrix.ServerName {
+ profile, err = db.GetProfileByLocalpart(ctx, localpart)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ profile = &authtypes.Profile{}
+ }
+
+ client := http.Client{}
+
+ data := url.Values{}
+ data.Add("medium", body.Medium)
+ data.Add("address", body.Address)
+ data.Add("room_id", roomID)
+ data.Add("sender", device.UserID)
+ data.Add("sender_display_name", profile.DisplayName)
+ // TODO: Also send:
+ // - The room name (room_name)
+ // - The room's avatar url (room_avatar_url)
+ // See https://github.com/matrix-org/sydent/blob/master/sydent/http/servlets/store_invite_servlet.py#L82-L91
+ // These can be easily retrieved by requesting the public rooms API
+ // server's database.
+
+ requestURL := fmt.Sprintf("https://%s/_matrix/identity/api/v1/store-invite", body.IDServer)
+ req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := client.Do(req.WithContext(ctx))
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ errMsg := fmt.Sprintf("Identity server %s responded with a %d error code", body.IDServer, resp.StatusCode)
+ return nil, errors.New(errMsg)
+ }
+
+ var idResp idServerStoreInviteResponse
+ err = json.NewDecoder(resp.Body).Decode(&idResp)
+ return &idResp, err
+}
+
+// queryIDServerPubKey requests a public key identified with a given ID to the
+// a given identity server and returns the matching base64-decoded public key.
+// We assume that the ID server is trusted at this point.
+// Returns an error if the request couldn't be sent, if its body couldn't be parsed
+// or if the key couldn't be decoded from base64.
+func queryIDServerPubKey(ctx context.Context, idServerName string, keyID string) ([]byte, error) {
+ requestURL := fmt.Sprintf("https://%s/_matrix/identity/api/v1/pubkey/%s", idServerName, keyID)
+ req, err := http.NewRequest(http.MethodGet, requestURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := http.DefaultClient.Do(req.WithContext(ctx))
+ if err != nil {
+ return nil, err
+ }
+
+ var pubKeyRes struct {
+ PublicKey gomatrixserverlib.Base64String `json:"public_key"`
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ errMsg := fmt.Sprintf("Couldn't retrieve key %s from server %s", keyID, idServerName)
+ return nil, errors.New(errMsg)
+ }
+
+ err = json.NewDecoder(resp.Body).Decode(&pubKeyRes)
+ return pubKeyRes.PublicKey, err
+}
+
+// checkIDServerSignatures iterates over the signatures of a requests.
+// If no signature can be found for the ID server's domain, returns an error, else
+// iterates over the signature for the said domain, retrieves the matching public
+// key, and verify it.
+// We assume that the ID server is trusted at this point.
+// Returns nil if all the verifications succeeded.
+// Returns an error if something failed in the process.
+func checkIDServerSignatures(
+ ctx context.Context, body *MembershipRequest, res *idServerLookupResponse,
+) error {
+ // Mashall the body so we can give it to VerifyJSON
+ marshalledBody, err := json.Marshal(*res)
+ if err != nil {
+ return err
+ }
+
+ signatures, ok := res.Signatures[body.IDServer]
+ if !ok {
+ return errors.New("No signature for domain " + body.IDServer)
+ }
+
+ for keyID := range signatures {
+ pubKey, err := queryIDServerPubKey(ctx, body.IDServer, keyID)
+ if err != nil {
+ return err
+ }
+ if err = gomatrixserverlib.VerifyJSON(body.IDServer, gomatrixserverlib.KeyID(keyID), pubKey, marshalledBody); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// emit3PIDInviteEvent builds and sends a "m.room.third_party_invite" event.
+// Returns an error if something failed in the process.
+func emit3PIDInviteEvent(
+ ctx context.Context,
+ body *MembershipRequest, res *idServerStoreInviteResponse,
+ device *authtypes.Device, roomID string, cfg config.Dendrite,
+ queryAPI api.RoomserverQueryAPI, producer *producers.RoomserverProducer,
+ evTime time.Time,
+) error {
+ builder := &gomatrixserverlib.EventBuilder{
+ Sender: device.UserID,
+ RoomID: roomID,
+ Type: "m.room.third_party_invite",
+ StateKey: &res.Token,
+ }
+
+ validityURL := fmt.Sprintf("https://%s/_matrix/identity/api/v1/pubkey/isvalid", body.IDServer)
+ content := common.ThirdPartyInviteContent{
+ DisplayName: res.DisplayName,
+ KeyValidityURL: validityURL,
+ PublicKey: res.PublicKey,
+ PublicKeys: res.PublicKeys,
+ }
+
+ if err := builder.SetContent(content); err != nil {
+ return err
+ }
+
+ var queryRes *api.QueryLatestEventsAndStateResponse
+ event, err := common.BuildEvent(ctx, builder, cfg, evTime, queryAPI, queryRes)
+ if err != nil {
+ return err
+ }
+
+ _, err = producer.SendEvents(ctx, []gomatrixserverlib.Event{*event}, cfg.Matrix.ServerName, nil)
+ return err
+}
diff --git a/clientapi/threepid/threepid.go b/clientapi/threepid/threepid.go
new file mode 100644
index 00000000..e5b3305e
--- /dev/null
+++ b/clientapi/threepid/threepid.go
@@ -0,0 +1,187 @@
+// 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 threepid
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/matrix-org/dendrite/common/config"
+)
+
+// EmailAssociationRequest represents the request defined at https://matrix.org/docs/spec/client_server/r0.2.0.html#post-matrix-client-r0-register-email-requesttoken
+type EmailAssociationRequest struct {
+ IDServer string `json:"id_server"`
+ Secret string `json:"client_secret"`
+ Email string `json:"email"`
+ SendAttempt int `json:"send_attempt"`
+}
+
+// EmailAssociationCheckRequest represents the request defined at https://matrix.org/docs/spec/client_server/r0.2.0.html#post-matrix-client-r0-account-3pid
+type EmailAssociationCheckRequest struct {
+ Creds Credentials `json:"threePidCreds"`
+ Bind bool `json:"bind"`
+}
+
+// Credentials represents the "ThreePidCredentials" structure defined at https://matrix.org/docs/spec/client_server/r0.2.0.html#post-matrix-client-r0-account-3pid
+type Credentials struct {
+ SID string `json:"sid"`
+ IDServer string `json:"id_server"`
+ Secret string `json:"client_secret"`
+}
+
+// CreateSession creates a session on an identity server.
+// Returns the session's ID.
+// Returns an error if there was a problem sending the request or decoding the
+// response, or if the identity server responded with a non-OK status.
+func CreateSession(
+ ctx context.Context, req EmailAssociationRequest, cfg config.Dendrite,
+) (string, error) {
+ if err := isTrusted(req.IDServer, cfg); err != nil {
+ return "", err
+ }
+
+ // Create a session on the ID server
+ postURL := fmt.Sprintf("https://%s/_matrix/identity/api/v1/validate/email/requestToken", req.IDServer)
+
+ data := url.Values{}
+ data.Add("client_secret", req.Secret)
+ data.Add("email", req.Email)
+ data.Add("send_attempt", strconv.Itoa(req.SendAttempt))
+
+ request, err := http.NewRequest(http.MethodPost, postURL, strings.NewReader(data.Encode()))
+ if err != nil {
+ return "", err
+ }
+ request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+
+ client := http.Client{}
+ resp, err := client.Do(request.WithContext(ctx))
+ if err != nil {
+ return "", err
+ }
+
+ // Error if the status isn't OK
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("Could not create a session on the server %s", req.IDServer)
+ }
+
+ // Extract the SID from the response and return it
+ var sid struct {
+ SID string `json:"sid"`
+ }
+ err = json.NewDecoder(resp.Body).Decode(&sid)
+
+ return sid.SID, err
+}
+
+// CheckAssociation checks the status of an ongoing association validation on an
+// identity server.
+// Returns a boolean set to true if the association has been validated, false if not.
+// If the association has been validated, also returns the related third-party
+// identifier and its medium.
+// Returns an error if there was a problem sending the request or decoding the
+// response, or if the identity server responded with a non-OK status.
+func CheckAssociation(
+ ctx context.Context, creds Credentials, cfg config.Dendrite,
+) (bool, string, string, error) {
+ if err := isTrusted(creds.IDServer, cfg); err != nil {
+ return false, "", "", err
+ }
+
+ requestURL := fmt.Sprintf("https://%s/_matrix/identity/api/v1/3pid/getValidated3pid?sid=%s&client_secret=%s", creds.IDServer, creds.SID, creds.Secret)
+ req, err := http.NewRequest(http.MethodGet, requestURL, nil)
+ if err != nil {
+ return false, "", "", err
+ }
+ resp, err := http.DefaultClient.Do(req.WithContext(ctx))
+ if err != nil {
+ return false, "", "", err
+ }
+
+ var respBody struct {
+ Medium string `json:"medium"`
+ ValidatedAt int64 `json:"validated_at"`
+ Address string `json:"address"`
+ ErrCode string `json:"errcode"`
+ Error string `json:"error"`
+ }
+
+ if err = json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
+ return false, "", "", err
+ }
+
+ if respBody.ErrCode == "M_SESSION_NOT_VALIDATED" {
+ return false, "", "", nil
+ } else if len(respBody.ErrCode) > 0 {
+ return false, "", "", errors.New(respBody.Error)
+ }
+
+ return true, respBody.Address, respBody.Medium, nil
+}
+
+// PublishAssociation publishes a validated association between a third-party
+// identifier and a Matrix ID.
+// Returns an error if there was a problem sending the request or decoding the
+// response, or if the identity server responded with a non-OK status.
+func PublishAssociation(creds Credentials, userID string, cfg config.Dendrite) error {
+ if err := isTrusted(creds.IDServer, cfg); err != nil {
+ return err
+ }
+
+ postURL := fmt.Sprintf("https://%s/_matrix/identity/api/v1/3pid/bind", creds.IDServer)
+
+ data := url.Values{}
+ data.Add("sid", creds.SID)
+ data.Add("client_secret", creds.Secret)
+ data.Add("mxid", userID)
+
+ request, err := http.NewRequest(http.MethodPost, postURL, strings.NewReader(data.Encode()))
+ if err != nil {
+ return err
+ }
+ request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+
+ client := http.Client{}
+ resp, err := client.Do(request)
+ if err != nil {
+ return err
+ }
+
+ // Error if the status isn't OK
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("Could not publish the association on the server %s", creds.IDServer)
+ }
+
+ return nil
+}
+
+// isTrusted checks if a given identity server is part of the list of trusted
+// identity servers in the configuration file.
+// Returns an error if the server isn't trusted.
+func isTrusted(idServer string, cfg config.Dendrite) error {
+ for _, server := range cfg.Matrix.TrustedIDServers {
+ if idServer == server {
+ return nil
+ }
+ }
+ return ErrNotTrusted
+}
diff --git a/clientapi/userutil/userutil.go b/clientapi/userutil/userutil.go
new file mode 100644
index 00000000..4cea3c18
--- /dev/null
+++ b/clientapi/userutil/userutil.go
@@ -0,0 +1,49 @@
+// 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 userutil
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/matrix-org/gomatrixserverlib"
+)
+
+// ParseUsernameParam extracts localpart from usernameParam.
+// usernameParam can either be a user ID or just the localpart/username.
+// If serverName is passed, it is verified against the domain obtained from usernameParam (if present)
+// Returns error in case of invalid usernameParam.
+func ParseUsernameParam(usernameParam string, expectedServerName *gomatrixserverlib.ServerName) (string, error) {
+ localpart := usernameParam
+
+ if strings.HasPrefix(usernameParam, "@") {
+ lp, domain, err := gomatrixserverlib.SplitID('@', usernameParam)
+
+ if err != nil {
+ return "", errors.New("Invalid username")
+ }
+
+ if expectedServerName != nil && domain != *expectedServerName {
+ return "", errors.New("User ID does not belong to this server")
+ }
+
+ localpart = lp
+ }
+ return localpart, nil
+}
+
+// MakeUserID generates user ID from localpart & server name
+func MakeUserID(localpart string, server gomatrixserverlib.ServerName) string {
+ return fmt.Sprintf("@%s:%s", localpart, string(server))
+}
diff --git a/clientapi/userutil/userutil_test.go b/clientapi/userutil/userutil_test.go
new file mode 100644
index 00000000..2628642f
--- /dev/null
+++ b/clientapi/userutil/userutil_test.go
@@ -0,0 +1,71 @@
+// 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 userutil
+
+import (
+ "testing"
+
+ "github.com/matrix-org/gomatrixserverlib"
+)
+
+var (
+ localpart = "somelocalpart"
+ serverName gomatrixserverlib.ServerName = "someservername"
+ invalidServerName gomatrixserverlib.ServerName = "invalidservername"
+ goodUserID = "@" + localpart + ":" + string(serverName)
+ badUserID = "@bad:user:name@noservername:"
+)
+
+// TestGoodUserID checks that correct localpart is returned for a valid user ID.
+func TestGoodUserID(t *testing.T) {
+ lp, err := ParseUsernameParam(goodUserID, &serverName)
+
+ if err != nil {
+ t.Error("User ID Parsing failed for ", goodUserID, " with error: ", err.Error())
+ }
+
+ if lp != localpart {
+ t.Error("Incorrect username, returned: ", lp, " should be: ", localpart)
+ }
+}
+
+// TestWithLocalpartOnly checks that localpart is returned when usernameParam contains only localpart.
+func TestWithLocalpartOnly(t *testing.T) {
+ lp, err := ParseUsernameParam(localpart, &serverName)
+
+ if err != nil {
+ t.Error("User ID Parsing failed for ", localpart, " with error: ", err.Error())
+ }
+
+ if lp != localpart {
+ t.Error("Incorrect username, returned: ", lp, " should be: ", localpart)
+ }
+}
+
+// TestIncorrectDomain checks for error when there's server name mismatch.
+func TestIncorrectDomain(t *testing.T) {
+ _, err := ParseUsernameParam(goodUserID, &invalidServerName)
+
+ if err == nil {
+ t.Error("Invalid Domain should return an error")
+ }
+}
+
+// TestBadUserID checks that ParseUsernameParam fails for invalid user ID
+func TestBadUserID(t *testing.T) {
+ _, err := ParseUsernameParam(badUserID, &serverName)
+
+ if err == nil {
+ t.Error("Illegal User ID should return an error")
+ }
+}