package retrogit import ( "bytes" "encoding/json" "fmt" "io/ioutil" "log" "net/http" "net/url" "strconv" "strings" "sync" "time" "appengine" "appengine/datastore" "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 githubOauthPublicConfig oauth.Config var timezones Timezones var sessionStore *sessions.CookieStore var sessionConfig SessionConfig var templates map[string]*Template func init() { templates = loadTemplates() timezones = initTimezones() sessionStore, sessionConfig = initSession() githubOauthConfig = initGithubOAuthConfig(true) githubOauthPublicConfig = initGithubOAuthConfig(false) router = mux.NewRouter() router.Handle("/", AppHandler(indexHandler)).Name("index") router.Handle("/session/sign-in", AppHandler(signInHandler)).Name("sign-in").Methods("POST") router.Handle("/session/sign-out", AppHandler(signOutHandler)).Name("sign-out").Methods("POST") router.Handle("/github/callback", AppHandler(githubOAuthCallbackHandler)) router.Handle("/digest/view", SignedInAppHandler(viewDigestHandler)).Name("view-digest") router.Handle("/digest/send", SignedInAppHandler(sendDigestHandler)).Name("send-digest").Methods("POST") router.Handle("/digest/cron", AppHandler(digestCronHandler)) router.Handle("/account/settings", SignedInAppHandler(settingsHandler)).Name("settings").Methods("GET") router.Handle("/account/settings", SignedInAppHandler(saveSettingsHandler)).Name("save-settings").Methods("POST") router.Handle("/account/set-initial-timezone", SignedInAppHandler(setInitialTimezoneHandler)).Name("set-initial-timezone").Methods("POST") router.Handle("/account/delete", SignedInAppHandler(deleteAccountHandler)).Name("delete-account").Methods("POST") router.Handle("/admin/users", AppHandler(usersAdminHandler)) router.Handle("/admin/digest", AppHandler(digestAdminHandler)).Name("digest-admin") http.Handle("/", router) } func initGithubOAuthConfig(includePrivateRepos bool) (config oauth.Config) { 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, &config) if err != nil { log.Panicf("Could not parse GitHub OAuth config %s: %s", configBytes, err.Error()) } repoScopeModifier := "" if !includePrivateRepos { repoScopeModifier = "public_" } config.Scope = fmt.Sprintf("%srepo user:email", repoScopeModifier) config.AuthURL = "https://github.com/login/oauth/authorize" config.TokenURL = "https://github.com/login/oauth/access_token" return } func indexHandler(w http.ResponseWriter, r *http.Request) *AppError { session, _ := sessionStore.Get(r, sessionConfig.CookieName) userId, ok := session.Values[sessionConfig.UserIdKey].(int) if !ok { data := map[string]interface{}{ "ContinueUrl": r.FormValue("continue_url"), } return templates["index-signed-out"].Render(w, data) } 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. session.Options.MaxAge = -1 session.Save(r, w) return RedirectToRoute("index") } if err != nil { return InternalError(err, "Could not look up account") } 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 { return GitHubFetchError(userErr, "user") } if emailAddressErr != nil { return GitHubFetchError(userErr, "emails") } 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, "DetectTimezone": !account.HasTimezoneSet, } return templates["index"].Render(w, data, &AppSignedInState{ Account: account, GitHubClient: githubClient, session: session, responseWriter: w, request: r, }) } func signInHandler(w http.ResponseWriter, r *http.Request) *AppError { config := &githubOauthConfig if r.FormValue("include_private") != "1" { config = &githubOauthPublicConfig } authCodeUrl := config.AuthCodeURL("") if continueUrl := r.FormValue("continue_url"); continueUrl != "" { if parsedAuthCodeUrl, err := url.Parse(authCodeUrl); err == nil { authCodeQuery := parsedAuthCodeUrl.Query() redirectUrl := authCodeQuery.Get("redirect_uri") if parsedRedirectUrl, err := url.Parse(redirectUrl); err == nil { redirectUrlQuery := parsedRedirectUrl.Query() redirectUrlQuery.Set("continue_url", continueUrl) parsedRedirectUrl.RawQuery = redirectUrlQuery.Encode() authCodeQuery.Set("redirect_uri", parsedRedirectUrl.String()) parsedAuthCodeUrl.RawQuery = authCodeQuery.Encode() authCodeUrl = parsedAuthCodeUrl.String() } } } return RedirectToUrl(authCodeUrl) } func signOutHandler(w http.ResponseWriter, r *http.Request) *AppError { session, _ := sessionStore.Get(r, sessionConfig.CookieName) session.Options.MaxAge = -1 session.Save(r, w) return RedirectToRoute("index") } func viewDigestHandler(w http.ResponseWriter, r *http.Request, state *AppSignedInState) *AppError { c := appengine.NewContext(r) digest, err := newDigest(c, state.GitHubClient, state.Account) if err != nil { return GitHubFetchError(err, "digest") } var data = map[string]interface{}{ "Digest": digest, } return templates["digest-page"].Render(w, data, state) } func sendDigestHandler(w http.ResponseWriter, r *http.Request, state *AppSignedInState) *AppError { c := appengine.NewContext(r) sent, err := sendDigestForAccount(state.Account, c) if err != nil { return InternalError(err, "Could not send digest") } if sent { state.AddFlash("Digest emailed!") } else { state.AddFlash("No digest was sent, it was empty or disabled.") } return RedirectToRoute("index") } func digestCronHandler(w http.ResponseWriter, r *http.Request) *AppError { c := appengine.NewContext(r) accounts, err := getAllAccounts(c) if err != nil { return InternalError(err, "Could not look up accounts") } 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") return nil } 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()) emailAddress, err := account.GetDigestEmailAddress(githubClient) if err != nil { return false, err } if emailAddress == "disabled" { return false, nil } 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 } digestMessage := &mail.Message{ Sender: "RetroGit ", To: []string{emailAddress}, Subject: "RetroGit Digest", HTMLBody: digestHtml.String(), } err = mail.Send(c, digestMessage) return true, err } func githubOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) *AppError { code := r.FormValue("code") c := appengine.NewContext(r) oauthTransport := githubOAuthTransport(c) token, err := oauthTransport.Exchange(code) if err != nil { return InternalError(err, "Could not exchange OAuth code") } oauthTransport.Token = token githubClient := github.NewClient(oauthTransport.Client()) user, _, err := githubClient.Users.Get("") if err != nil { return GitHubFetchError(err, "user") } account, err := getAccount(c, *user.ID) if err != nil && err != datastore.ErrNoSuchEntity { return InternalError(err, "Could not look up user") } if account == nil { account = &Account{GitHubUserId: *user.ID} } account.OAuthToken = *token err = account.Put(c) if err != nil { return InternalError(err, "Could not save user") } session, _ := sessionStore.Get(r, sessionConfig.CookieName) session.Values[sessionConfig.UserIdKey] = user.ID session.Save(r, w) continueUrl := r.FormValue("continue_url") if continueUrl == "" || !strings.HasPrefix(continueUrl, "/") { indexUrl, _ := router.Get("index").URL() continueUrl = indexUrl.String() } return RedirectToUrl(continueUrl) } func settingsHandler(w http.ResponseWriter, r *http.Request, state *AppSignedInState) *AppError { c := appengine.NewContext(r) user, _, err := state.GitHubClient.Users.Get("") if err != nil { return GitHubFetchError(err, "user") } repos, err := getRepos(c, state.GitHubClient, state.Account, user) if err != nil { return GitHubFetchError(err, "repositories") } emails, _, err := state.GitHubClient.Users.ListEmails(nil) if err != nil { return GitHubFetchError(err, "emails") } emailAddresses := make([]string, len(emails)) for i := range emails { emailAddresses[i] = *emails[i].Email } accountEmailAddress, err := state.Account.GetDigestEmailAddress(state.GitHubClient) if err != nil { return GitHubFetchError(err, "emails") } var data = map[string]interface{}{ "Account": state.Account, "User": user, "Timezones": timezones, "Repos": repos, "EmailAddresses": emailAddresses, "AccountEmailAddress": accountEmailAddress, } return templates["settings"].Render(w, data, state) } func saveSettingsHandler(w http.ResponseWriter, r *http.Request, state *AppSignedInState) *AppError { c := appengine.NewContext(r) account := state.Account user, _, err := state.GitHubClient.Users.Get("") if err != nil { return GitHubFetchError(err, "user") } repos, err := getRepos(c, state.GitHubClient, account, user) if err != nil { return GitHubFetchError(err, "repos") } account.Frequency = r.FormValue("frequency") weeklyDay, err := strconv.Atoi(r.FormValue("weekly_day")) if err != nil { return BadRequest(err, "Malformed weekly_day value") } account.WeeklyDay = time.Weekday(weeklyDay) timezoneName := r.FormValue("timezone_name") _, err = time.LoadLocation(timezoneName) if err != nil { return BadRequest(err, "Malformed timezone_name value") } 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 { return InternalError(err, "Could not save user") } state.AddFlash("Settings saved.") return RedirectToRoute("settings") } func setInitialTimezoneHandler(w http.ResponseWriter, r *http.Request, state *AppSignedInState) *AppError { c := appengine.NewContext(r) account := state.Account timezoneName := r.FormValue("timezone_name") _, err := time.LoadLocation(timezoneName) if err != nil { return BadRequest(err, "Malformed timezone_name value") } account.TimezoneName = timezoneName err = account.Put(c) if err != nil { return InternalError(err, "Could not save user") } // Since we've now computed an initial timezone for the user, start a // background task to compute their digest. This ensures that we have most // of the relevant data already cached if they choose to view or email their // digest immediately. cacheDigestForAccountFunc.Call(c, account.GitHubUserId) return nil } var cacheDigestForAccountFunc = delay.Func( "cacheDigestForAccount", func(c appengine.Context, githubUserId int) error { c.Infof("Caching digest for %d...", githubUserId) account, err := getAccount(c, githubUserId) if err != nil { c.Errorf(" Error looking up account: %s", err.Error()) // Not returning error since we don't want these tasks to be // retried. return nil } oauthTransport := githubOAuthTransport(c) oauthTransport.Token = &account.OAuthToken githubClient := github.NewClient(oauthTransport.Client()) _, err = newDigest(c, githubClient, account) if err != nil { c.Errorf(" Error computing digest: %s", err.Error()) } c.Infof(" Done!") return nil }) func deleteAccountHandler(w http.ResponseWriter, r *http.Request, state *AppSignedInState) *AppError { c := appengine.NewContext(r) state.Account.Delete(c) state.ClearSession() return RedirectToRoute("index") } func usersAdminHandler(w http.ResponseWriter, r *http.Request) *AppError { c := appengine.NewContext(r) accounts, err := getAllAccounts(c) if err != nil { return InternalError(err, "Could not look up accounts") } users := make([]map[string]interface{}, len(accounts)) for i := range accounts { account := &accounts[i] oauthTransport := githubOAuthTransport(c) oauthTransport.Token = &account.OAuthToken githubClient := github.NewClient(oauthTransport.Client()) user, _, err := githubClient.Users.Get("") emailAddress, err := account.GetDigestEmailAddress(githubClient) if err != nil { emailAddress = err.Error() } repos, reposErr := getRepos(c, githubClient, account, user) users[i] = map[string]interface{}{ "Account": account, "User": user, "EmailAddress": emailAddress, "Repos": repos, "ReposError": reposErr, } } var data = map[string]interface{}{ "Users": users, } return templates["users-admin"].Render(w, data) } func digestAdminHandler(w http.ResponseWriter, r *http.Request) *AppError { userId, err := strconv.Atoi(r.FormValue("user_id")) if err != nil { return BadRequest(err, "Malformed user_id value") } c := appengine.NewContext(r) account, err := getAccount(c, userId) if account == nil { return BadRequest(err, "user_id does not point to an account") } if err != nil { return InternalError(err, "Could not look up account") } oauthTransport := githubOAuthTransport(c) oauthTransport.Token = &account.OAuthToken githubClient := github.NewClient(oauthTransport.Client()) digest, err := newDigest(c, githubClient, account) if err != nil { return GitHubFetchError(err, "digest") } var data = map[string]interface{}{ "Digest": digest, } return templates["digest-admin"].Render(w, data) } func githubOAuthTransport(c appengine.Context) *oauth.Transport { appengineTransport := &urlfetch.Transport{Context: c} appengineTransport.Deadline = time.Second * 60 cachingTransport := &CachingTransport{ Transport: appengineTransport, Context: c, } return &oauth.Transport{ Config: &githubOauthConfig, Transport: cachingTransport, } }