| // Copyright 2016 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // Package som implements HTTP server that handles requests to default module. |
| package som |
| |
| import ( |
| "encoding/base64" |
| "encoding/json" |
| "fmt" |
| "html/template" |
| "io/ioutil" |
| "net/http" |
| "net/url" |
| "strings" |
| "time" |
| |
| "golang.org/x/net/context" |
| |
| "github.com/luci/gae/service/datastore" |
| "github.com/luci/gae/service/info" |
| "github.com/luci/gae/service/memcache" |
| "github.com/luci/luci-go/appengine/gaeauth/server" |
| "github.com/luci/luci-go/appengine/gaeauth/server/gaesigner" |
| "github.com/luci/luci-go/appengine/gaemiddleware" |
| "github.com/luci/luci-go/common/logging" |
| "github.com/luci/luci-go/common/tsmon/metric" |
| "github.com/luci/luci-go/server/auth" |
| "github.com/luci/luci-go/server/auth/identity" |
| "github.com/luci/luci-go/server/auth/xsrf" |
| "github.com/luci/luci-go/server/router" |
| "github.com/luci/luci-go/server/settings" |
| ) |
| |
| const ( |
| authGroup = "sheriff-o-matic-access" |
| annotationsCacheKey = "annotation-metadata" |
| bugQueueCacheFormat = "bugqueue-%s" |
| settingsKey = "tree" |
| // annotations will expire after this amount of time |
| annotationExpiration = time.Hour * 24 * 10 |
| productionAnalyticsID = "UA-55762617-1" |
| stagingAnalyticsID = "UA-55762617-22" |
| ) |
| |
| var ( |
| mainPage = template.Must(template.ParseFiles("./index.html")) |
| accessDeniedPage = template.Must(template.ParseFiles("./access-denied.html")) |
| monorailEndpoint = "https://monorail-prod.appspot.com/_ah/api/monorail/v1/" |
| jsErrors = metric.NewCounter("frontend/js_errors", |
| "Number of uncaught javascript errors.", nil) |
| ) |
| |
| var errStatus = func(c context.Context, w http.ResponseWriter, status int, msg string) { |
| logging.Errorf(c, "Status %d msg %s", status, msg) |
| w.WriteHeader(status) |
| w.Write([]byte(msg)) |
| } |
| |
| func indexPage(ctx *router.Context) { |
| c, w, r, p := ctx.Context, ctx.Writer, ctx.Request, ctx.Params |
| if p.ByName("path") == "" { |
| http.Redirect(w, r, "/chromium", http.StatusFound) |
| return |
| } |
| |
| user := auth.CurrentIdentity(c) |
| |
| if user.Kind() == identity.Anonymous { |
| url, err := auth.LoginURL(c, p.ByName("path")) |
| if err != nil { |
| errStatus(c, w, http.StatusInternalServerError, fmt.Sprintf( |
| "You must login. Additionally, an error was encountered while serving this request: %s", err.Error())) |
| } else { |
| http.Redirect(w, r, url, http.StatusFound) |
| } |
| |
| return |
| } |
| |
| isGoogler, err := auth.IsMember(c, authGroup) |
| |
| if err != nil { |
| errStatus(c, w, http.StatusInternalServerError, err.Error()) |
| return |
| } |
| |
| logoutURL, err := auth.LogoutURL(c, "/") |
| |
| if err != nil { |
| errStatus(c, w, http.StatusInternalServerError, err.Error()) |
| return |
| } |
| |
| if !isGoogler { |
| err = accessDeniedPage.Execute(w, map[string]interface{}{ |
| "Group": authGroup, |
| "LogoutURL": logoutURL, |
| }) |
| if err != nil { |
| logging.Errorf(c, "while rendering index: %s", err) |
| } |
| return |
| } |
| |
| tok, err := xsrf.Token(c) |
| if err != nil { |
| logging.Errorf(c, "while getting xrsf token: %s", err) |
| } |
| |
| AnalyticsID := stagingAnalyticsID |
| if !strings.HasSuffix(info.AppID(c), "-staging") { |
| logging.Debugf(c, "Using production GA ID for app %s", info.AppID(c)) |
| AnalyticsID = productionAnalyticsID |
| } |
| |
| data := map[string]interface{}{ |
| "User": user.Email(), |
| "LogoutUrl": logoutURL, |
| "IsDevAppServer": info.IsDevAppServer(c), |
| "XsrfToken": tok, |
| "AnalyticsID": AnalyticsID, |
| } |
| |
| err = mainPage.Execute(w, data) |
| if err != nil { |
| logging.Errorf(c, "while rendering index: %s", err) |
| } |
| } |
| |
| func getTreesHandler(ctx *router.Context) { |
| c, w := ctx.Context, ctx.Writer |
| |
| q := datastore.NewQuery("Tree") |
| results := []*Tree{} |
| err := datastore.GetAll(c, q, &results) |
| if err != nil { |
| errStatus(c, w, http.StatusInternalServerError, err.Error()) |
| return |
| } |
| |
| txt, err := json.Marshal(results) |
| if err != nil { |
| errStatus(c, w, http.StatusInternalServerError, err.Error()) |
| return |
| } |
| |
| w.Header().Set("Content-Type", "application/json") |
| w.Write(txt) |
| } |
| |
| // Switches chromium.org emails for google.com emails and vice versa. |
| // Note that chromium.org emails may be different from google.com emails. |
| func getAlternateEmail(email string) string { |
| s := strings.Split(email, "@") |
| if len(s) != 2 { |
| return email |
| } |
| |
| user, domain := s[0], s[1] |
| if domain == "chromium.org" { |
| return fmt.Sprintf("%s@google.com", user) |
| } |
| return fmt.Sprintf("%s@chromium.org", user) |
| } |
| |
| func getCrRevJSON(c context.Context, pos string) (map[string]string, error) { |
| itm := memcache.NewItem(c, fmt.Sprintf("crrev:%s", pos)) |
| err := memcache.Get(c, itm) |
| |
| if err == memcache.ErrCacheMiss { |
| hc, err := getOAuthClient(c) |
| if err != nil { |
| return nil, err |
| } |
| |
| resp, err := hc.Get(fmt.Sprintf("https://cr-rev.appspot.com/_ah/api/crrev/v1/redirect/%s", pos)) |
| if err != nil { |
| return nil, err |
| } |
| |
| defer resp.Body.Close() |
| body, err := ioutil.ReadAll(resp.Body) |
| if err != nil { |
| return nil, err |
| } |
| itm.SetValue(body) |
| if err = memcache.Set(c, itm); err != nil { |
| return nil, fmt.Errorf("while setting memcache: %s", err) |
| } |
| } else if err != nil { |
| return nil, fmt.Errorf("while getting from memcache: %s", err) |
| } |
| |
| m := map[string]string{} |
| err = json.Unmarshal(itm.Value(), &m) |
| if err != nil { |
| return nil, err |
| } |
| |
| return m, nil |
| } |
| |
| func getRevRangeHandler(ctx *router.Context) { |
| c, w, r, p := ctx.Context, ctx.Writer, ctx.Request, ctx.Params |
| |
| start := p.ByName("start") |
| end := p.ByName("end") |
| if start == "" || end == "" { |
| errStatus(c, w, http.StatusBadRequest, "Start and end parameters must be set.") |
| return |
| } |
| |
| itm := memcache.NewItem(c, fmt.Sprintf("revrange:%s..%s", start, end)) |
| err := memcache.Get(c, itm) |
| |
| if err == memcache.ErrCacheMiss { |
| startRev, err := getCrRevJSON(c, start) |
| if err != nil { |
| errStatus(c, w, http.StatusInternalServerError, err.Error()) |
| return |
| } |
| |
| endRev, err := getCrRevJSON(c, end) |
| if err != nil { |
| errStatus(c, w, http.StatusInternalServerError, err.Error()) |
| return |
| } |
| |
| // TODO(seanmccullough): some sanity checking of the rev json (same repo etc) |
| |
| gitilesURL := fmt.Sprintf("https://chromium.googlesource.com/chromium/src/+log/%s^..%s?format=JSON", |
| startRev["git_sha"], endRev["git_sha"]) |
| |
| itm.SetValue([]byte(gitilesURL)) |
| if err = memcache.Set(c, itm); err != nil { |
| errStatus(c, w, http.StatusInternalServerError, fmt.Sprintf("while setting memcache: %s", err)) |
| return |
| } |
| } else if err != nil { |
| errStatus(c, w, http.StatusInternalServerError, fmt.Sprintf("while getting memcache: %s", err)) |
| return |
| } |
| |
| http.Redirect(w, r, string(itm.Value()), 301) |
| } |
| |
| type eCatcherReq struct { |
| Errors map[string]int64 `json:"errors"` |
| XSRFToken string `json:"xsrf_token"` |
| } |
| |
| func postClientMonHandler(ctx *router.Context) { |
| c, w, r := ctx.Context, ctx.Writer, ctx.Request |
| |
| req := &eCatcherReq{} |
| if err := json.NewDecoder(r.Body).Decode(req); err != nil { |
| errStatus(c, w, http.StatusBadRequest, err.Error()) |
| return |
| } |
| |
| if err := xsrf.Check(c, req.XSRFToken); err != nil { |
| errStatus(c, w, http.StatusForbidden, err.Error()) |
| return |
| } |
| |
| for _, errCount := range req.Errors { |
| jsErrors.Add(c, errCount) |
| } |
| logging.Errorf(c, "clientmon report: %v", req.Errors) |
| } |
| func getTreeLogoHandler(ctx *router.Context) { |
| c, w := ctx.Context, ctx.Writer |
| |
| sa, err := info.ServiceAccount(c) |
| if err != nil { |
| logging.Errorf(c, "failed to get service account: %v", err) |
| errStatus(c, w, http.StatusInternalServerError, err.Error()) |
| return |
| } |
| |
| getTreeLogo(ctx, sa, gaesigner.Signer{}) |
| } |
| |
| type signer interface { |
| SignBytes(c context.Context, b []byte) (string, []byte, error) |
| } |
| |
| func getTreeLogo(ctx *router.Context, sa string, sign signer) { |
| c, w, r, p := ctx.Context, ctx.Writer, ctx.Request, ctx.Params |
| tree := p.ByName("tree") |
| resource := fmt.Sprintf("/%s.appspot.com/logos/%s.png", info.AppID(c), tree) |
| expStr := fmt.Sprintf("%d", time.Now().Add(10*time.Minute).Unix()) |
| sl := []string{ |
| "GET", |
| "", // Optional MD5, which we don't have. |
| "", // Content type, ommitted because it breaks the signature. |
| expStr, |
| resource, |
| } |
| unsigned := strings.Join(sl, "\n") |
| _, b, err := sign.SignBytes(c, []byte(unsigned)) |
| if err != nil { |
| logging.Errorf(c, "failed to sign bytes: %v", err) |
| errStatus(c, w, http.StatusInternalServerError, err.Error()) |
| return |
| } |
| sig := base64.StdEncoding.EncodeToString(b) |
| params := url.Values{ |
| "GoogleAccessId": {sa}, |
| "Expires": {expStr}, |
| "Signature": {sig}, |
| } |
| |
| signedURL := fmt.Sprintf("https://storage.googleapis.com%s?%s", resource, params.Encode()) |
| http.Redirect(w, r, signedURL, http.StatusFound) |
| } |
| |
| // getOAuthClient returns a client capable of making HTTP requests authenticated |
| // with OAuth access token for userinfo.email scope. |
| var getOAuthClient = func(c context.Context) (*http.Client, error) { |
| // Note: "https://www.googleapis.com/auth/userinfo.email" is the default |
| // scope used by GetRPCTransport(AsSelf). Use auth.WithScopes(...) option to |
| // override. |
| t, err := auth.GetRPCTransport(c, auth.AsSelf) |
| if err != nil { |
| return nil, err |
| } |
| return &http.Client{Transport: t}, nil |
| } |
| |
| // base is the root of the middleware chain. |
| func base(includeCookie bool) router.MiddlewareChain { |
| methods := []auth.Method{ |
| &server.OAuth2Method{Scopes: []string{server.EmailScope}}, |
| &server.InboundAppIDAuthMethod{}, |
| } |
| if includeCookie { |
| methods = append(methods, server.CookieAuth) |
| } |
| |
| return gaemiddleware.BaseProd().Extend( |
| auth.Use(methods), |
| auth.Authenticate, |
| ) |
| } |
| |
| func requireGoogler(c *router.Context, next router.Handler) { |
| isGoogler, err := auth.IsMember(c.Context, authGroup) |
| switch { |
| case err != nil: |
| errStatus(c.Context, c.Writer, http.StatusInternalServerError, err.Error()) |
| case !isGoogler: |
| errStatus(c.Context, c.Writer, http.StatusForbidden, "Access denied") |
| default: |
| next(c) |
| } |
| } |
| |
| func noopHandler(ctx *router.Context) { |
| return |
| } |
| |
| func getXSRFToken(ctx *router.Context) { |
| c, w := ctx.Context, ctx.Writer |
| |
| tok, err := xsrf.Token(c) |
| if err != nil { |
| logging.Errorf(c, "while getting xrsf token: %s", err) |
| } |
| |
| data := map[string]string{ |
| "token": tok, |
| } |
| txt, err := json.Marshal(data) |
| if err != nil { |
| errStatus(c, w, http.StatusInternalServerError, err.Error()) |
| return |
| } |
| |
| w.Header().Set("Content-Type", "application/json") |
| w.Write(txt) |
| } |
| |
| //// Routes. |
| func init() { |
| |
| settings.RegisterUIPage(settingsKey, settingsUIPage{}) |
| |
| r := router.New() |
| basemw := base(true) |
| |
| protected := basemw.Extend(requireGoogler) |
| |
| gaemiddleware.InstallHandlers(r, gaemiddleware.BaseProd()) |
| r.GET("/api/v1/trees/", protected, getTreesHandler) |
| r.GET("/api/v1/alerts/:tree", protected, getAlertsHandler) |
| r.GET("/api/v1/pubsubalerts/:tree", protected, getPubSubAlertsHandler) |
| r.GET("/api/v1/restarts/:tree", protected, getRestartingMastersHandler) |
| r.GET("/api/v1/xsrf_token", protected, getXSRFToken) |
| |
| // Disallow cookies because this handler should not be accessible by regular |
| // users. |
| r.POST("/api/v1/alerts/:tree", basemw, postAlertsHandler) |
| r.GET("/api/v1/annotations/", protected, getAnnotationsHandler) |
| r.POST("/api/v1/annotations/:annKey/:action", protected, postAnnotationsHandler) |
| r.GET("/api/v1/bugqueue/:label", protected, getBugQueueHandler) |
| r.GET("/api/v1/bugqueue/:label/uncached/", protected, getUncachedBugsHandler) |
| r.GET("/api/v1/revrange/:start/:end", basemw, getRevRangeHandler) |
| r.GET("/logos/:tree", protected, getTreeLogoHandler) |
| r.GET("/alertdiff/:tree", protected, getMiloDiffHandler) |
| |
| // Non-public endpoints. |
| r.GET("/_cron/refresh/bugqueue/:label", basemw, refreshBugQueueHandler) |
| r.GET("/_cron/annotations/flush_old/", basemw, flushOldAnnotationsHandler) |
| r.GET("/_cron/annotations/refresh/", basemw, refreshAnnotationsHandler) |
| r.GET("/_cron/analyze/:tree", basemw, getAnalyzeHandler) |
| r.POST("/_/clientmon", basemw, postClientMonHandler) |
| r.POST("/_ah/push-handlers/milo", basemw, postMiloPubSubHandler) |
| |
| // Ingore reqeuests from builder-alerts rather than 404. |
| r.GET("/alerts", gaemiddleware.BaseProd(), noopHandler) |
| r.POST("/alerts", gaemiddleware.BaseProd(), noopHandler) |
| |
| rootRouter := router.New() |
| rootRouter.GET("/*path", basemw, indexPage) |
| |
| http.DefaultServeMux.Handle("/_cron/", r) |
| http.DefaultServeMux.Handle("/api/", r) |
| http.DefaultServeMux.Handle("/admin/", r) |
| http.DefaultServeMux.Handle("/auth/", r) |
| http.DefaultServeMux.Handle("/_ah/", r) |
| http.DefaultServeMux.Handle("/internal/", r) |
| http.DefaultServeMux.Handle("/_/", r) |
| http.DefaultServeMux.Handle("/logos/", r) |
| http.DefaultServeMux.Handle("/alerts", r) |
| http.DefaultServeMux.Handle("/alertdiff/", r) |
| |
| http.DefaultServeMux.Handle("/", rootRouter) |
| } |