From 1efbad8119f21aaa405f75d75d77cfc6bd10f06e Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Mon, 10 Jul 2017 14:52:41 +0100 Subject: [PATCH] Profile API (#151) * Profile retrieval * Saving avatar (without propagating it) * Saving display name (without propagating it) * Getters for display name and avatar URL * Doc'd * Remove unused import * Applied requested changes * Added auth on PUT /profile/{userID}/... * Improved error handling/reporting * Using utils log reporting * Removed useless checks --- .../clientapi/auth/authtypes/account.go | 1 + .../clientapi/auth/authtypes/profile.go | 22 +++ .../auth/storage/accounts/profile_table.go | 93 ++++++++++ .../auth/storage/accounts/storage.go | 34 +++- .../dendrite/clientapi/readers/profile.go | 161 ++++++++++++++++++ .../dendrite/clientapi/routing/routing.go | 46 +++-- 6 files changed, 341 insertions(+), 16 deletions(-) create mode 100644 src/github.com/matrix-org/dendrite/clientapi/auth/authtypes/profile.go create mode 100644 src/github.com/matrix-org/dendrite/clientapi/auth/storage/accounts/profile_table.go create mode 100644 src/github.com/matrix-org/dendrite/clientapi/readers/profile.go diff --git a/src/github.com/matrix-org/dendrite/clientapi/auth/authtypes/account.go b/src/github.com/matrix-org/dendrite/clientapi/auth/authtypes/account.go index ed33d0b5..1a03590e 100644 --- a/src/github.com/matrix-org/dendrite/clientapi/auth/authtypes/account.go +++ b/src/github.com/matrix-org/dendrite/clientapi/auth/authtypes/account.go @@ -23,6 +23,7 @@ type Account struct { UserID string Localpart string ServerName gomatrixserverlib.ServerName + Profile *Profile // TODO: Other flags like IsAdmin, IsGuest // TODO: Devices // TODO: Associations (e.g. with application services) diff --git a/src/github.com/matrix-org/dendrite/clientapi/auth/authtypes/profile.go b/src/github.com/matrix-org/dendrite/clientapi/auth/authtypes/profile.go new file mode 100644 index 00000000..6cf508f4 --- /dev/null +++ b/src/github.com/matrix-org/dendrite/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/src/github.com/matrix-org/dendrite/clientapi/auth/storage/accounts/profile_table.go b/src/github.com/matrix-org/dendrite/clientapi/auth/storage/accounts/profile_table.go new file mode 100644 index 00000000..36416e07 --- /dev/null +++ b/src/github.com/matrix-org/dendrite/clientapi/auth/storage/accounts/profile_table.go @@ -0,0 +1,93 @@ +// 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 ( + "database/sql" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" +) + +const profilesSchema = ` +-- Stores data about accounts profiles. +CREATE TABLE IF NOT EXISTS 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 profiles(localpart, display_name, avatar_url) VALUES ($1, $2, $3)" + +const selectProfileByLocalpartSQL = "" + + "SELECT localpart, display_name, avatar_url FROM profiles WHERE localpart = $1" + +const setAvatarURLSQL = "" + + "UPDATE profiles SET avatar_url = $1 WHERE localpart = $2" + +const setDisplayNameSQL = "" + + "UPDATE 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(localpart string) (err error) { + _, err = s.insertProfileStmt.Exec(localpart, "", "") + return +} + +func (s *profilesStatements) selectProfileByLocalpart(localpart string) (*authtypes.Profile, error) { + var profile authtypes.Profile + err := s.selectProfileByLocalpartStmt.QueryRow(localpart).Scan(&profile.Localpart, &profile.DisplayName, &profile.AvatarURL) + return &profile, err +} + +func (s *profilesStatements) setAvatarURL(localpart string, avatarURL string) (err error) { + _, err = s.setAvatarURLStmt.Exec(avatarURL, localpart) + return +} + +func (s *profilesStatements) setDisplayName(localpart string, displayName string) (err error) { + _, err = s.setDisplayNameStmt.Exec(displayName, localpart) + return +} diff --git a/src/github.com/matrix-org/dendrite/clientapi/auth/storage/accounts/storage.go b/src/github.com/matrix-org/dendrite/clientapi/auth/storage/accounts/storage.go index 1f1499bb..cd6abc09 100644 --- a/src/github.com/matrix-org/dendrite/clientapi/auth/storage/accounts/storage.go +++ b/src/github.com/matrix-org/dendrite/clientapi/auth/storage/accounts/storage.go @@ -28,9 +28,10 @@ import ( type Database struct { db *sql.DB accounts accountsStatements + profiles profilesStatements } -// NewDatabase creates a new accounts database +// NewDatabase creates a new accounts and profiles database func NewDatabase(dataSourceName string, serverName gomatrixserverlib.ServerName) (*Database, error) { var db *sql.DB var err error @@ -41,7 +42,11 @@ func NewDatabase(dataSourceName string, serverName gomatrixserverlib.ServerName) if err = a.prepare(db, serverName); err != nil { return nil, err } - return &Database{db, a}, nil + p := profilesStatements{} + if err = p.prepare(db); err != nil { + return nil, err + } + return &Database{db, a, p}, nil } // GetAccountByPassword returns the account associated with the given localpart and password. @@ -57,13 +62,34 @@ func (d *Database) GetAccountByPassword(localpart, plaintextPassword string) (*a return d.accounts.selectAccountByLocalpart(localpart) } -// CreateAccount makes a new account with the given login name and password. If no password is supplied, -// the account will be a passwordless account. +// 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(localpart string) (*authtypes.Profile, error) { + return d.profiles.selectProfileByLocalpart(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(localpart string, avatarURL string) error { + return d.profiles.setAvatarURL(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(localpart string, displayName string) error { + return d.profiles.setDisplayName(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. func (d *Database) CreateAccount(localpart, plaintextPassword string) (*authtypes.Account, error) { hash, err := hashPassword(plaintextPassword) if err != nil { return nil, err } + if err := d.profiles.insertProfile(localpart); err != nil { + return nil, err + } return d.accounts.insertAccount(localpart, hash) } diff --git a/src/github.com/matrix-org/dendrite/clientapi/readers/profile.go b/src/github.com/matrix-org/dendrite/clientapi/readers/profile.go new file mode 100644 index 00000000..65fa9a06 --- /dev/null +++ b/src/github.com/matrix-org/dendrite/clientapi/readers/profile.go @@ -0,0 +1,161 @@ +// 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 readers + +import ( + "fmt" + "net/http" + "strings" + + "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/util" +) + +type profileResponse struct { + AvatarURL string `json:"avatar_url"` + DisplayName string `json:"displayname"` +} + +type avatarURL struct { + AvatarURL string `json:"avatar_url"` +} + +type displayName struct { + DisplayName string `json:"displayname"` +} + +// GetProfile implements GET /profile/{userID} +func GetProfile( + req *http.Request, accountDB *accounts.Database, userID string, +) util.JSONResponse { + if req.Method != "GET" { + return util.JSONResponse{ + Code: 405, + JSON: jsonerror.NotFound("Bad method"), + } + } + localpart := getLocalPart(userID) + profile, err := accountDB.GetProfileByLocalpart(localpart) + if err != nil { + return httputil.LogThenError(req, err) + } + res := profileResponse{ + AvatarURL: profile.AvatarURL, + DisplayName: profile.DisplayName, + } + return util.JSONResponse{ + Code: 200, + JSON: res, + } +} + +// GetAvatarURL implements GET /profile/{userID}/avatar_url +func GetAvatarURL( + req *http.Request, accountDB *accounts.Database, userID string, +) util.JSONResponse { + localpart := getLocalPart(userID) + profile, err := accountDB.GetProfileByLocalpart(localpart) + if err != nil { + return httputil.LogThenError(req, err) + } + res := avatarURL{ + AvatarURL: profile.AvatarURL, + } + return util.JSONResponse{ + Code: 200, + JSON: res, + } +} + +// SetAvatarURL implements PUT /profile/{userID}/avatar_url +func SetAvatarURL( + req *http.Request, accountDB *accounts.Database, userID string, +) util.JSONResponse { + var r avatarURL + if resErr := httputil.UnmarshalJSONRequest(req, &r); resErr != nil { + return *resErr + } + if r.AvatarURL == "" { + return util.JSONResponse{ + Code: 400, + JSON: jsonerror.BadJSON("'avatar_url' must be supplied."), + } + } + + localpart := getLocalPart(userID) + if err := accountDB.SetAvatarURL(localpart, r.AvatarURL); err != nil { + return httputil.LogThenError(req, err) + } + return util.JSONResponse{ + Code: 200, + JSON: struct{}{}, + } +} + +// GetDisplayName implements GET /profile/{userID}/displayname +func GetDisplayName( + req *http.Request, accountDB *accounts.Database, userID string, +) util.JSONResponse { + localpart := getLocalPart(userID) + profile, err := accountDB.GetProfileByLocalpart(localpart) + if err != nil { + return httputil.LogThenError(req, err) + } + res := displayName{ + DisplayName: profile.DisplayName, + } + return util.JSONResponse{ + Code: 200, + JSON: res, + } +} + +// SetDisplayName implements PUT /profile/{userID}/displayname +func SetDisplayName( + req *http.Request, accountDB *accounts.Database, userID string, +) util.JSONResponse { + var r displayName + if resErr := httputil.UnmarshalJSONRequest(req, &r); resErr != nil { + return *resErr + } + if r.DisplayName == "" { + return util.JSONResponse{ + Code: 400, + JSON: jsonerror.BadJSON("'displayname' must be supplied."), + } + } + + localpart := getLocalPart(userID) + if err := accountDB.SetDisplayName(localpart, r.DisplayName); err != nil { + return httputil.LogThenError(req, err) + } + return util.JSONResponse{ + Code: 200, + JSON: struct{}{}, + } +} + +func getLocalPart(userID string) string { + if !strings.HasPrefix(userID, "@") { + panic(fmt.Errorf("Invalid user ID")) + } + + // Get the part before ":" + username := strings.Split(userID, ":")[0] + // Return the part after the "@" + return strings.Split(username, "@")[1] +} diff --git a/src/github.com/matrix-org/dendrite/clientapi/routing/routing.go b/src/github.com/matrix-org/dendrite/clientapi/routing/routing.go index ce895c4b..3482344d 100644 --- a/src/github.com/matrix-org/dendrite/clientapi/routing/routing.go +++ b/src/github.com/matrix-org/dendrite/clientapi/routing/routing.go @@ -163,14 +163,43 @@ func Setup( r0mux.Handle("/profile/{userID}", common.MakeAPI("profile", func(req *http.Request) util.JSONResponse { - // TODO: Get profile data for user ID - return util.JSONResponse{ - Code: 200, - JSON: struct{}{}, - } + vars := mux.Vars(req) + return readers.GetProfile(req, accountDB, vars["userID"]) }), ) + r0mux.Handle("/profile/{userID}/avatar_url", + common.MakeAPI("profile_avatar_url", func(req *http.Request) util.JSONResponse { + vars := mux.Vars(req) + return readers.GetAvatarURL(req, accountDB, vars["userID"]) + }), + ).Methods("GET") + + r0mux.Handle("/profile/{userID}/avatar_url", + common.MakeAuthAPI("profile_avatar_url", deviceDB, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return readers.SetAvatarURL(req, accountDB, vars["userID"]) + }), + ).Methods("PUT", "OPTIONS") + // 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.MakeAPI("profile_displayname", func(req *http.Request) util.JSONResponse { + vars := mux.Vars(req) + return readers.GetDisplayName(req, accountDB, vars["userID"]) + }), + ).Methods("GET") + + r0mux.Handle("/profile/{userID}/displayname", + common.MakeAuthAPI("profile_displayname", deviceDB, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return readers.SetDisplayName(req, accountDB, vars["userID"]) + }), + ).Methods("PUT", "OPTIONS") + // 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.MakeAPI("account_3pid", func(req *http.Request) util.JSONResponse { // TODO: Get 3pid data for user ID @@ -237,13 +266,6 @@ func Setup( }), ) - r0mux.Handle("/profile/{userID}/displayname", - common.MakeAPI("profile_displayname", func(req *http.Request) util.JSONResponse { - // TODO: Set and get the displayname - return util.JSONResponse{Code: 200, JSON: struct{}{}} - }), - ) - r0mux.Handle("/user/{userID}/account_data/{type}", common.MakeAPI("user_account_data", func(req *http.Request) util.JSONResponse { // TODO: Set and get the account_data