diff --git a/src/github.com/matrix-org/go-neb/database/db.go b/src/github.com/matrix-org/go-neb/database/db.go index 7e8fea2..eca84a9 100644 --- a/src/github.com/matrix-org/go-neb/database/db.go +++ b/src/github.com/matrix-org/go-neb/database/db.go @@ -184,6 +184,17 @@ func (d *ServiceDB) LoadAuthRealm(realmID string) (realm types.AuthRealm, err er return } +// LoadAuthRealmsByType loads all auth realms with the given type from the database. +// The realms are ordered based on their realm ID. +// Returns an empty list if there are no realms with that type. +func (d *ServiceDB) LoadAuthRealmsByType(realmType string) (realms []types.AuthRealm, err error) { + err = runTransaction(d.db, func(txn *sql.Tx) error { + realms, err = selectRealmsByTypeTxn(txn, realmType) + return err + }) + return +} + // StoreAuthRealm stores the given AuthRealm, clobbering based on the realm ID. // This function updates the time added/updated values. The previous realm, if any, is // returned. diff --git a/src/github.com/matrix-org/go-neb/database/schema.go b/src/github.com/matrix-org/go-neb/database/schema.go index 8b16c43..c2046d9 100644 --- a/src/github.com/matrix-org/go-neb/database/schema.go +++ b/src/github.com/matrix-org/go-neb/database/schema.go @@ -262,6 +262,35 @@ func selectRealmTxn(txn *sql.Tx, realmID string) (types.AuthRealm, error) { return realm, nil } +const selectRealmsByTypeSQL = ` +SELECT realm_id, realm_json FROM auth_realms WHERE realm_type = $1 ORDER BY realm_id +` + +func selectRealmsByTypeTxn(txn *sql.Tx, realmType string) (realms []types.AuthRealm, err error) { + rows, err := txn.Query(selectRealmsByTypeSQL, realmType) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var realmID string + var realmJSON []byte + if err = rows.Scan(&realmID, &realmJSON); err != nil { + return + } + realm := types.CreateAuthRealm(realmID, realmType) + if realm == nil { + err = fmt.Errorf("Cannot create realm %s of type %s", realmID, realmType) + return + } + if err = json.Unmarshal(realmJSON, realm); err != nil { + return + } + realms = append(realms, realm) + } + return +} + const updateRealmSQL = ` UPDATE auth_realms SET realm_type=$1, realm_json=$2, time_updated_ms=$3 WHERE realm_id=$4 diff --git a/src/github.com/matrix-org/go-neb/goneb.go b/src/github.com/matrix-org/go-neb/goneb.go index 9dba6ba..2e22ef9 100644 --- a/src/github.com/matrix-org/go-neb/goneb.go +++ b/src/github.com/matrix-org/go-neb/goneb.go @@ -9,6 +9,7 @@ import ( "github.com/matrix-org/go-neb/server" _ "github.com/matrix-org/go-neb/services/echo" _ "github.com/matrix-org/go-neb/services/github" + _ "github.com/matrix-org/go-neb/services/jira" "github.com/matrix-org/go-neb/types" _ "github.com/mattn/go-sqlite3" "net/http" diff --git a/src/github.com/matrix-org/go-neb/plugin/plugin.go b/src/github.com/matrix-org/go-neb/plugin/plugin.go index 8ee6842..33123aa 100644 --- a/src/github.com/matrix-org/go-neb/plugin/plugin.go +++ b/src/github.com/matrix-org/go-neb/plugin/plugin.go @@ -66,6 +66,11 @@ func runCommandForPlugin(plugin Plugin, event *matrix.Event, arguments []string) } cmdArgs := arguments[len(bestMatch.Path):] + log.WithFields(log.Fields{ + "room_id": event.RoomID, + "user_id": event.Sender, + "command": bestMatch.Path, + }).Info("Executing command") content, err := bestMatch.Command(event.RoomID, event.Sender, cmdArgs) if err != nil { if content != nil { diff --git a/src/github.com/matrix-org/go-neb/realms/jira/jira.go b/src/github.com/matrix-org/go-neb/realms/jira/jira.go index 020f8fc..c28f9cf 100644 --- a/src/github.com/matrix-org/go-neb/realms/jira/jira.go +++ b/src/github.com/matrix-org/go-neb/realms/jira/jira.go @@ -3,9 +3,11 @@ package realms import ( "crypto/rsa" "crypto/x509" + "database/sql" "encoding/json" "encoding/pem" "errors" + "fmt" log "github.com/Sirupsen/logrus" "github.com/andygrunwald/go-jira" "github.com/dghubble/oauth1" @@ -14,9 +16,11 @@ import ( "github.com/matrix-org/go-neb/types" "golang.org/x/net/context" "net/http" + "strings" ) -type jiraRealm struct { +// JIRARealm is an AuthRealm which can process JIRA installations +type JIRARealm struct { id string redirectURL string privateKey *rsa.PrivateKey @@ -57,15 +61,18 @@ func (s *JIRASession) ID() string { return s.id } -func (r *jiraRealm) ID() string { +// ID returns the ID of this JIRA realm. +func (r *JIRARealm) ID() string { return r.id } -func (r *jiraRealm) Type() string { +// Type returns the type of realm this is. +func (r *JIRARealm) Type() string { return "jira" } -func (r *jiraRealm) Register() error { +// Register is called when this realm is being created from an external entity +func (r *JIRARealm) Register() error { if r.ConsumerName == "" || r.ConsumerKey == "" || r.ConsumerSecret == "" || r.PrivateKeyPEM == "" { return errors.New("ConsumerName, ConsumerKey, ConsumerSecret, PrivateKeyPEM must be specified.") } @@ -78,7 +85,7 @@ func (r *jiraRealm) Register() error { } // Check to see if JIRA endpoint is valid by pinging an endpoint - cli, err := r.jiraClient(r.JIRAEndpoint, "", true) + cli, err := r.JIRAClient("", true) if err != nil { return err } @@ -97,7 +104,8 @@ func (r *jiraRealm) Register() error { return nil } -func (r *jiraRealm) RequestAuthSession(userID string, req json.RawMessage) interface{} { +// RequestAuthSession is called by a user wishing to auth with this JIRA realm +func (r *JIRARealm) RequestAuthSession(userID string, req json.RawMessage) interface{} { logger := log.WithField("jira_url", r.JIRAEndpoint) if err := r.ensureInited(); err != nil { logger.WithError(err).Print("Failed to init realm") @@ -132,7 +140,8 @@ func (r *jiraRealm) RequestAuthSession(userID string, req json.RawMessage) inter }{authURL.String()} } -func (r *jiraRealm) OnReceiveRedirect(w http.ResponseWriter, req *http.Request) { +// OnReceiveRedirect is called when JIRA installations redirect back to NEB +func (r *JIRARealm) OnReceiveRedirect(w http.ResponseWriter, req *http.Request) { logger := log.WithField("jira_url", r.JIRAEndpoint) if err := r.ensureInited(); err != nil { failWith(logger, w, 500, "Failed to initialise realm", err) @@ -180,7 +189,8 @@ func (r *jiraRealm) OnReceiveRedirect(w http.ResponseWriter, req *http.Request) w.Write([]byte("OK!")) } -func (r *jiraRealm) AuthSession(id, userID, realmID string) types.AuthSession { +// AuthSession returns a JIRASession with the given parameters +func (r *JIRARealm) AuthSession(id, userID, realmID string) types.AuthSession { return &JIRASession{ id: id, userID: userID, @@ -188,31 +198,83 @@ func (r *jiraRealm) AuthSession(id, userID, realmID string) types.AuthSession { } } -// jiraClient returns an authenticated jira.Client for the given userID. Returns an unauthenticated +// ProjectKeyExists returns true if the given project key exists on this JIRA realm. +// An authenticated client for userID will be used if one exists, else an +// unauthenticated client will be used, which may not be able to see the complete list +// of projects. +func (r *JIRARealm) ProjectKeyExists(userID, projectKey string) (bool, error) { + if err := r.ensureInited(); err != nil { + return false, err + } + cli, err := r.JIRAClient(userID, true) + if err != nil { + return false, err + } + var projects []jira.Project + req, err := cli.NewRequest("GET", "rest/api/2/project", nil) + if err != nil { + return false, err + } + res, err := cli.Do(req, &projects) + if err != nil { + return false, err + } + if res == nil { + return false, errors.New("No response returned") + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return false, fmt.Errorf("%srest/api/2/project returned code %d", + r.JIRAEndpoint, res.StatusCode) + } + + for _, p := range projects { + if strings.EqualFold(p.Key, projectKey) { + return true, nil + } + } + return false, nil +} + +// JIRAClient returns an authenticated jira.Client for the given userID. Returns an unauthenticated // client if allowUnauth is true and no authenticated session is found, else returns an error. -func (r *jiraRealm) jiraClient(jiraBaseURL, userID string, allowUnauth bool) (*jira.Client, error) { - // TODO: Check if user has an auth session. Requires access token+secret - hasAuthSession := false - - if hasAuthSession { - // make an authenticated client - var cli *jira.Client - - auth := r.oauth1Config(jiraBaseURL) - - httpClient := auth.Client(context.TODO(), oauth1.NewToken("access_tokenTODO", "access_secretTODO")) - cli, err := jira.NewClient(httpClient, jiraBaseURL) - return cli, err - } else if allowUnauth { - // make an unauthenticated client - cli, err := jira.NewClient(nil, jiraBaseURL) - return cli, err - } else { +func (r *JIRARealm) JIRAClient(userID string, allowUnauth bool) (*jira.Client, error) { + // Check if user has an auth session. + session, err := database.GetServiceDB().LoadAuthSessionByUser(r.id, userID) + if err != nil { + if err == sql.ErrNoRows { + if allowUnauth { + // make an unauthenticated client + return jira.NewClient(nil, r.JIRAEndpoint) + } + return nil, errors.New("No authenticated session found for " + userID) + } + // some other error + return nil, err + } + + jsession, ok := session.(*JIRASession) + if !ok { + return nil, errors.New("Failed to cast user session to a JIRASession") + } + // Make sure they finished the auth process + if jsession.AccessSecret == "" || jsession.AccessToken == "" { + if allowUnauth { + // make an unauthenticated client + return jira.NewClient(nil, r.JIRAEndpoint) + } return nil, errors.New("No authenticated session found for " + userID) } + + // make an authenticated client + auth := r.oauth1Config(r.JIRAEndpoint) + httpClient := auth.Client( + context.TODO(), + oauth1.NewToken(jsession.AccessToken, jsession.AccessSecret), + ) + return jira.NewClient(httpClient, r.JIRAEndpoint) } -func (r *jiraRealm) ensureInited() error { +func (r *JIRARealm) ensureInited() error { if err := r.parsePrivateKey(); err != nil { log.WithError(err).Print("Failed to parse private key") return err @@ -227,7 +289,7 @@ func (r *jiraRealm) ensureInited() error { return nil } -func (r *jiraRealm) parsePrivateKey() error { +func (r *JIRARealm) parsePrivateKey() error { if r.privateKey != nil { return nil } @@ -244,7 +306,7 @@ func (r *jiraRealm) parsePrivateKey() error { return nil } -func (r *jiraRealm) oauth1Config(jiraBaseURL string) *oauth1.Config { +func (r *JIRARealm) oauth1Config(jiraBaseURL string) *oauth1.Config { return &oauth1.Config{ ConsumerKey: r.ConsumerKey, ConsumerSecret: r.ConsumerSecret, @@ -319,6 +381,6 @@ func failWith(logger *log.Entry, w http.ResponseWriter, code int, msg string, er func init() { types.RegisterAuthRealm(func(realmID, redirectURL string) types.AuthRealm { - return &jiraRealm{id: realmID, redirectURL: redirectURL} + return &JIRARealm{id: realmID, redirectURL: redirectURL} }) } diff --git a/src/github.com/matrix-org/go-neb/services/jira/jira.go b/src/github.com/matrix-org/go-neb/services/jira/jira.go new file mode 100644 index 0000000..a9a7100 --- /dev/null +++ b/src/github.com/matrix-org/go-neb/services/jira/jira.go @@ -0,0 +1,181 @@ +package services + +import ( + "database/sql" + "errors" + "fmt" + log "github.com/Sirupsen/logrus" + "github.com/andygrunwald/go-jira" + "github.com/matrix-org/go-neb/database" + "github.com/matrix-org/go-neb/matrix" + "github.com/matrix-org/go-neb/plugin" + "github.com/matrix-org/go-neb/realms/jira" + "github.com/matrix-org/go-neb/types" + "net/http" + "regexp" + "strings" +) + +// Matches alphas then a -, then a number. E.g "FOO-123" +var issueKeyRegex = regexp.MustCompile("([A-z]+)-([0-9]+)") +var projectKeyRegex = regexp.MustCompile("^[A-z]+$") + +type jiraService struct { + id string + UserID string + Rooms []string +} + +func (s *jiraService) ServiceUserID() string { return s.UserID } +func (s *jiraService) ServiceID() string { return s.id } +func (s *jiraService) ServiceType() string { return "jira" } +func (s *jiraService) RoomIDs() []string { return s.Rooms } +func (s *jiraService) Register() error { return nil } +func (s *jiraService) PostRegister(old types.Service) {} +func (s *jiraService) Plugin(roomID string) plugin.Plugin { + return plugin.Plugin{ + Commands: []plugin.Command{ + plugin.Command{ + Path: []string{"jira", "create"}, + Command: func(roomID, userID string, args []string) (interface{}, error) { + // E.g jira create PROJ "Issue title" "Issue desc" + if len(args) <= 1 { + return nil, errors.New("Missing project key (e.g 'ABC') and/or title") + } + + if !projectKeyRegex.MatchString(args[0]) { + return nil, errors.New("Project key must only contain A-Z.") + } + + pkey := strings.ToUpper(args[0]) // REST API complains if they are not ALL CAPS + + title := args[1] + desc := "" + if len(args) == 3 { + desc = args[2] + } else if len(args) > 3 { // > 3 args is probably a title without quote marks + joinedTitle := strings.Join(args[1:], " ") + title = joinedTitle + } + + r, err := s.projectToRealm(userID, pkey) + if err != nil { + log.WithError(err).Print("Failed to map project key to realm") + return nil, errors.New("Failed to map project key to a JIRA endpoint.") + } + if r == nil { + return nil, errors.New("No known project exists with that project key.") + } + + iss := jira.Issue{ + Fields: &jira.IssueFields{ + Summary: title, + Description: desc, + Project: jira.Project{ + Key: pkey, + }, + // FIXME: This may vary depending on the JIRA install! + Type: jira.IssueType{ + Name: "Bug", + }, + }, + } + cli, err := r.JIRAClient(userID, false) + if err != nil { + return nil, err + } + i, res, err := cli.Issue.Create(&iss) + if err != nil { + log.WithFields(log.Fields{ + log.ErrorKey: err, + "user_id": userID, + "project": pkey, + "realm_id": r.ID(), + }).Print("Failed to create issue") + return nil, errors.New("Failed to create issue") + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("Failed to create issue: JIRA returned %d", res.StatusCode) + } + + return &matrix.TextMessage{ + "m.notice", + fmt.Sprintf("Created issue: %s", i.Key), + }, nil + }, + }, + }, + } +} +func (s *jiraService) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli *matrix.Client) { + w.WriteHeader(200) // Do nothing +} + +func (s *jiraService) projectToRealm(userID, pkey string) (*realms.JIRARealm, error) { + // We don't know which JIRA installation this project maps to, so: + // - Get all known JIRA realms and f.e query their endpoints with the + // given user ID's credentials (so if it is a private project they + // can see it will succeed.) + // - If there is a matching project with that key, return that realm. + // We search installations which the user has already OAuthed with first as most likely + // the project key will be on a JIRA they have access to. + // TODO: Return whether they have authed or not so they know if they need to make a starter link + logger := log.WithFields(log.Fields{ + "user_id": userID, + "project": pkey, + }) + knownRealms, err := database.GetServiceDB().LoadAuthRealmsByType("jira") + if err != nil { + logger.WithError(err).Print("Failed to load jira auth realms") + return nil, err + } + // typecast and move ones which the user has authed with to the front of the queue + var queue []*realms.JIRARealm + var unauthRealms []*realms.JIRARealm + for _, r := range knownRealms { + jrealm, ok := r.(*realms.JIRARealm) + if !ok { + logger.WithField("realm_id", r.ID()).Print( + "Failed to type-cast 'jira' type realm into JIRARealm", + ) + continue + } + + _, err := database.GetServiceDB().LoadAuthSessionByUser(r.ID(), userID) + if err != nil { + if err == sql.ErrNoRows { + unauthRealms = append(unauthRealms, jrealm) + } else { + logger.WithError(err).WithField("realm_id", r.ID()).Print( + "Failed to load auth sessions for user", + ) + } + continue // this may not have been the match anyway so don't give up! + } + queue = append(queue, jrealm) + } + + // push unauthed realms to the back + queue = append(queue, unauthRealms...) + + for _, jr := range queue { + exists, err := jr.ProjectKeyExists(userID, pkey) + if err != nil { + logger.WithError(err).WithField("realm_id", jr.ID()).Print( + "Failed to check if project key exists on this realm.", + ) + continue // may not have been found anyway so keep searching! + } + if exists { + logger.Info("Project exists on ", jr.ID()) + return jr, nil + } + } + return nil, nil +} + +func init() { + types.RegisterService(func(serviceID, webhookEndpointURL string) types.Service { + return &jiraService{id: serviceID} + }) +}