retrogit/app/app.go

387 lines
10 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io/ioutil"
log_ "log"
"net/http"
"path/filepath"
"strings"
"google.golang.org/appengine"
"google.golang.org/appengine/log"
"google.golang.org/appengine/mail"
"github.com/google/go-github/github"
"github.com/gorilla/sessions"
)
const (
AppErrorTypeInternal = iota
AppErrorTypeTemplate
AppErrorTypeGitHubFetch
AppErrorTypeRedirect
AppErrorTypeBadInput
)
type AppError struct {
Error error
Message string
Code int
Type int
}
type AppSignedInState struct {
Account *Account
GitHubClient *github.Client
session *sessions.Session
request *http.Request
responseWriter http.ResponseWriter
}
func (state *AppSignedInState) AddFlash(value interface{}) {
state.session.AddFlash(value)
state.saveSession()
}
func (state *AppSignedInState) Flashes() []interface{} {
flashes := state.session.Flashes()
if len(flashes) > 0 {
state.saveSession()
}
return flashes
}
func (state *AppSignedInState) ClearSession() {
state.session.Options.MaxAge = -1
state.saveSession()
}
func (state *AppSignedInState) saveSession() {
state.session.Save(state.request, state.responseWriter)
}
func GitHubFetchError(err error, fetchType string) *AppError {
return &AppError{
Error: err,
Message: fmt.Sprintf("Could not fetch %s data from GitHub", fetchType),
Code: http.StatusInternalServerError,
Type: AppErrorTypeGitHubFetch,
}
}
func InternalError(err error, message string) *AppError {
return &AppError{
Error: err,
Message: message,
Code: http.StatusInternalServerError,
Type: AppErrorTypeInternal,
}
}
func RedirectToUrl(url string) *AppError {
return &AppError{
Error: nil,
Message: url,
Code: http.StatusFound,
Type: AppErrorTypeRedirect,
}
}
func BadRequest(err error, message string) *AppError {
return &AppError{
Error: err,
Message: message,
Code: http.StatusBadRequest,
Type: AppErrorTypeBadInput,
}
}
func RedirectToRoute(routeName string, queryParameters ...map[string]string) *AppError {
route := router.Get(routeName)
if route == nil {
return InternalError(
errors.New("No such route"),
fmt.Sprintf("Could not look up route '%s'", routeName))
}
routeUrl, err := route.URL()
if err != nil {
return InternalError(
errors.New("Could not get route URL"),
fmt.Sprintf("Could not get route URL for route '%s'", routeName))
}
if len(queryParameters) != 0 {
routeUrlQuery := routeUrl.Query()
for k, v := range queryParameters[0] {
routeUrlQuery.Set(k, v)
}
routeUrl.RawQuery = routeUrlQuery.Encode()
}
return RedirectToUrl(routeUrl.String())
}
func NotSignedIn(r *http.Request) *AppError {
return RedirectToRoute("index", map[string]string{"continue_url": r.URL.String()})
}
func Panic(panicData interface{}) *AppError {
return InternalError(
errors.New(fmt.Sprintf("Panic: %+v\n\n%s", panicData, stack(3))),
"Panic")
}
type AppHandler func(http.ResponseWriter, *http.Request) *AppError
func (fn AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer panicRecovery(w, r)
makeUncacheable(w)
if e := fn(w, r); e != nil {
handleAppError(e, w, r)
}
}
type SignedInAppHandler func(http.ResponseWriter, *http.Request, *AppSignedInState) *AppError
func (fn SignedInAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer panicRecovery(w, r)
makeUncacheable(w)
session, _ := sessionStore.Get(r, sessionConfig.CookieName)
userId, ok := session.Values[sessionConfig.UserIdKey].(int)
if !ok {
handleAppError(NotSignedIn(r), w, r)
return
}
c := appengine.NewContext(r)
account, err := getAccount(c, userId)
if account == nil || err != nil {
handleAppError(NotSignedIn(r), w, r)
return
}
oauthTransport := githubOAuthTransport(c)
oauthTransport.Token = &account.OAuthToken
githubClient := github.NewClient(oauthTransport.Client())
state := &AppSignedInState{
Account: account,
GitHubClient: githubClient,
session: session,
responseWriter: w,
request: r,
}
if e := fn(w, r, state); e != nil {
handleAppError(e, w, r)
}
}
func panicRecovery(w http.ResponseWriter, r *http.Request) {
if panicData := recover(); panicData != nil {
handleAppError(Panic(panicData), w, r)
}
}
func makeUncacheable(w http.ResponseWriter) {
w.Header().Set(
"Cache-Control", "no-cache, no-store, max-age=0, must-revalidate")
w.Header().Set("Expires", "0")
}
func handleAppError(e *AppError, w http.ResponseWriter, r *http.Request) {
c := appengine.NewContext(r)
if e.Type == AppErrorTypeGitHubFetch {
if gitHubError, ok := (e.Error).(*github.ErrorResponse); ok {
gitHubStatus := gitHubError.Response.StatusCode
if gitHubStatus == http.StatusUnauthorized ||
gitHubStatus == http.StatusForbidden {
var data = map[string]interface{}{
"ContinueUrl": r.URL,
"IsForbidden": gitHubStatus == http.StatusForbidden,
}
e = templates["github-auth-error"].Render(w, data)
if e != nil {
handleAppError(e, w, r)
}
return
}
} else {
log.Errorf(c, "GitHub fetch error was not of type github.ErrorResponse")
}
} else if e.Type == AppErrorTypeRedirect {
http.Redirect(w, r, e.Message, e.Code)
return
}
if e.Type != AppErrorTypeBadInput {
log.Errorf(c, "%v", e.Error)
if !appengine.IsDevAppServer() {
sendAppErrorMail(e, r)
}
var data = map[string]interface{}{
"ShowDetails": appengine.IsDevAppServer(),
"Error": e,
}
w.WriteHeader(e.Code)
templateError := templates["internal-error"].Render(w, data)
if templateError != nil {
log.Errorf(c, "Error %s rendering error template.", templateError.Error.Error())
}
return
} else {
log.Infof(c, "%v", e.Error)
}
http.Error(w, e.Message, e.Code)
}
func sendAppErrorMail(e *AppError, r *http.Request) {
session, _ := sessionStore.Get(r, sessionConfig.CookieName)
userId, _ := session.Values[sessionConfig.UserIdKey].(int)
errorMessage := &mail.Message{
Sender: "RetroGit Admin <digests@retrogit.com>",
To: []string{"mihai.parparita@gmail.com"},
Subject: fmt.Sprintf("RetroGit Internal Error on %s", r.URL),
Body: fmt.Sprintf(`Request URL: %s
HTTP status code: %d
Error type: %d
User ID: %d
Message: %s
Error: %s`,
r.URL,
e.Code,
e.Type,
userId,
e.Message,
e.Error),
}
c := appengine.NewContext(r)
err := mail.Send(c, errorMessage)
if err != nil {
log.Errorf(c, "Error %s sending error email.", err.Error())
}
}
type Template struct {
*template.Template
}
func (t *Template) Render(w http.ResponseWriter, data map[string]interface{}, state ...*AppSignedInState) *AppError {
if len(state) > 0 {
data["Flashes"] = state[0].Flashes()
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err := t.Execute(w, data)
if err != nil {
return &AppError{
Error: err,
Message: fmt.Sprintf("Could not render template '%s'", t.Name()),
Code: http.StatusInternalServerError,
Type: AppErrorTypeTemplate,
}
}
return nil
}
func loadTemplates() (templates map[string]*Template) {
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
},
"absoluteRouteUrl": func(name string) (string, error) {
url, err := router.Get(name).URL()
if err != nil {
return "", err
}
var baseUrl string
if appengine.IsDevAppServer() {
baseUrl = "http://localhost:8080"
} else {
baseUrl = "https://www.retrogit.com"
}
return baseUrl + url.String(), nil
},
"absoluteUrlForPath": func(path string) string {
if appengine.IsDevAppServer() {
return "http://localhost:8080" + path
}
return "https://www.retrogit.com" + path
},
"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)
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 email ones, which
// don't use it.
if !strings.HasSuffix(templateName, "-email") {
fileNames = append(fileNames, "templates/base/page.html")
}
fileNames = append(fileNames, templateFileName)
fileNames = append(fileNames, sharedFileNames...)
_, templateFileName = filepath.Split(fileNames[0])
parsedTemplate, err := template.New(templateFileName).Funcs(funcMap).ParseFiles(fileNames...)
if err != nil {
log_.Printf("Could not parse template files for %s: %s", templateFileName, err.Error())
}
templates[templateName] = &Template{parsedTemplate}
}
return templates
}
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)
result = make(map[string]template.CSS)
if err != nil {
log_.Printf("Could not parse styles JSON %s: %s", stylesBytes, err.Error())
return
}
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_.Printf("Unexpected type for %s in styles JSON, ignoring", k)
}
}
}
parse("", stylesJson.(map[string]interface{}), nil)
return
}