retrogit/app/githop.go
Mihai Parparita b992e192ca Speed up homepage.
Don't try to fetch repositories to render the homepage (just use the excluded
repository count). Also do the user and email address fetches in parallel.
2014-10-02 22:06:32 -07:00

558 lines
16 KiB
Go

package githop
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"io/ioutil"
"log"
"net/http"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"appengine"
"appengine/delay"
"appengine/mail"
"appengine/urlfetch"
"code.google.com/p/goauth2/oauth"
"github.com/google/go-github/github"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
)
var router *mux.Router
var githubOauthConfig oauth.Config
var timezones Timezones
var sessionStore *sessions.CookieStore
var sessionConfig SessionConfig
var templates map[string]*template.Template
func init() {
initTemplates()
timezones = initTimezones()
sessionStore, sessionConfig = initSession()
initGithubOAuthConfig()
router = mux.NewRouter()
router.HandleFunc("/", indexHandler).Name("index")
router.HandleFunc("/session/sign-in", signInHandler).Name("sign-in")
router.HandleFunc("/session/sign-out", signOutHandler).Name("sign-out")
router.HandleFunc("/github/callback", githubOAuthCallbackHandler)
router.HandleFunc("/digest/view", viewDigestHandler).Name("view-digest")
router.HandleFunc("/digest/send", sendDigestHandler).Name("send-digest").Methods("POST")
router.HandleFunc("/digest/cron", digestCronHandler)
router.HandleFunc("/account/settings", settingsHandler).Name("settings").Methods("GET")
router.HandleFunc("/account/settings", saveSettingsHandler).Name("save-settings").Methods("POST")
router.HandleFunc("/admin/digest", digestAdminHandler)
http.Handle("/", router)
}
func initGithubOAuthConfig() {
path := "config/github-oauth"
if appengine.IsDevAppServer() {
path += "-dev"
}
path += ".json"
configBytes, err := ioutil.ReadFile(path)
if err != nil {
log.Panicf("Could not read GitHub OAuth config from %s: %s", path, err.Error())
}
err = json.Unmarshal(configBytes, &githubOauthConfig)
if err != nil {
log.Panicf("Could not parse GitHub OAuth config %s: %s", configBytes, err.Error())
}
githubOauthConfig.Scope = "repo, user:email"
githubOauthConfig.AuthURL = "https://github.com/login/oauth/authorize"
githubOauthConfig.TokenURL = "https://github.com/login/oauth/access_token"
}
func initTemplates() {
styles := loadStyles()
funcMap := template.FuncMap{
"routeUrl": func(name string) (string, error) {
url, err := router.Get(name).URL()
if err != nil {
return "", err
}
return url.String(), nil
},
"style": func(names ...string) (result template.CSS) {
for _, name := range names {
result += styles[name]
}
return
},
}
sharedFileNames, err := filepath.Glob("templates/shared/*.html")
if err != nil {
log.Panicf("Could not read shared template file names %s", err.Error())
}
templateFileNames, err := filepath.Glob("templates/*.html")
if err != nil {
log.Panicf("Could not read template file names %s", err.Error())
}
templates = make(map[string]*template.Template)
for _, templateFileName := range templateFileNames {
templateName := filepath.Base(templateFileName)
templateName = strings.TrimSuffix(templateName, filepath.Ext(templateName))
fileNames := make([]string, 0, len(sharedFileNames)+2)
// The base template has to come first, except for the email template, which
// doesn't use it
if templateName != "digest-email" {
fileNames = append(fileNames, "templates/base/page.html")
}
fileNames = append(fileNames, templateFileName)
fileNames = append(fileNames, sharedFileNames...)
_, templateFileName = filepath.Split(fileNames[0])
templates[templateName], err = template.New(templateFileName).Funcs(funcMap).ParseFiles(fileNames...)
if err != nil {
log.Panicf("Could not parse template files for %s: %s", templateFileName, err.Error())
}
}
}
func loadStyles() (result map[string]template.CSS) {
stylesBytes, err := ioutil.ReadFile("config/styles.json")
if err != nil {
log.Panicf("Could not read styles JSON: %s", err.Error())
}
var stylesJson interface{}
err = json.Unmarshal(stylesBytes, &stylesJson)
if err != nil {
log.Panicf("Could not parse styles JSON %s: %s", stylesBytes, err.Error())
}
result = make(map[string]template.CSS)
var parse func(string, map[string]interface{}, *string)
parse = func(path string, stylesJson map[string]interface{}, currentStyle *string) {
if path != "" {
path += "."
}
for k, v := range stylesJson {
switch v.(type) {
case string:
*currentStyle += k + ":" + v.(string) + ";"
case map[string]interface{}:
nestedStyle := ""
parse(path+k, v.(map[string]interface{}), &nestedStyle)
result[path+k] = template.CSS(nestedStyle)
default:
log.Panicf("Unexpected type for %s in styles JSON", k)
}
}
}
parse("", stylesJson.(map[string]interface{}), nil)
return
}
func signInHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, githubOauthConfig.AuthCodeURL(""), http.StatusFound)
}
func signOutHandler(w http.ResponseWriter, r *http.Request) {
session, _ := sessionStore.Get(r, sessionConfig.CookieName)
session.Options.MaxAge = -1
session.Save(r, w)
indexUrl, _ := router.Get("index").URL()
http.Redirect(w, r, indexUrl.String(), http.StatusFound)
}
func indexHandler(w http.ResponseWriter, r *http.Request) {
session, _ := sessionStore.Get(r, sessionConfig.CookieName)
userId, ok := session.Values[sessionConfig.UserIdKey].(int)
if !ok {
if err := templates["index-signed-out"].Execute(w, nil); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
c := appengine.NewContext(r)
account, err := getAccount(c, userId)
if account == nil {
// Can't look up the account, session cookie must be invalid, clear it.
indexUrl, _ := router.Get("sign-out").URL()
http.Redirect(w, r, indexUrl.String(), http.StatusFound)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
oauthTransport := githubOAuthTransport(c)
oauthTransport.Token = &account.OAuthToken
githubClient := github.NewClient(oauthTransport.Client())
var wg sync.WaitGroup
wg.Add(2)
var user *github.User
var userErr error
var emailAddress string
var emailAddressErr error
go func() {
user, _, userErr = githubClient.Users.Get("")
wg.Done()
}()
go func() {
emailAddress, emailAddressErr = account.GetDigestEmailAddress(githubClient)
wg.Done()
}()
wg.Wait()
if userErr != nil {
http.Error(w, userErr.Error(), http.StatusInternalServerError)
return
}
if emailAddressErr != nil {
http.Error(w, emailAddressErr.Error(), http.StatusInternalServerError)
return
}
var repositoryCount string
if len(account.ExcludedRepoIds) > 0 {
repositoryCount = fmt.Sprintf("all but %d", len(account.ExcludedRepoIds))
} else {
repositoryCount = "all"
}
var settingsSummary = map[string]interface{}{
"Frequency": account.Frequency,
"RepositoryCount": repositoryCount,
"EmailAddress": emailAddress,
}
var data = map[string]interface{}{
"User": user,
"SettingsSummary": settingsSummary,
}
if err := templates["index"].Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func viewDigestHandler(w http.ResponseWriter, r *http.Request) {
session, _ := sessionStore.Get(r, sessionConfig.CookieName)
userId := session.Values[sessionConfig.UserIdKey].(int)
c := appengine.NewContext(r)
account, err := getAccount(c, userId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
oauthTransport := githubOAuthTransport(c)
oauthTransport.Token = &account.OAuthToken
githubClient := github.NewClient(oauthTransport.Client())
digest, err := newDigest(c, githubClient, account)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
var data = map[string]interface{}{
"Digest": digest,
}
if err := templates["digest-page"].Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func sendDigestHandler(w http.ResponseWriter, r *http.Request) {
session, _ := sessionStore.Get(r, sessionConfig.CookieName)
userId := session.Values[sessionConfig.UserIdKey].(int)
c := appengine.NewContext(r)
account, err := getAccount(c, userId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, err = sendDigestForAccount(account, c)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
indexUrl, _ := router.Get("index").URL()
http.Redirect(w, r, indexUrl.String(), http.StatusFound)
}
func digestCronHandler(w http.ResponseWriter, r *http.Request) {
c := appengine.NewContext(r)
accounts, err := getAllAccounts(c)
if err != nil {
c.Errorf("Error looking up accounts: %s", err.Error())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, account := range accounts {
if account.Frequency == "weekly" {
now := time.Now().In(account.TimezoneLocation)
if now.Weekday() != account.WeeklyDay {
c.Infof("Skipping %d, since it wants weekly digests on %ss and today is a %s.",
account.GitHubUserId, account.WeeklyDay, now.Weekday())
continue
}
}
c.Infof("Enqueing task for %d...", account.GitHubUserId)
sendDigestForAccountFunc.Call(c, account.GitHubUserId)
}
fmt.Fprint(w, "Done")
}
var sendDigestForAccountFunc = delay.Func(
"sendDigestForAccount",
func(c appengine.Context, githubUserId int) error {
c.Infof("Sending digest for %d...", githubUserId)
account, err := getAccount(c, githubUserId)
if err != nil {
c.Errorf(" Error looking up account: %s", err.Error())
return err
}
sent, err := sendDigestForAccount(account, c)
if err != nil {
c.Errorf(" Error: %s", err.Error())
} else if sent {
c.Infof(" Sent!")
} else {
c.Infof(" Not sent, digest was empty")
}
return err
})
func sendDigestForAccount(account *Account, c appengine.Context) (bool, error) {
oauthTransport := githubOAuthTransport(c)
oauthTransport.Token = &account.OAuthToken
githubClient := github.NewClient(oauthTransport.Client())
digest, err := newDigest(c, githubClient, account)
if err != nil {
return false, err
}
if digest.Empty() {
return false, nil
}
var data = map[string]interface{}{
"Digest": digest,
}
var digestHtml bytes.Buffer
if err := templates["digest-email"].Execute(&digestHtml, data); err != nil {
return false, err
}
emailAddress, err := account.GetDigestEmailAddress(githubClient)
if err != nil {
return false, err
}
digestMessage := &mail.Message{
Sender: "GitHop <mihai.parparita@gmail.com>",
To: []string{emailAddress},
Subject: "GitHop Digest",
HTMLBody: digestHtml.String(),
}
err = mail.Send(c, digestMessage)
return true, err
}
func githubOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
code := r.FormValue("code")
c := appengine.NewContext(r)
oauthTransport := githubOAuthTransport(c)
token, err := oauthTransport.Exchange(code)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
oauthTransport.Token = token
githubClient := github.NewClient(oauthTransport.Client())
user, _, err := githubClient.Users.Get("")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
account := &Account{
GitHubUserId: *user.ID,
OAuthToken: *token,
}
err = account.Put(c)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
session, _ := sessionStore.Get(r, sessionConfig.CookieName)
session.Values[sessionConfig.UserIdKey] = user.ID
session.Save(r, w)
indexUrl, _ := router.Get("index").URL()
http.Redirect(w, r, indexUrl.String(), http.StatusFound)
}
func settingsHandler(w http.ResponseWriter, r *http.Request) {
session, _ := sessionStore.Get(r, sessionConfig.CookieName)
userId := session.Values[sessionConfig.UserIdKey].(int)
c := appengine.NewContext(r)
account, err := getAccount(c, userId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
oauthTransport := githubOAuthTransport(c)
oauthTransport.Token = &account.OAuthToken
githubClient := github.NewClient(oauthTransport.Client())
user, _, err := githubClient.Users.Get("")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
repos, err := getRepos(c, githubClient, account, user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
emails, _, err := githubClient.Users.ListEmails(nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
emailAddresses := make([]string, len(emails))
for i := range emails {
emailAddresses[i] = *emails[i].Email
}
accountEmailAddress, err := account.GetDigestEmailAddress(githubClient)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var data = map[string]interface{}{
"Account": account,
"User": user,
"Timezones": timezones,
"Repos": repos,
"EmailAddresses": emailAddresses,
"AccountEmailAddress": accountEmailAddress,
}
if err := templates["settings"].Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func saveSettingsHandler(w http.ResponseWriter, r *http.Request) {
session, _ := sessionStore.Get(r, sessionConfig.CookieName)
userId := session.Values[sessionConfig.UserIdKey].(int)
c := appengine.NewContext(r)
account, err := getAccount(c, userId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
oauthTransport := githubOAuthTransport(c)
oauthTransport.Token = &account.OAuthToken
githubClient := github.NewClient(oauthTransport.Client())
user, _, err := githubClient.Users.Get("")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
repos, err := getRepos(c, githubClient, account, user)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
account.Frequency = r.FormValue("frequency")
weeklyDay, err := strconv.Atoi(r.FormValue("weekly_day"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
account.WeeklyDay = time.Weekday(weeklyDay)
timezoneName := r.FormValue("timezone_name")
_, err = time.LoadLocation(timezoneName)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
account.TimezoneName = timezoneName
account.ExcludedRepoIds = make([]int, 0)
for _, repo := range repos.AllRepos {
repoId := *repo.ID
_, included := r.Form[fmt.Sprintf("repo-%d", repoId)]
if !included {
account.ExcludedRepoIds = append(account.ExcludedRepoIds, repoId)
}
}
account.DigestEmailAddress = r.FormValue("email_address")
err = account.Put(c)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
settingsUrl, _ := router.Get("settings").URL()
http.Redirect(w, r, settingsUrl.String(), http.StatusFound)
}
func digestAdminHandler(w http.ResponseWriter, r *http.Request) {
userId, err := strconv.Atoi(r.FormValue("user_id"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
c := appengine.NewContext(r)
account, err := getAccount(c, userId)
if account == nil {
http.Error(w, "Couldn't find account", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
oauthTransport := githubOAuthTransport(c)
oauthTransport.Token = &account.OAuthToken
githubClient := github.NewClient(oauthTransport.Client())
digest, err := newDigest(c, githubClient, account)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
var data = map[string]interface{}{
"Digest": digest,
}
if err := templates["digest-admin"].Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func githubOAuthTransport(c appengine.Context) *oauth.Transport {
appengineTransport := &urlfetch.Transport{Context: c}
cachingTransport := &CachingTransport{
Transport: appengineTransport,
Context: c,
}
return &oauth.Transport{
Config: &githubOauthConfig,
Transport: cachingTransport,
}
}