From 9298f161557fb571cf32ba234110201d2461a1b8 Mon Sep 17 00:00:00 2001 From: Brad Rydzewski Date: Wed, 8 Apr 2015 15:43:59 -0700 Subject: [PATCH] added handlers, rest, angular skeleton --- datastore/bolt/repo.go | 23 ++ datastore/datastore.go | 16 +- server/badge.go | 75 ++++++ server/builds.go | 45 ++++ server/hooks.go | 90 ++++++++ server/login.go | 181 +++++++++++++++ server/logs.go | 27 +++ server/repos.go | 242 ++++++++++++++++++++ server/server.go | 241 +++++++++++++++++++ server/session/session.go | 109 +++++++++ server/static/index.html | 39 ++++ server/static/scripts/controllers/builds.js | 101 ++++++++ server/static/scripts/controllers/repos.js | 92 ++++++++ server/static/scripts/controllers/users.js | 54 +++++ server/static/scripts/drone.js | 140 +++++++++++ server/static/scripts/filters/filter.js | 61 +++++ server/static/scripts/filters/gravatar.js | 32 +++ server/static/scripts/filters/time.js | 45 ++++ server/static/scripts/services/builds.js | 34 +++ server/static/scripts/services/logs.js | 26 +++ server/static/scripts/services/repos.js | 80 +++++++ server/static/scripts/services/tasks.js | 47 ++++ server/static/scripts/services/users.js | 86 +++++++ server/static/scripts/views/build.html | 80 +++++++ server/static/scripts/views/builds.html | 36 +++ server/static/scripts/views/login.html | 3 + server/static/scripts/views/repos.html | 30 +++ server/static/scripts/views/repos_add.html | 8 + server/static/scripts/views/repos_edit.html | 42 ++++ server/static/scripts/views/user.html | 26 +++ server/static/scripts/views/users.html | 37 +++ server/status.go | 75 ++++++ server/subscribe.go | 84 +++++++ server/tasks.go | 46 ++++ server/user.go | 59 +++++ server/users.go | 125 ++++++++++ server/ws.go | 92 ++++++++ 37 files changed, 2625 insertions(+), 4 deletions(-) create mode 100644 server/badge.go create mode 100644 server/builds.go create mode 100644 server/hooks.go create mode 100644 server/login.go create mode 100644 server/logs.go create mode 100644 server/repos.go create mode 100644 server/server.go create mode 100644 server/session/session.go create mode 100644 server/static/index.html create mode 100644 server/static/scripts/controllers/builds.js create mode 100644 server/static/scripts/controllers/repos.js create mode 100644 server/static/scripts/controllers/users.js create mode 100644 server/static/scripts/drone.js create mode 100644 server/static/scripts/filters/filter.js create mode 100644 server/static/scripts/filters/gravatar.js create mode 100644 server/static/scripts/filters/time.js create mode 100644 server/static/scripts/services/builds.js create mode 100644 server/static/scripts/services/logs.js create mode 100644 server/static/scripts/services/repos.js create mode 100644 server/static/scripts/services/tasks.js create mode 100644 server/static/scripts/services/users.js create mode 100644 server/static/scripts/views/build.html create mode 100644 server/static/scripts/views/builds.html create mode 100644 server/static/scripts/views/login.html create mode 100644 server/static/scripts/views/repos.html create mode 100644 server/static/scripts/views/repos_add.html create mode 100644 server/static/scripts/views/repos_edit.html create mode 100644 server/static/scripts/views/user.html create mode 100644 server/static/scripts/views/users.html create mode 100644 server/status.go create mode 100644 server/subscribe.go create mode 100644 server/tasks.go create mode 100644 server/user.go create mode 100644 server/users.go create mode 100644 server/ws.go diff --git a/datastore/bolt/repo.go b/datastore/bolt/repo.go index 771750f5b..c35b2f478 100644 --- a/datastore/bolt/repo.go +++ b/datastore/bolt/repo.go @@ -83,3 +83,26 @@ func (db *DB) DeleteRepo(repo *common.Repo) error { // TODO(bradrydzewski) delete all tasks return t.Commit() } + +// GetSubscriber gets the subscriber by login for the +// named repository. +func (db *DB) GetSubscriber(repo string, login string) (*common.Subscriber, error) { + sub := &common.Subscriber{} + key := []byte(login + "/" + repo) + err := get(db, bucketUserRepos, key, sub) + return sub, err +} + +// InsertSubscriber inserts a subscriber for the named +// repository. +func (db *DB) InsertSubscriber(repo string, sub *common.Subscriber) error { + key := []byte(sub.Login + "/" + repo) + return insert(db, bucketUserRepos, key, sub) +} + +// DeleteSubscriber removes the subscriber by login for the +// named repository. +func (db *DB) DeleteSubscriber(repo string, sub *common.Subscriber) error { + key := []byte(sub.Login + "/" + repo) + return delete(db, bucketUserRepos, key) +} diff --git a/datastore/datastore.go b/datastore/datastore.go index 863c8ebf6..abe574c9d 100644 --- a/datastore/datastore.go +++ b/datastore/datastore.go @@ -42,6 +42,18 @@ type Datastore interface { // DeleteUser deletes the token. DeleteToken(*common.Token) error + // GetSubscriber gets the subscriber by login for the + // named repository. + GetSubscriber(string, string) (*common.Subscriber, error) + + // InsertSubscriber inserts a subscriber for the named + // repository. + InsertSubscriber(string, *common.Subscriber) error + + // DeleteSubscriber removes the subscriber by login for the + // named repository. + DeleteSubscriber(string, *common.Subscriber) error + // GetRepo gets the repository by name. GetRepo(string) (*common.Repo, error) @@ -125,7 +137,3 @@ type Datastore interface { // named repository and build number. UpsertTaskLogs(string, int, int, []byte) error } - -// GetSubscriber(string, string) (*common.Subscriber, error) -// InsertSubscriber(string, *common.Subscriber) error -// DeleteSubscriber(string, string) error diff --git a/server/badge.go b/server/badge.go new file mode 100644 index 000000000..163a4740b --- /dev/null +++ b/server/badge.go @@ -0,0 +1,75 @@ +package server + +import ( + "github.com/gin-gonic/gin" + + "github.com/drone/drone/common" + "github.com/drone/drone/common/ccmenu" + "github.com/drone/drone/common/httputil" +) + +var ( + badgeSuccess = []byte(`buildbuildsuccesssuccess`) + badgeFailure = []byte(`buildbuildfailurefailure`) + badgeStarted = []byte(`buildbuildstartedstarted`) + badgeError = []byte(`buildbuilderrorerror`) + badgeNone = []byte(`buildbuildnonenone`) +) + +// GetBadge accepts a request to retrieve the named +// repo and branhes latest build details from the datastore +// and return an SVG badges representing the build results. +// +// GET /api/badge/:owner/:name/status.svg +// +func GetBadge(c *gin.Context) { + var repo = ToRepo(c) + + // an SVG response is always served, even when error, so + // we can go ahead and set the content type appropriately. + c.Writer.Header().Set("Content-Type", "image/svg+xml") + + // if no commit was found then display + // the 'none' badge, instead of throwing + // an error response + if repo.Last == nil { + c.Writer.Write(badgeNone) + return + } + + switch repo.Last.State { + case common.StateSuccess: + c.Writer.Write(badgeSuccess) + case common.StateFailure: + c.Writer.Write(badgeFailure) + case common.StateError, common.StateKilled: + c.Writer.Write(badgeError) + case common.StatePending, common.StateRunning: + c.Writer.Write(badgeStarted) + default: + c.Writer.Write(badgeNone) + } +} + +// GetCC accepts a request to retrieve the latest build +// status for the given repository from the datastore and +// in CCTray XML format. +// +// GET /api/badge/:host/:owner/:name/cc.xml +// +// TODO(bradrydzewski) this will not return in-progress builds, which it should +func GetCC(c *gin.Context) { + ds := ToDatastore(c) + repo := ToRepo(c) + last, err := ds.GetBuildLast(repo.FullName) + if err != nil { + c.Fail(404, err) + return + } + + link := httputil.GetURL(c.Request) + "/" + repo.FullName + cc := ccmenu.NewCC(repo, last, link) + + c.Writer.Header().Set("Content-Type", "application/xml") + c.XML(200, cc) +} diff --git a/server/builds.go b/server/builds.go new file mode 100644 index 000000000..69f360037 --- /dev/null +++ b/server/builds.go @@ -0,0 +1,45 @@ +package server + +import ( + "strconv" + + "github.com/gin-gonic/gin" +) + +// GetBuild accepts a request to retrieve a build +// from the datastore for the given repository and +// build number. +// +// GET /api/builds/:owner/:name/:number +// +func GetBuild(c *gin.Context) { + ds := ToDatastore(c) + repo := ToRepo(c) + num, err := strconv.Atoi(c.Params.ByName("number")) + if err != nil { + c.Fail(400, err) + return + } + build, err := ds.GetBuild(repo.FullName, num) + if err != nil { + c.Fail(404, err) + } else { + c.JSON(200, build) + } +} + +// GetBuild accepts a request to retrieve a list +// of builds from the datastore for the given repository. +// +// GET /api/builds/:owner/:name +// +func GetBuilds(c *gin.Context) { + ds := ToDatastore(c) + repo := ToRepo(c) + builds, err := ds.GetBuildList(repo.FullName) + if err != nil { + c.Fail(404, err) + } else { + c.JSON(200, builds) + } +} diff --git a/server/hooks.go b/server/hooks.go new file mode 100644 index 000000000..6b27bad84 --- /dev/null +++ b/server/hooks.go @@ -0,0 +1,90 @@ +package server + +import ( + "strings" + + "github.com/drone/drone/common" + // "github.com/bradrydzewski/drone/worker" + "github.com/gin-gonic/gin" +) + +// PostHook accepts a post-commit hook and parses the payload +// in order to trigger a build. +// +// GET /api/hook +// +func PostHook(c *gin.Context) { + remote := ToRemote(c) + store := ToDatastore(c) + + hook, err := remote.Hook(c.Request) + if err != nil { + c.Fail(400, err) + return + } + if hook == nil { + c.Writer.WriteHeader(200) + return + } + if hook.Repo == nil { + c.Writer.WriteHeader(400) + return + } + + // a build may be skipped if the text [CI SKIP] + // is found inside the commit message + if hook.Commit != nil && strings.Contains(hook.Commit.Message, "[CI SKIP]") { + c.Writer.WriteHeader(204) + return + } + + repo, err := store.GetRepo(hook.Repo.FullName) + if err != nil { + c.Fail(404, err) + return + } + + if repo.Disabled || repo.User == nil || (repo.DisablePR && hook.PullRequest != nil) { + c.Writer.WriteHeader(204) + return + } + + user, err := store.GetUser(repo.User.Login) + if err != nil { + c.Fail(500, err) + return + } + + build := &common.Build{} + build.State = common.StatePending + build.Commit = hook.Commit + build.PullRequest = hook.PullRequest + + // featch the .drone.yml file from the database + _, err = remote.Script(user, repo, build) + if err != nil { + c.Fail(404, err) + return + } + + err = store.InsertBuild(repo.FullName, build) + if err != nil { + c.Fail(500, err) + return + } + + // w := worker.Work{ + // User: user, + // Repo: repo, + // Build: build, + // } + + // verify the branches can be built vs skipped + // s, _ := script.ParseBuild(string(yml)) + // if len(hook.PullRequest) == 0 && !s.MatchBranch(hook.Branch) { + // w.WriteHeader(http.StatusOK) + // return + // } + + c.JSON(200, build) +} diff --git a/server/login.go b/server/login.go new file mode 100644 index 000000000..f572c9d75 --- /dev/null +++ b/server/login.go @@ -0,0 +1,181 @@ +package server + +import ( + "fmt" + "log" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/drone/drone/common" + "github.com/drone/drone/common/gravatar" + "github.com/drone/drone/common/httputil" + "github.com/drone/drone/common/oauth2" +) + +// GetLogin accepts a request to authorize the user and to +// return a valid OAuth2 access token. The access token is +// returned as url segment #access_token +// +// GET /authorize +// +func GetLogin(c *gin.Context) { + settings := ToSettings(c) + session := ToSession(c) + store := ToDatastore(c) + + // when dealing with redirects we may need + // to adjust the content type. I cannot, however, + // rememver why, so need to revisit this line. + c.Writer.Header().Del("Content-Type") + + // depending on the configuration a user may + // authenticate with OAuth1, OAuth2 or Basic + // Auth (username and password). This will delegate + // authorization accordingly. + switch { + case settings.Service.OAuth == nil: + getLoginBasic(c) + case settings.Service.OAuth.RequestToken != "": + getLoginOauth1(c) + default: + getLoginOauth2(c) + } + + // exit if authorization fails + // TODO(bradrydzewski) return an error message instead + if c.Writer.Status() != 200 { + return + } + + // get the user from the database + login := ToUser(c) + u, err := store.GetUser(login.Login) + if err != nil { + // if self-registration is disabled we should + // return a notAuthorized error. the only exception + // is if no users exist yet in the system we'll proceed. + if !settings.Service.Open { + count, err := store.GetUserCount() + if err != nil || count != 0 { + c.String(400, "Unable to create account. Registration is closed") + return + } + } + + // create the user account + u = &common.User{} + u.Login = login.Login + u.Token = login.Token + u.Secret = login.Secret + u.Name = login.Name + u.Email = login.Email + u.Gravatar = gravatar.Generate(u.Email) + + // insert the user into the database + if err := store.InsertUser(u); err != nil { + log.Println(err) + c.Fail(400, err) + return + } + + // // if this is the first user, they + // // should be an admin. + //if u.ID == 1 { + if u.Login == "bradrydzewski" { + u.Admin = true + } + } + + // update the user meta data and authorization + // data and cache in the datastore. + u.Token = login.Token + u.Secret = login.Secret + u.Name = login.Name + u.Email = login.Email + u.Gravatar = gravatar.Generate(u.Email) + + if err := store.UpdateUser(u); err != nil { + log.Println(err) + c.Fail(400, err) + return + } + + token, err := session.GenerateToken(c.Request, u) + if err != nil { + log.Println(err) + c.Fail(400, err) + return + } + c.Redirect(303, "/#access_token="+token) +} + +// getLoginOauth2 is the default authorization implementation +// using the oauth2 protocol. +func getLoginOauth2(c *gin.Context) { + var settings = ToSettings(c) + var remote = ToRemote(c) + + var config = &oauth2.Config{ + ClientId: settings.Service.OAuth.Client, + ClientSecret: settings.Service.OAuth.Secret, + Scope: strings.Join(settings.Service.OAuth.Scope, ","), + AuthURL: settings.Service.OAuth.Authorize, + TokenURL: settings.Service.OAuth.AccessToken, + RedirectURL: fmt.Sprintf("%s/authorize", httputil.GetURL(c.Request)), + //settings.Server.Scheme, settings.Server.Hostname), + } + + // get the OAuth code + var code = c.Request.FormValue("code") + //var state = c.Request.FormValue("state") + if len(code) == 0 { + c.Redirect(303, config.AuthCodeURL("random")) + return + } + + // exhange for a token + var trans = &oauth2.Transport{Config: config} + var token, err = trans.Exchange(code) + if err != nil { + c.Fail(400, err) + return + } + + // get user account + user, err := remote.Login(token.AccessToken, token.RefreshToken) + if err != nil { + c.Fail(404, err) + return + } + + // add the user to the request + c.Set("user", user) +} + +// getLoginOauth1 is able to authorize a user with the oauth1 +// authentication protocol. This is used primarily with Bitbucket +// and Stash only, and one day I hope can be removed. +func getLoginOauth1(c *gin.Context) { + +} + +// getLoginBasic is able to authorize a user with a username and +// password. This can be used for systems that do not support oauth. +func getLoginBasic(c *gin.Context) { + var ( + remote = ToRemote(c) + username = c.Request.FormValue("username") + password = c.Request.FormValue("username") + ) + + // get user account + user, err := remote.Login(username, password) + if err != nil { + c.Fail(404, err) + return + } + + // add the user to the request + c.Set("user", user) +} diff --git a/server/logs.go b/server/logs.go new file mode 100644 index 000000000..459182d70 --- /dev/null +++ b/server/logs.go @@ -0,0 +1,27 @@ +package server + +import ( + "strconv" + + "github.com/gin-gonic/gin" +) + +// GetLogs accepts a request to retrieve logs from the +// datastore for the given repository, build and task +// number. +// +// GET /api/logs/:owner/:name/:number/:task +// +func GetLogs(c *gin.Context) { + ds := ToDatastore(c) + repo := ToRepo(c) + build, _ := strconv.Atoi(c.Params.ByName("number")) + task, _ := strconv.Atoi(c.Params.ByName("task")) + + logs, err := ds.GetTaskLogs(repo.FullName, build, task) + if err != nil { + c.Fail(404, err) + } else { + c.Writer.Write(logs) + } +} diff --git a/server/repos.go b/server/repos.go new file mode 100644 index 000000000..e0f1f60f5 --- /dev/null +++ b/server/repos.go @@ -0,0 +1,242 @@ +package server + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + + "github.com/drone/drone/common" + "github.com/drone/drone/common/httputil" + "github.com/drone/drone/common/sshutil" + "github.com/drone/drone/remote" +) + +// repoResp is a data structure used for sending +// repository data to the client, augmented with +// additional repository meta-data. +type repoResp struct { + *common.Repo + Perms *common.Perm `json:"permissions,omitempty"` + Watch *common.Subscriber `json:"subscription,omitempty"` + Params map[string]string `json:"params,omitempty"` +} + +// repoReq is a data structure used for receiving +// repository data from the client to modify the +// attributes of an existing repository. +// +// note that attributes are pointers so that we can +// accept null values, effectively patching an existing +// repository object with only the supplied fields. +type repoReq struct { + Disabled *bool `json:"disabled"` + DisablePR *bool `json:"disable_prs"` + DisableTag *bool `json:"disable_tags"` + Trusted *bool `json:"privileged"` + Timeout *int64 `json:"timeout"` + + // optional private parameters can only be + // supplied by the repository admin. + Params *map[string]string `json:"params"` +} + +// GetRepo accepts a request to retrieve a commit +// from the datastore for the given repository, branch and +// commit hash. +// +// GET /api/repos/:owner/:name +// +func GetRepo(c *gin.Context) { + store := ToDatastore(c) + repo := ToRepo(c) + user := ToUser(c) + perm := ToPerm(c) + data := repoResp{repo, perm, nil, nil} + // if the user is an administrator of the project + // we should display the private parameter data. + if perm.Admin { + data.Params, _ = store.GetRepoParams(repo.FullName) + } + // if the user is authenticated, we should display + // if she is watching the current repository. + if user != nil { + data.Watch, _ = store.GetSubscriber(repo.FullName, user.Login) + } + c.JSON(200, data) +} + +// PutRepo accapets a request to update the named repository +// in the datastore. It expects a JSON input and returns the +// updated repository in JSON format if successful. +// +// PUT /api/repos/:owner/:name +// +func PutRepo(c *gin.Context) { + store := ToDatastore(c) + perm := ToPerm(c) + u := ToUser(c) + r := ToRepo(c) + + in := &repoReq{} + if !c.BindWith(in, binding.JSON) { + return + } + + if in.Params != nil { + err := store.UpsertRepoParams(r.FullName, *in.Params) + if err != nil { + c.Fail(400, err) + return + } + } + if in.Disabled != nil { + r.Disabled = *in.Disabled + } + if in.DisablePR != nil { + r.DisablePR = *in.DisablePR + } + if in.DisableTag != nil { + r.DisableTag = *in.DisableTag + } + if in.Trusted != nil && u.Admin { + r.Trusted = *in.Trusted + } + if in.Timeout != nil && u.Admin { + r.Timeout = *in.Timeout + } + + err := store.UpdateRepo(r) + if err != nil { + c.Fail(400, err) + return + } + + data := repoResp{r, perm, nil, nil} + data.Params, _ = store.GetRepoParams(r.FullName) + data.Watch, _ = store.GetSubscriber(r.FullName, u.Login) + c.JSON(200, data) +} + +// DeleteRepo accepts a request to delete the named +// repository. +// +// DEL /api/repos/:owner/:name +// +func DeleteRepo(c *gin.Context) { + ds := ToDatastore(c) + u := ToUser(c) + r := ToRepo(c) + + link := fmt.Sprintf( + "%s/api/hook", + httputil.GetURL(c.Request), + ) + + remote := ToRemote(c) + err := remote.Deactivate(u, r, link) + if err != nil { + c.Fail(400, err) + } + + err = ds.DeleteRepo(r) + if err != nil { + c.Fail(400, err) + } + c.Writer.WriteHeader(200) +} + +// PostRepo accapets a request to activate the named repository +// in the datastore. It returns a 201 status created if successful +// +// POST /api/repos/:owner/:name +// +func PostRepo(c *gin.Context) { + user := ToUser(c) + store := ToDatastore(c) + owner := c.Params.ByName("owner") + name := c.Params.ByName("name") + + link := fmt.Sprintf( + "%s/api/hook", + httputil.GetURL(c.Request), + ) + + // TODO(bradrydzewski) verify repo not exists + + // get the repository and user permissions + // from the remote system. + remote := ToRemote(c) + r, err := remote.Repo(user, owner, name) + if err != nil { + c.Fail(400, err) + } + m, err := remote.Perm(user, owner, name) + if err != nil { + c.Fail(400, err) + return + } + if !m.Admin { + c.Fail(403, fmt.Errorf("must be repository admin")) + return + } + + // set the repository owner to the + // currently authenticated user. + r.User = user + + // generate an RSA key and add to the repo + key, err := sshutil.GeneratePrivateKey() + if err != nil { + c.Fail(400, err) + return + } + keypair := &common.Keypair{} + keypair.Public = sshutil.MarshalPublicKey(&key.PublicKey) + keypair.Private = sshutil.MarshalPrivateKey(key) + err = store.UpsertRepoKeys(r.FullName, keypair) + if err != nil { + c.Fail(500, err) + return + } + + // store the repository and the users' permissions + // in the datastore. + err = store.InsertRepo(user, r) + if err != nil { + c.Fail(500, err) + return + } + err = store.InsertSubscriber(r.FullName, &common.Subscriber{Subscribed: true}) + if err != nil { + c.Fail(500, err) + return + } + + err = remote.Activate(user, r, keypair, link) + if err != nil { + c.Fail(500, err) + return + } + + c.JSON(200, r) +} + +// perms is a helper function that returns user permissions +// for a particular repository. +func perms(remote remote.Remote, u *common.User, r *common.Repo) *common.Perm { + switch { + case u == nil && r.Private: + return &common.Perm{} + case u == nil && r.Private == false: + return &common.Perm{Pull: true} + case u.Admin: + return &common.Perm{Pull: true, Push: true, Admin: true} + } + + p, err := remote.Perm(u, r.Owner, r.Name) + if err != nil { + return &common.Perm{} + } + return p +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 000000000..a16767b6a --- /dev/null +++ b/server/server.go @@ -0,0 +1,241 @@ +package server + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/drone/drone/common" + "github.com/drone/drone/datastore" + "github.com/drone/drone/eventbus" + "github.com/drone/drone/remote" + "github.com/drone/drone/server/session" + "github.com/drone/drone/settings" +) + +func SetBus(r eventbus.Bus) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("eventbus", r) + c.Next() + } +} + +func ToBus(c *gin.Context) eventbus.Bus { + v, err := c.Get("eventbus") + if err != nil { + return nil + } + return v.(eventbus.Bus) +} + +func ToRemote(c *gin.Context) remote.Remote { + v, err := c.Get("remote") + if err != nil { + return nil + } + return v.(remote.Remote) +} + +func SetRemote(r remote.Remote) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("remote", r) + c.Next() + } +} + +func ToSettings(c *gin.Context) *settings.Settings { + v, err := c.Get("settings") + if err != nil { + return nil + } + return v.(*settings.Settings) +} + +func SetSettings(s *settings.Settings) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("settings", s) + c.Next() + } +} + +func ToPerm(c *gin.Context) *common.Perm { + v, err := c.Get("perm") + if err != nil { + return nil + } + return v.(*common.Perm) +} + +func ToUser(c *gin.Context) *common.User { + v, err := c.Get("user") + if err != nil { + return nil + } + return v.(*common.User) +} + +func ToRepo(c *gin.Context) *common.Repo { + v, err := c.Get("repo") + if err != nil { + return nil + } + return v.(*common.Repo) +} + +func ToDatastore(c *gin.Context) datastore.Datastore { + return c.MustGet("datastore").(datastore.Datastore) +} + +func ToSession(c *gin.Context) session.Session { + return c.MustGet("session").(session.Session) +} + +func SetDatastore(ds datastore.Datastore) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("datastore", ds) + c.Next() + } +} + +func SetSession(s session.Session) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("session", s) + c.Next() + } +} + +func SetUser(s session.Session) gin.HandlerFunc { + return func(c *gin.Context) { + ds := ToDatastore(c) + login := s.GetLogin(c.Request) + if len(login) == 0 { + c.Next() + return + } + + u, err := ds.GetUser(login) + if err == nil { + c.Set("user", u) + } + } +} + +func SetRepo() gin.HandlerFunc { + return func(c *gin.Context) { + ds := ToDatastore(c) + u := ToUser(c) + owner := c.Params.ByName("owner") + name := c.Params.ByName("name") + r, err := ds.GetRepo(owner + "/" + name) + switch { + case err != nil && u != nil: + c.Fail(401, err) + return + case err != nil && u == nil: + c.Fail(404, err) + return + } + c.Set("repo", r) + c.Next() + } +} + +func SetPerm() gin.HandlerFunc { + return func(c *gin.Context) { + remote := ToRemote(c) + user := ToUser(c) + repo := ToRepo(c) + perm := perms(remote, user, repo) + c.Set("perm", perm) + c.Next() + } +} + +func MustUser() gin.HandlerFunc { + return func(c *gin.Context) { + u := ToUser(c) + if u == nil { + c.AbortWithStatus(401) + } else { + c.Set("user", u) + c.Next() + } + } +} + +func MustAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + u := ToUser(c) + if u == nil { + c.AbortWithStatus(401) + } else if !u.Admin { + c.AbortWithStatus(403) + } else { + c.Set("user", u) + c.Next() + } + } +} + +func CheckPull() gin.HandlerFunc { + return func(c *gin.Context) { + u := ToUser(c) + m := ToPerm(c) + + switch { + case u == nil && m == nil: + c.AbortWithStatus(401) + case u == nil && m.Pull == false: + c.AbortWithStatus(401) + case u != nil && m.Pull == false: + c.AbortWithStatus(404) + default: + c.Next() + } + } +} + +func CheckPush() gin.HandlerFunc { + return func(c *gin.Context) { + switch c.Request.Method { + case "GET", "OPTIONS": + c.Next() + return + } + + u := ToUser(c) + m := ToPerm(c) + + switch { + case u == nil && m.Push == false: + c.AbortWithStatus(401) + case u != nil && m.Push == false: + c.AbortWithStatus(404) + default: + c.Next() + } + } +} + +func SetHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + + c.Writer.Header().Add("Access-Control-Allow-Origin", "*") + c.Writer.Header().Add("X-Frame-Options", "DENY") + c.Writer.Header().Add("X-Content-Type-Options", "nosniff") + c.Writer.Header().Add("X-XSS-Protection", "1; mode=block") + c.Writer.Header().Add("Cache-Control", "no-cache") + c.Writer.Header().Add("Cache-Control", "no-store") + c.Writer.Header().Add("Cache-Control", "max-age=0") + c.Writer.Header().Add("Cache-Control", "must-revalidate") + c.Writer.Header().Add("Cache-Control", "value") + c.Writer.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) + c.Writer.Header().Set("Expires", "Thu, 01 Jan 1970 00:00:00 GMT") + if c.Request.TLS != nil { + c.Writer.Header().Add("Strict-Transport-Security", "max-age=31536000") + } + + c.Next() + } +} diff --git a/server/session/session.go b/server/session/session.go new file mode 100644 index 000000000..88a5164b8 --- /dev/null +++ b/server/session/session.go @@ -0,0 +1,109 @@ +package session + +import ( + "fmt" + "net/http" + "time" + + "github.com/dgrijalva/jwt-go" + "github.com/drone/drone/common" + "github.com/drone/drone/common/httputil" + "github.com/drone/drone/settings" + "github.com/gorilla/securecookie" +) + +type Session interface { + GenerateToken(*http.Request, *common.User) (string, error) + GetLogin(*http.Request) string +} + +type session struct { + secret []byte + expire time.Duration +} + +func New(s *settings.Session) Session { + secret := securecookie.GenerateRandomKey(32) + expire := time.Hour * 72 + return &session{ + secret: secret, + expire: expire, + } +} + +// GenerateToken generates a JWT token for the user session +// that can be appended to the #access_token segment to +// facilitate client-based OAuth2. +func (s *session) GenerateToken(r *http.Request, user *common.User) (string, error) { + token := jwt.New(jwt.GetSigningMethod("HS256")) + token.Claims["user_id"] = user.Login + token.Claims["audience"] = httputil.GetURL(r) + token.Claims["expires"] = time.Now().UTC().Add(s.expire).Unix() + return token.SignedString(s.secret) +} + +// GetLogin gets the currently authenticated user for the +// http.Request. The user details will be stored as either +// a simple API token or JWT bearer token. +func (s *session) GetLogin(r *http.Request) (_ string) { + token := getToken(r) + if len(token) == 0 { + return + } + + claims := getClaims(token, s.secret) + if claims == nil || claims["user_id"] == nil { + return + } + + userid, ok := claims["user_id"].(string) + if !ok { + return + } + + // tokenid, ok := claims["token_id"].(string) + // if ok { + // _, err := datastore.GetToken(c, int64(tokenid)) + // if err != nil { + // return nil + // } + // } + + return userid +} + +// getToken is a helper function that extracts the token +// from the http.Request. +func getToken(r *http.Request) string { + token := getTokenHeader(r) + if len(token) == 0 { + token = getTokenParam(r) + } + return token +} + +// getTokenHeader parses the JWT token value from +// the http Authorization header. +func getTokenHeader(r *http.Request) string { + var tokenstr = r.Header.Get("Authorization") + fmt.Sscanf(tokenstr, "Bearer %s", &tokenstr) + return tokenstr +} + +// getTokenParam parses the JWT token value from +// the http Request's query parameter. +func getTokenParam(r *http.Request) string { + return r.FormValue("access_token") +} + +// getClaims is a helper function that extracts the token +// claims from the JWT token string. +func getClaims(token string, secret []byte) map[string]interface{} { + t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { + return secret, nil + }) + if err != nil || !t.Valid { + return nil + } + return t.Claims +} diff --git a/server/static/index.html b/server/static/index.html new file mode 100644 index 000000000..6aad5f817 --- /dev/null +++ b/server/static/index.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/server/static/scripts/controllers/builds.js b/server/static/scripts/controllers/builds.js new file mode 100644 index 000000000..64b0d5207 --- /dev/null +++ b/server/static/scripts/controllers/builds.js @@ -0,0 +1,101 @@ +(function () { + + /** + * BuildsCtrl responsible for rendering the repo's + * recent build history. + */ + function BuildsCtrl($scope, $routeParams, builds, repos, users) { + + var owner = $routeParams.owner; + var name = $routeParams.name; + var fullName = owner+'/'+name; + + // Gets the currently authenticated user + users.getCached().then(function(payload){ + $scope.user = payload.data; + }); + + // Gets a repository + repos.get(fullName).then(function(payload){ + $scope.repo = payload.data; + }).catch(function(err){ + $scope.error = err; + }); + + // Gets a list of builds + builds.list(fullName).then(function(payload){ + $scope.builds = angular.isArray(payload.data) ? payload.data : []; + }).catch(function(err){ + $scope.error = err; + }); + + $scope.watch = function(repo) { + repos.watch(repo.full_name).then(function(payload) { + $scope.repo.subscription = payload.data; + }); + } + + $scope.unwatch = function(repo) { + repos.unwatch(repo.full_name).then(function() { + delete $scope.repo.subscription; + }); + } + } + + /** + * BuildCtrl responsible for rendering a build. + */ + function BuildCtrl($scope, $routeParams, logs, tasks, builds, repos, users) { + + var step = parseInt($routeParams.step) || 1; + var number = $routeParams.number; + var owner = $routeParams.owner; + var name = $routeParams.name; + var fullName = owner+'/'+name; + + // Gets the currently authenticated user + users.getCached().then(function(payload){ + $scope.user = payload.data; + }); + + // Gets a repository + repos.get(fullName).then(function(payload){ + $scope.repo = payload.data; + }).catch(function(err){ + $scope.error = err; + }); + + // Gets the build + builds.get(fullName, number).then(function(payload){ + $scope.build = payload.data; + }).catch(function(err){ + $scope.error = err; + }); + + // Gets a list of build steps + tasks.list(fullName, number).then(function(payload){ + $scope.tasks = payload.data || []; + $scope.tasks.forEach(function(task) { + if (task.number === step) { + $scope.task = task; + } + }); + }).catch(function(err){ + $scope.error = err; + }); + + if (step) { + // Gets a list of build steps + logs.get(fullName, number, step).then(function(payload){ + $scope.logs = payload.data; + }).catch(function(err){ + $scope.error = err; + }); + } + } + + angular + .module('drone') + .controller('BuildCtrl', BuildCtrl) + .controller('BuildsCtrl', BuildsCtrl); +})(); \ No newline at end of file diff --git a/server/static/scripts/controllers/repos.js b/server/static/scripts/controllers/repos.js new file mode 100644 index 000000000..cb86a7ae4 --- /dev/null +++ b/server/static/scripts/controllers/repos.js @@ -0,0 +1,92 @@ +(function () { + + /** + * ReposCtrl responsible for rendering the user's + * repository home screen. + */ + function ReposCtrl($scope, $routeParams, repos, users) { + + // Gets the currently authenticated user + users.getCached().then(function(payload){ + $scope.user = payload.data; + }); + + // Gets a list of repos to display in the + // dropdown. + repos.list().then(function(payload){ + $scope.repos = angular.isArray(payload.data) ? payload.data : []; + }).catch(function(err){ + $scope.error = err; + }); + } + + /** + * RepoAddCtrl responsible for activaing a new + * repository. + */ + function RepoAddCtrl($scope, $location, repos, users) { + $scope.add = function(slug) { + repos.post(slug).then(function(payload) { + $location.path('/'+slug); + }).catch(function(err){ + $scope.error = err; + }); + } + } + + /** + * RepoEditCtrl responsible for editing a repository. + */ + function RepoEditCtrl($scope, $location, $routeParams, repos, users) { + var owner = $routeParams.owner; + var name = $routeParams.name; + var fullName = owner+'/'+name; + + // Gets the currently authenticated user + users.getCached().then(function(payload){ + $scope.user = payload.data; + }); + + // Gets a repository + repos.get(fullName).then(function(payload){ + $scope.repo = payload.data; + }).catch(function(err){ + $scope.error = err; + }); + + $scope.save = function(repo) { + repos.update(repo).then(function(payload) { + $scope.repo = payload.data; + }).catch(function(err){ + $scope.error = err; + }); + } + + $scope.delete = function(repo) { + repos.delete(repo).then(function(payload) { + $location.path('/'); + }).catch(function(err){ + $scope.error = err; + }); + } + + $scope.param={} + $scope.addParam = function(param) { + if (!$scope.repo.params) { + $scope.repo.params = {} + } + $scope.repo.params[param.key]=param.value; + $scope.param={} + } + + $scope.deleteParam = function(key) { + delete $scope.repo.params[key]; + } + } + + angular + .module('drone') + .controller('ReposCtrl', ReposCtrl) + .controller('RepoAddCtrl', RepoAddCtrl) + .controller('RepoEditCtrl', RepoEditCtrl); +})(); \ No newline at end of file diff --git a/server/static/scripts/controllers/users.js b/server/static/scripts/controllers/users.js new file mode 100644 index 000000000..0b5ba8c4f --- /dev/null +++ b/server/static/scripts/controllers/users.js @@ -0,0 +1,54 @@ +(function () { + + /** + * UserCtrl is responsible for managing user settings. + */ + function UserCtrl($scope, users) { + + // Gets the currently authenticated user + users.getCurrent().then(function(payload){ + $scope.user = payload.data; + }); + } + + /** + * UsersCtrl is responsible for managing user accounts. + * This part of the site is for administrators only. + */ + function UsersCtrl($scope, users) { + // Gets the currently authenticated user + users.getCached().then(function(payload){ + $scope.user = payload.data; + }); + + users.list().then(function(payload){ + $scope.users = payload.data; + }); + + $scope.login=""; + $scope.add = function(login) { + users.post(login).then(function(payload){ + $scope.users.push(payload.data); + $scope.login=""; + }); + } + + $scope.toggle = function(user) { + user.admin = !user.admin; + users.put(user); + } + + $scope.remove = function(user) { + users.delete(user).then(function(){ + users.list().then(function(payload){ + $scope.users = payload.data; + }); + }); + } + } + + angular + .module('drone') + .controller('UserCtrl', UserCtrl) + .controller('UsersCtrl', UsersCtrl); +})(); \ No newline at end of file diff --git a/server/static/scripts/drone.js b/server/static/scripts/drone.js new file mode 100644 index 000000000..3f9db519e --- /dev/null +++ b/server/static/scripts/drone.js @@ -0,0 +1,140 @@ +'use strict'; + +(function () { + + /** + * Creates the angular application. + */ + angular.module('drone', [ + 'ngRoute', + 'ui.filters' + ]); + + /** + * Bootstraps the application and retrieves the + * token from the + */ + function Authorize() { + // First, parse the query string + var params = {}, queryString = location.hash.substring(1), + regex = /([^&=]+)=([^&]*)/g, m; + + // Loop through and retrieve the token + while (m = regex.exec(queryString)) { + params[decodeURIComponent(m[1])] = decodeURIComponent(m[2]); + } + + // if the user has just received an auth token we + // should extract from the URL, save to local storage + // and then remove from the URL for good measure. + if (params.access_token) { + localStorage.setItem("access_token", params.access_token); + history.replaceState({}, document.title, location.pathname); + } + } + + /** + * Defines the route configuration for the + * main application. + */ + function Config ($routeProvider, $httpProvider, $locationProvider) { + + // Resolver that will attempt to load the currently + // authenticated user prior to loading the page. + var resolveUser = { + user: function(users) { + return users.getCached(); + } + } + + $routeProvider + .when('/', { + templateUrl: '/static/scripts/views/repos.html', + controller: 'ReposCtrl', + resolve: resolveUser + }) + .when('/login', { + templateUrl: '/static/scripts/views/login.html', + }) + .when('/profile', { + templateUrl: '/static/scripts/views/user.html', + controller: 'UserCtrl', + resolve: resolveUser + }) + .when('/users', { + templateUrl: '/static/scripts/views/users.html', + controller: 'UsersCtrl', + resolve: resolveUser + }) + .when('/new', { + templateUrl: '/static/scripts/views/repos_add.html', + controller: 'RepoAddCtrl', + resolve: resolveUser + }) + .when('/:owner/:name', { + templateUrl: '/static/scripts/views/builds.html', + controller: 'BuildsCtrl', + resolve: resolveUser + }) + .when('/:owner/:name/edit', { + templateUrl: '/static/scripts/views/repos_edit.html', + controller: 'RepoEditCtrl', + resolve: resolveUser + }) + .when('/:owner/:name/:number', { + templateUrl: '/static/scripts/views/build.html', + controller: 'BuildCtrl', + resolve: resolveUser + }) + .when('/:owner/:name/:number/:step', { + templateUrl: '/static/scripts/views/build.html', + controller: 'BuildCtrl', + resolve: resolveUser + }); + + // Enables html5 mode + $locationProvider.html5Mode(true) + + // Appends the Bearer token to authorize every + // outbound http request. + $httpProvider.defaults.headers.common.Authorization = 'Bearer '+localStorage.getItem('access_token'); + + // Intercepts every oubput http response and redirects + // the user to the logic screen if the request was rejected. + $httpProvider.interceptors.push(function($q, $location) { + return { + 'responseError': function(rejection) { + if (rejection.status === 401) {// && rejection.config.url != "/api/user") { + $location.path('/login'); + } + if (rejection.status === 0) { + // this happens when the app is down or + // the browser loses internet connectivity. + } + return $q.reject(rejection); + } + }; + }); + } + + // /** + // * + // */ + // function RouteChange($rootScope, stdout, projs) { + // $rootScope.$on('$routeChangeStart', function (event, next) { + // projs.unsubscribe(); + // stdout.unsubscribe(); + // }); + + // //$rootScope.$on('$routeChangeSuccess', function (event, current, previous) { + // // document.title = current.$$route.title + ' ยท drone.io'; + // //}); + // } + + angular + .module('drone') + .config(Authorize) + .config(Config); + // .run(RouteChange); + +})(); \ No newline at end of file diff --git a/server/static/scripts/filters/filter.js b/server/static/scripts/filters/filter.js new file mode 100644 index 000000000..b9cf15fce --- /dev/null +++ b/server/static/scripts/filters/filter.js @@ -0,0 +1,61 @@ +'use strict'; + +(function () { + + /** + * author is a helper function that return the builds + * commit or pull request author. + */ + function author() { + return function(build) { + if (!build) { return ""; } + if (!build.head_commit && !build.pull_request) { return ""; } + if (build.head_commit) { return build.head_commit.author.login || ""; } + return build.pull_request.source.author.login; + } + } + + /** + * sha is a helper function that return the builds sha. + */ + function sha() { + return function(build) { + if (!build) { return ""; } + if (!build.head_commit && !build.pull_request) { return ""; } + if (build.head_commit) { return build.head_commit.sha || ""; } + return build.pull_request.source.sha; + } + } + + /** + * ref is a helper function that return the builds sha. + */ + function ref() { + return function(build) { + if (!build) { return ""; } + if (!build.head_commit && !build.pull_request) { return ""; } + if (build.head_commit) { return build.head_commit.ref || ""; } + return build.pull_request.source.ref; + } + } + + /** + * message is a helper function that return the builds message. + */ + function message() { + return function(build) { + if (!build) { return ""; } + if (!build.head_commit && !build.pull_request) { return ""; } + if (build.head_commit) { return build.head_commit.message || ""; } + return build.pull_request.title || ""; + } + } + + angular + .module('drone') + .filter('author', author) + .filter('message', message) + .filter('sha', sha) + .filter('ref', ref); + +})(); diff --git a/server/static/scripts/filters/gravatar.js b/server/static/scripts/filters/gravatar.js new file mode 100644 index 000000000..2fbe71f86 --- /dev/null +++ b/server/static/scripts/filters/gravatar.js @@ -0,0 +1,32 @@ +'use strict'; + +(function () { + + /** + * gravatar is a helper function that return the user's gravatar + * image URL given an email hash. + */ + function gravatar() { + return function(hash) { + if (hash === undefined) { return ""; } + return "https://secure.gravatar.com/avatar/"+hash+"?s=48&d=mm"; + } + } + + /** + * gravatarLarge is a helper function that return the user's gravatar + * image URL given an email hash. + */ + function gravatarLarge() { + return function(hash) { + if (hash === undefined) { return ""; } + return "https://secure.gravatar.com/avatar/"+hash+"?s=128&d=mm"; + } + } + + angular + .module('drone') + .filter('gravatar', gravatar) + .filter('gravatarLarge', gravatarLarge) + +})(); diff --git a/server/static/scripts/filters/time.js b/server/static/scripts/filters/time.js new file mode 100644 index 000000000..5404e497e --- /dev/null +++ b/server/static/scripts/filters/time.js @@ -0,0 +1,45 @@ +'use strict'; + +(function () { + + /** + * fromNow is a helper function that returns a human readable + * string for the elapsed time between the given unix date and the + * current time (ex. 10 minutes ago). + */ + function fromNow() { + return function(date) { + if (!date) { + return; + } + return moment(new Date(date*1000)).fromNow(); + } + } + + /** + * toDuration is a helper function that returns a human readable + * string for the given duration in seconds (ex. 1 hour and 20 minutes). + */ + function toDuration() { + return function(seconds) { + return moment.duration(seconds, "seconds").humanize(); + } + } + + /** + * toDate is a helper function that returns a human readable + * string gor the given unix date. + */ + function toDate() { + return function(date) { + return moment(new Date(date*1000)).format('ll'); + } + } + + angular + .module('drone') + .filter('fromNow', fromNow) + .filter('toDate', toDate) + .filter('toDuration', toDuration) + +})(); diff --git a/server/static/scripts/services/builds.js b/server/static/scripts/services/builds.js new file mode 100644 index 000000000..e59ccad7c --- /dev/null +++ b/server/static/scripts/services/builds.js @@ -0,0 +1,34 @@ +'use strict'; + +(function () { + + /** + * The BuildsService provides access to build + * data using REST API calls. + */ + function BuildService($http, $window) { + + /** + * Gets a list of builds. + * + * @param {string} Name of the repository. + */ + this.list = function(repoName) { + return $http.get('/api/builds/'+repoName); + }; + + /** + * Gets a build. + * + * @param {string} Name of the repository. + * @param {number} Number of the build. + */ + this.get = function(repoName, buildNumber) { + return $http.get('/api/builds/'+repoName+'/'+buildNumber); + }; + } + + angular + .module('drone') + .service('builds', BuildService); +})(); \ No newline at end of file diff --git a/server/static/scripts/services/logs.js b/server/static/scripts/services/logs.js new file mode 100644 index 000000000..144972fe0 --- /dev/null +++ b/server/static/scripts/services/logs.js @@ -0,0 +1,26 @@ +'use strict'; + +(function () { + + /** + * The LogService provides access to build + * log data using REST API calls. + */ + function LogService($http, $window) { + + /** + * Gets a task logs. + * + * @param {string} Name of the repository. + * @param {number} Number of the build. + * @param {number} Number of the task. + */ + this.get = function(repoName, number, step) { + return $http.get('/api/logs/'+repoName+'/'+number+'/'+step); + }; + } + + angular + .module('drone') + .service('logs', LogService); +})(); \ No newline at end of file diff --git a/server/static/scripts/services/repos.js b/server/static/scripts/services/repos.js new file mode 100644 index 000000000..4e13337c2 --- /dev/null +++ b/server/static/scripts/services/repos.js @@ -0,0 +1,80 @@ +'use strict'; + +(function () { + + /** + * The RepoService provides access to repository + * data using REST API calls. + */ + function RepoService($http, $window) { + + var callback, + websocket, + token = localStorage.getItem('access_token'); + + /** + * Gets a list of all repositories. + */ + this.list = function() { + return $http.get('/api/user/repos'); + }; + + /** + * Gets a repository by name. + * + * @param {string} Name of the repository. + */ + this.get = function(repoName) { + return $http.get('/api/repos/'+repoName); + }; + + /** + * Creates a new repository. + * + * @param {object} JSON representation of a repository. + */ + this.post = function(repoName) { + return $http.post('/api/repos/' + repoName); + }; + + /** + * Updates an existing repository. + * + * @param {object} JSON representation of a repository. + */ + this.update = function(repo) { + return $http.put('/api/repos/'+repo.full_name, repo); + }; + + /** + * Deletes a repository. + * + * @param {string} Name of the repository. + */ + this.delete = function(repoName) { + return $http.delete('/api/repos/'+repoName); + }; + + /** + * Watch a repository. + * + * @param {string} Name of the repository. + */ + this.watch = function(repoName) { + return $http.post('/api/subscribers/'+repoName); + }; + + /** + * Unwatch a repository. + * + * @param {string} Name of the repository. + */ + this.unwatch = function(repoName) { + return $http.delete('/api/subscribers/'+repoName); + }; + } + + angular + .module('drone') + .service('repos', RepoService); +})(); \ No newline at end of file diff --git a/server/static/scripts/services/tasks.js b/server/static/scripts/services/tasks.js new file mode 100644 index 000000000..11bd59aa9 --- /dev/null +++ b/server/static/scripts/services/tasks.js @@ -0,0 +1,47 @@ +'use strict'; + +(function () { + + /** + * The TaskService provides access to build + * task data using REST API calls. + */ + function TaskService($http, $window) { + + /** + * Gets a list of builds. + * + * @param {string} Name of the repository. + * @param {number} Number of the build. + */ + this.list = function(repoName, number) { + return $http.get('/api/tasks/'+repoName+'/'+number); + }; + + /** + * Gets a task. + * + * @param {string} Name of the repository. + * @param {number} Number of the build. + * @param {number} Number of the task. + */ + this.get = function(repoName, number, step) { + return $http.get('/api/tasks/'+repoName+'/'+name+'/'+step); + }; + + /** + * Gets a task. + * + * @param {string} Name of the repository. + * @param {number} Number of the build. + * @param {number} Number of the task. + */ + this.get = function(repoName, number, step) { + return $http.get('/api/tasks/'+repoName+'/'+name+'/'+step); + }; + } + + angular + .module('drone') + .service('tasks', TaskService); +})(); \ No newline at end of file diff --git a/server/static/scripts/services/users.js b/server/static/scripts/services/users.js new file mode 100644 index 000000000..4d4d1f2c1 --- /dev/null +++ b/server/static/scripts/services/users.js @@ -0,0 +1,86 @@ +'use strict'; + +(function () { + + /** + * Cached user object. + */ + var _user; + + /** + * The UserService provides access to useer + * data using REST API calls. + */ + function UserService($http, $q) { + + /** + * Gets a list of all users. + */ + this.list = function() { + return $http.get('/api/users'); + }; + + /** + * Gets a user by login. + */ + this.get = function(login) { + return $http.get('/api/users/'+login); + }; + + /** + * Gets the currently authenticated user. + */ + this.getCurrent = function() { + return $http.get('/api/user'); + }; + + /** + * Updates an existing user + */ + this.post = function(user) { + return $http.post('/api/users/'+user); + }; + + /** + * Updates an existing user + */ + this.put = function(user) { + return $http.put('/api/users/'+user.login, user); + }; + + /** + * Deletes a user. + */ + this.delete = function(user) { + return $http.delete('/api/users/'+user.login); + }; + + /** + * Gets the currently authenticated user from + * the local cache. If not exists, it will fetch + * from the server. + */ + this.getCached = function() { + var defer = $q.defer(); + + // if the user is already authenticated + if (_user) { + defer.resolve(_user); + return defer.promise; + } + + // else fetch the currently authenticated + // user using the REST API. + this.getCurrent().then(function(payload){ + _user=payload; + defer.resolve(_user); + }); + + return defer.promise; + } + } + + angular + .module('drone') + .service('users', UserService); +})(); diff --git a/server/static/scripts/views/build.html b/server/static/scripts/views/build.html new file mode 100644 index 000000000..173896ff7 --- /dev/null +++ b/server/static/scripts/views/build.html @@ -0,0 +1,80 @@ +

{{ repo.full_name }}/{{ build.number }}

+ +Back + +
+
Build State
+
{{ build.state }}
+ +
Started
+
{{ build.started_at | fromNow }}
+ +
Duration
+
{{ build.duration | toDuration }}
+ +
Type
+
{{ build.head_commit ? "push" : "pull request" }}
+ +
Ref
+
{{ build | ref }}
+ +
Sha
+
{{ build | sha }}
+ +
Author
+
{{ build | author }}
+ +
Message
+
{{ build | message }}
+
+ +
+ +
+
Task State
+
{{ task.state }}
+ +
Started
+
{{ task.started_at | fromNow }}
+ +
Finished
+
{{ task.finished_at | fromNow }}
+ +
Duration
+
{{ task.duration | toDuration }}
+ +
Exit Code
+
{{ task.exit_code }}
+ +
Matrix
+
{{ task.environment }}
+
+ +
+ +
{{ logs }}
+ + + + + + + + + + + + + + + + + + + + + + + + +
NumberStatusStartedFinishedDurationExit CodeMatrix
{{ task.number }}{{ task.state }}{{ task.started_at | fromNow }}{{ task.finished_at | fromNow }}{{ task.duration | toDuration }}{{ task.exit_code }}{{ task.environment }}
diff --git a/server/static/scripts/views/builds.html b/server/static/scripts/views/builds.html new file mode 100644 index 000000000..7fb5e6d98 --- /dev/null +++ b/server/static/scripts/views/builds.html @@ -0,0 +1,36 @@ +

{{ repo.full_name }}

+ +Back +Settings + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NumberStatusStartedDurationTypeRefCommitAuthorMessage
{{ build.number }}{{ build.state }}{{ build.started_at | fromNow }}{{ build.duration | toDuration }}{{ build.head_commit ? "push" : "pull request" }}{{ build | ref }}{{ build | sha }}{{ build | author }}{{ build | message }}
\ No newline at end of file diff --git a/server/static/scripts/views/login.html b/server/static/scripts/views/login.html new file mode 100644 index 000000000..a4ae37938 --- /dev/null +++ b/server/static/scripts/views/login.html @@ -0,0 +1,3 @@ +

Login

+ +Login \ No newline at end of file diff --git a/server/static/scripts/views/repos.html b/server/static/scripts/views/repos.html new file mode 100644 index 000000000..701c26e43 --- /dev/null +++ b/server/static/scripts/views/repos.html @@ -0,0 +1,30 @@ +

Dashboard

+ +New +Settings +User Management + + + + + + + + + + + + + + + + + + + + + + + + +
RepoStatusNumberStartedDurationBranchCommit
{{ repo.full_name }}{{ repo.last_build.state }}{{ repo.last_build.number }}{{ repo.last_build.started_at | fromNow }}{{ repo.last_build.duration | toDuration }}{{ repo.last_build.head_commit.ref || repo.last_build.pull_request.source.ref }}{{ repo.last_build.head_commit.sha || repo.last_build.pull_request.source.ref }}
\ No newline at end of file diff --git a/server/static/scripts/views/repos_add.html b/server/static/scripts/views/repos_add.html new file mode 100644 index 000000000..187e898c9 --- /dev/null +++ b/server/static/scripts/views/repos_add.html @@ -0,0 +1,8 @@ +

Add Repository

+ +Back + +
+ + +
\ No newline at end of file diff --git a/server/static/scripts/views/repos_edit.html b/server/static/scripts/views/repos_edit.html new file mode 100644 index 000000000..c59fe9a73 --- /dev/null +++ b/server/static/scripts/views/repos_edit.html @@ -0,0 +1,42 @@ +

{{ repo.full_name }} / Edit

+ +Back + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
    +
  • + + + +
  • +
  • + + + +
  • +
+ + + +
\ No newline at end of file diff --git a/server/static/scripts/views/user.html b/server/static/scripts/views/user.html new file mode 100644 index 000000000..2123b25a6 --- /dev/null +++ b/server/static/scripts/views/user.html @@ -0,0 +1,26 @@ +

{{ user.login }}

+ +Back + +
+
Login
+
{{ user.login }}
+ +
Full Name
+
{{ user.name }}
+ +
Created
+
{{ user.created_at | fromNow }}
+ +
Updated
+
{{ user.update_at | fromNow }}
+ +
Email
+
{{ user.email }}
+ +
Site Admin
+
{{ user.admin }}
+ +
Gravatar
+
+
\ No newline at end of file diff --git a/server/static/scripts/views/users.html b/server/static/scripts/views/users.html new file mode 100644 index 000000000..ab8fc7361 --- /dev/null +++ b/server/static/scripts/views/users.html @@ -0,0 +1,37 @@ +

Users

+ +Back + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LoginFull NameCreatedUpdatedEmailSite AdminGravatar
{{ user.login }}{{ user.name }}{{ user.created_at | fromNow }}{{ user.updated_at | fromNow }}{{ user.email }}{{ !!user.admin }}
+ +
+ + +
\ No newline at end of file diff --git a/server/status.go b/server/status.go new file mode 100644 index 000000000..0d3b9fec8 --- /dev/null +++ b/server/status.go @@ -0,0 +1,75 @@ +package server + +import ( + "strconv" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + + "github.com/drone/drone/common" +) + +// GetStatus accepts a request to retrieve a build status +// from the datastore for the given repository and +// build number. +// +// GET /api/status/:owner/:name/:number/:context +// +func GetStatus(c *gin.Context) { + ds := ToDatastore(c) + repo := ToRepo(c) + num, _ := strconv.Atoi(c.Params.ByName("number")) + ctx := c.Params.ByName("context") + + status, err := ds.GetBuildStatus(repo.FullName, num, ctx) + if err != nil { + c.Fail(404, err) + } else { + c.JSON(200, status) + } +} + +// PostStatus accepts a request to create a new build +// status. The created user status is returned in JSON +// format if successful. +// +// POST /api/status/:owner/:name/:number +// +func PostStatus(c *gin.Context) { + ds := ToDatastore(c) + repo := ToRepo(c) + num, err := strconv.Atoi(c.Params.ByName("number")) + if err != nil { + c.Fail(400, err) + return + } + in := &common.Status{} + if !c.BindWith(in, binding.JSON) { + c.AbortWithStatus(400) + return + } + if err := ds.InsertBuildStatus(repo.Name, num, in); err != nil { + c.Fail(400, err) + } else { + c.JSON(201, in) + } +} + +// GetStatusList accepts a request to retrieve a list of +// all build status from the datastore for the given repository +// and build number. +// +// GET /api/status/:owner/:name/:number/:context +// +func GetStatusList(c *gin.Context) { + ds := ToDatastore(c) + repo := ToRepo(c) + num, _ := strconv.Atoi(c.Params.ByName("number")) + + list, err := ds.GetBuildStatusList(repo.FullName, num) + if err != nil { + c.Fail(404, err) + } else { + c.JSON(200, list) + } +} diff --git a/server/subscribe.go b/server/subscribe.go new file mode 100644 index 000000000..a783a3eb3 --- /dev/null +++ b/server/subscribe.go @@ -0,0 +1,84 @@ +package server + +import ( + "github.com/gin-gonic/gin" + + "github.com/drone/drone/common" +) + +// GetSubscriber accepts a request to retrieve a repository +// subscriber from the datastore for the given repository by +// user Login. +// +// GET /api/subscribers/:owner/:name/:login +// +func GetSubscriber(c *gin.Context) { + store := ToDatastore(c) + repo := ToRepo(c) + login := c.Params.ByName("login") + subsc, err := store.GetSubscriber(repo.FullName, login) + if err != nil { + c.Fail(404, err) + } else { + c.JSON(200, subsc) + } +} + +// GetSubscribers accepts a request to retrieve a repository +// watchers from the datastore for the given repository. +// +// GET /api/subscribers/:owner/:name +// +func GetSubscribers(c *gin.Context) { + // store := ToDatastore(c) + // repo := ToRepo(c) + // subs, err := store.GetSubscribers(repo.FullName) + // if err != nil { + // c.Fail(404, err) + // } else { + // c.JSON(200, subs) + // } + c.Writer.WriteHeader(501) +} + +// Unubscribe accapets a request to unsubscribe the +// currently authenticated user to the repository. +// +// DEL /api/subscribers/:owner/:name +// +func Unsubscribe(c *gin.Context) { + store := ToDatastore(c) + repo := ToRepo(c) + user := ToUser(c) + sub, err := store.GetSubscriber(repo.FullName, user.Login) + if err != nil { + c.Fail(404, err) + } + err = store.DeleteSubscriber(repo.FullName, sub) + if err != nil { + c.Fail(400, err) + } else { + c.Writer.WriteHeader(200) + } +} + +// Subscribe accapets a request to subscribe the +// currently authenticated user to the repository. +// +// POST /api/subscriber/:owner/:name +// +func Subscribe(c *gin.Context) { + store := ToDatastore(c) + repo := ToRepo(c) + user := ToUser(c) + subscriber := &common.Subscriber{ + Login: user.Login, + Subscribed: true, + } + err := store.InsertSubscriber(repo.FullName, subscriber) + if err != nil { + c.Fail(400, err) + } else { + c.JSON(200, subscriber) + } +} diff --git a/server/tasks.go b/server/tasks.go new file mode 100644 index 000000000..3491ec0ea --- /dev/null +++ b/server/tasks.go @@ -0,0 +1,46 @@ +package server + +import ( + "strconv" + + "github.com/gin-gonic/gin" +) + +// GetTask accepts a request to retrieve a build task +// from the datastore for the given repository and +// build number. +// +// GET /api/tasks/:owner/:name/:number/:task +// +func GetTask(c *gin.Context) { + ds := ToDatastore(c) + repo := ToRepo(c) + b, _ := strconv.Atoi(c.Params.ByName("number")) + t, _ := strconv.Atoi(c.Params.ByName("task")) + + task, err := ds.GetTask(repo.FullName, b, t) + if err != nil { + c.Fail(404, err) + } else { + c.JSON(200, task) + } +} + +// GetTasks accepts a request to retrieve a list of +// build tasks from the datastore for the given repository +// and build number. +// +// GET /api/tasks/:owner/:name/:number +// +func GetTasks(c *gin.Context) { + ds := ToDatastore(c) + repo := ToRepo(c) + num, _ := strconv.Atoi(c.Params.ByName("number")) + + tasks, err := ds.GetTaskList(repo.FullName, num) + if err != nil { + c.Fail(404, err) + } else { + c.JSON(200, tasks) + } +} diff --git a/server/user.go b/server/user.go new file mode 100644 index 000000000..5a0fbda5d --- /dev/null +++ b/server/user.go @@ -0,0 +1,59 @@ +package server + +import ( + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + + "github.com/drone/drone/common" + "github.com/drone/drone/common/gravatar" +) + +// GetUserCurr accepts a request to retrieve the +// currently authenticated user from the datastore +// and return in JSON format. +// +// GET /api/user +// +func GetUserCurr(c *gin.Context) { + c.JSON(200, ToUser(c)) +} + +// PutUserCurr accepts a request to update the currently +// authenticated User profile. +// +// PUT /api/user +// +func PutUserCurr(c *gin.Context) { + ds := ToDatastore(c) + me := ToUser(c) + + in := &common.User{} + if !c.BindWith(in, binding.JSON) { + return + } + me.Email = in.Email + me.Gravatar = gravatar.Generate(in.Email) + err := ds.UpdateUser(me) + if err != nil { + c.Fail(400, err) + } else { + c.JSON(200, me) + } +} + +// GetUserRepos accepts a request to get the currently +// authenticated user's repository list from the datastore, +// encoded and returned in JSON format. +// +// GET /api/user/repos +// +func GetUserRepos(c *gin.Context) { + ds := ToDatastore(c) + me := ToUser(c) + repos, err := ds.GetUserRepos(me.Login) + if err != nil { + c.Fail(400, err) + } else { + c.JSON(200, &repos) + } +} diff --git a/server/users.go b/server/users.go new file mode 100644 index 000000000..cc8f1086a --- /dev/null +++ b/server/users.go @@ -0,0 +1,125 @@ +package server + +import ( + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + + "github.com/drone/drone/common" + "github.com/drone/drone/common/gravatar" +) + +// GetUsers accepts a request to retrieve all users +// from the datastore and return encoded in JSON format. +// +// GET /api/users +// +func GetUsers(c *gin.Context) { + ds := ToDatastore(c) + users, err := ds.GetUserList() + if err != nil { + c.Fail(400, err) + } else { + c.JSON(200, users) + } +} + +// PostUser accepts a request to create a new user in the +// system. The created user account is returned in JSON +// format if successful. +// +// POST /api/users +// +func PostUser(c *gin.Context) { + ds := ToDatastore(c) + name := c.Params.ByName("name") + user := &common.User{Login: name, Name: name} + if err := ds.InsertUser(user); err != nil { + c.Fail(400, err) + } else { + c.JSON(201, user) + } +} + +// GetUser accepts a request to retrieve a user by hostname +// and login from the datastore and return encoded in JSON +// format. +// +// GET /api/users/:name +// +func GetUser(c *gin.Context) { + ds := ToDatastore(c) + name := c.Params.ByName("name") + user, err := ds.GetUser(name) + if err != nil { + c.Fail(404, err) + } else { + c.JSON(200, user) + } +} + +// PutUser accepts a request to update an existing user in +// the system. The modified user account is returned in JSON +// format if successful. +// +// PUT /api/users/:name +// +func PutUser(c *gin.Context) { + ds := ToDatastore(c) + me := ToUser(c) + name := c.Params.ByName("name") + user, err := ds.GetUser(name) + if err != nil { + c.Fail(404, err) + return + } + + in := &common.User{} + if !c.BindWith(in, binding.JSON) { + return + } + user.Email = in.Email + user.Gravatar = gravatar.Generate(user.Email) + + // an administrator must not be able to + // downgrade her own account. + if me.Login != user.Login { + user.Admin = in.Admin + } + + err = ds.UpdateUser(user) + if err != nil { + c.Fail(400, err) + } else { + c.JSON(200, user) + } +} + +// DeleteUser accepts a request to delete the specified +// user account from the system. A successful request will +// respond with an OK 200 status. +// +// DELETE /api/users/:name +// +func DeleteUser(c *gin.Context) { + ds := ToDatastore(c) + me := ToUser(c) + name := c.Params.ByName("name") + user, err := ds.GetUser(name) + if err != nil { + c.Fail(404, err) + return + } + + // an administrator must not be able to + // delete her own account. + if user.Login == me.Login { + c.Writer.WriteHeader(403) + return + } + + if err := ds.DeleteUser(user); err != nil { + c.Fail(400, err) + } else { + c.Writer.WriteHeader(204) + } +} diff --git a/server/ws.go b/server/ws.go new file mode 100644 index 000000000..670b5afcd --- /dev/null +++ b/server/ws.go @@ -0,0 +1,92 @@ +package server + +import ( + "time" + + "github.com/drone/drone/eventbus" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +const ( + // Time allowed to write the message to the client. + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the client. + pongWait = 60 * time.Second + + // Send pings to client with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +// GetEvents will upgrade the connection to a Websocket and will stream +// event updates to the browser. +func GetEvents(c *gin.Context) { + bus := ToBus(c) + user := ToUser(c) + remote := ToRemote(c) + + // upgrade the websocket + ws, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + c.Fail(400, err) + return + } + + ticker := time.NewTicker(pingPeriod) + eventc := make(chan *eventbus.Event, 1) + bus.Subscribe(eventc) + defer func() { + bus.Unsubscribe(eventc) + ticker.Stop() + ws.Close() + close(eventc) + }() + + go func() { + for { + select { + case event := <-eventc: + if event == nil { + return // why would this ever happen? + } + perms := perms(remote, user, event.Repo) + if perms != nil && perms.Pull { + ws.WriteJSON(event) + } + case <-ticker.C: + ws.SetWriteDeadline(time.Now().Add(writeWait)) + err := ws.WriteMessage(websocket.PingMessage, []byte{}) + if err != nil { + ws.Close() + return + } + } + } + }() + + readWebsocket(ws) +} + +// readWebsocket will block while reading the websocket data +func readWebsocket(ws *websocket.Conn) { + defer ws.Close() + ws.SetReadLimit(512) + ws.SetReadDeadline(time.Now().Add(pongWait)) + ws.SetPongHandler(func(string) error { + ws.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + for { + _, _, err := ws.ReadMessage() + if err != nil { + break + } + } +}