mirror of
https://github.com/samsonjs/retrogit.git
synced 2026-04-17 13:15:51 +00:00
When running in production http.Reqest.URL is an absolute URL, so we shouldn't check for leading slashes. Instead check for the hostname matching (which also works for relative URLs on localhost).
575 lines
16 KiB
Go
575 lines
16 KiB
Go
package retrogit
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"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 <digests@retrogit.com>",
|
|
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 != "" {
|
|
continueUrlParsed, err := url.Parse(continueUrl)
|
|
if err != nil || continueUrlParsed.Host != r.URL.Host {
|
|
continueUrl = ""
|
|
}
|
|
}
|
|
if 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")
|
|
}
|
|
|
|
type AdminUserData struct {
|
|
Account *Account
|
|
User *github.User
|
|
EmailAddress string
|
|
Repos *Repos
|
|
ReposError error
|
|
}
|
|
ch := make(chan *AdminUserData)
|
|
for i := range accounts {
|
|
go func(account *Account) {
|
|
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)
|
|
ch <- &AdminUserData{
|
|
Account: account,
|
|
User: user,
|
|
EmailAddress: emailAddress,
|
|
Repos: repos,
|
|
ReposError: reposErr,
|
|
}
|
|
}(&accounts[i])
|
|
}
|
|
|
|
users := make([]*AdminUserData, 0)
|
|
for _ = range accounts {
|
|
select {
|
|
case r := <-ch:
|
|
users = append(users, r)
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
digest.Redact()
|
|
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,
|
|
}
|
|
}
|