diff --git a/go.mod b/go.mod index 14d37869..ab5a0ac1 100644 --- a/go.mod +++ b/go.mod @@ -8,14 +8,15 @@ require ( github.com/aymerick/douceur v0.2.0 github.com/dnote/actions v0.2.0 github.com/dnote/color v1.7.0 - github.com/dnote/xgo v0.0.0-20200205013105-40be7d6d43ff // indirect github.com/gobuffalo/packr/v2 v2.8.1 github.com/google/go-cmp v0.5.4 github.com/google/go-github v17.0.0+incompatible github.com/google/go-querystring v1.0.0 // indirect github.com/google/uuid v1.1.3 + github.com/gorilla/csrf v1.6.2 github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/mux v1.8.0 + github.com/gorilla/schema v1.2.0 github.com/jinzhu/gorm v1.9.16 github.com/joho/godotenv v1.3.0 github.com/karrick/godirwalk v1.16.1 // indirect @@ -25,16 +26,18 @@ require ( github.com/pkg/errors v0.9.1 github.com/radovskyb/watcher v1.0.7 github.com/robfig/cron v1.2.0 + github.com/rogpeppe/go-internal v1.6.2 // indirect github.com/rubenv/sql-migrate v0.0.0-20200616145509-8d140a17f351 github.com/sergi/go-diff v1.1.0 github.com/sirupsen/logrus v1.7.0 // indirect github.com/spf13/cobra v1.1.1 golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad golang.org/x/net v0.0.0-20201224014010-6772e930b67b // indirect - golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a // indirect + golang.org/x/sync v0.0.0-20201207232520-09787c993a3a // indirect golang.org/x/sys v0.0.0-20201231184435-2d18734c6014 // indirect golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/yaml.v2 v2.4.0 diff --git a/go.sum b/go.sum index 238bdc3a..d55b52cd 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,6 @@ github.com/dnote/actions v0.2.0 h1:P1ut2/QRKwfAzIIB374vN9A4IanU94C/payEocvngYo= github.com/dnote/actions v0.2.0/go.mod h1:bBIassLhppVQdbC3iaE92SHBpM1HOVe+xZoAlj9ROxw= github.com/dnote/color v1.7.0 h1:8/QGLQKSU8/zcWQaHbMyC1hJRkKO/Uu9M89sH76ecHE= github.com/dnote/color v1.7.0/go.mod h1:75UcP/TH7CNvjQ5pwDumkUS3vkPdGggy7/3fT8MlxHM= -github.com/dnote/xgo v0.0.0-20200205013105-40be7d6d43ff h1:DJKdzouhr6u1NzuLbmSWeei9BagH3Nm4mSOzP0RMdc0= -github.com/dnote/xgo v0.0.0-20200205013105-40be7d6d43ff/go.mod h1:ruGZjl8WThApI7BAIKV2Q/PnJoudvd6Epjc3z79jWVg= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= @@ -164,12 +162,18 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/csrf v1.6.2 h1:QqQ/OWwuFp4jMKgBFAzJVW3FMULdyUW7JoM4pEWuqKg= +github.com/gorilla/csrf v1.6.2/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= +github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -264,8 +268,6 @@ github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsO github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= -github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= @@ -354,6 +356,8 @@ github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.5.2 h1:qLvObTrvO/XRCqmkKxUlOBc48bI3efyDuAZe25QiF0w= github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.6.2 h1:aIihoIOHCiLZHxyoNQ+ABL4NKhFTgKLBdMLyEAh98m0= +github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rubenv/sql-migrate v0.0.0-20200616145509-8d140a17f351 h1:HXr/qUllAWv9riaI4zh2eXWKmCSDqVS/XH1MRHLKRwk= github.com/rubenv/sql-migrate v0.0.0-20200616145509-8d140a17f351/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= @@ -493,8 +497,8 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -562,11 +566,14 @@ golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200308013534-11ec41452d41 h1:9Di9iYgOt9ThCipBxChBVhgNipDoE5mxO84rQV7D0FE= golang.org/x/tools v0.0.0-20200308013534-11ec41452d41/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= diff --git a/pkg/server/config/config.go b/pkg/server/config/config.go index 0ed705d1..36190429 100644 --- a/pkg/server/config/config.go +++ b/pkg/server/config/config.go @@ -79,6 +79,8 @@ type Config struct { DisableRegistration bool Port string DB PostgresConfig + PageTemplateDir string + StaticDir string } // Load constructs and returns a new config based on the environment variables. @@ -108,6 +110,16 @@ func (c *Config) SetOnPremise(val bool) { c.OnPremise = val } +// SetPageTemplateDir sets page template dir for the config +func (c *Config) SetPageTemplateDir(d string) { + c.PageTemplateDir = d +} + +// SetStaticDir sets static dir for the confi +func (c *Config) SetStaticDir(d string) { + c.StaticDir = d +} + func validate(c Config) error { if _, err := url.ParseRequestURI(c.WebURL); err != nil { return errors.Wrapf(ErrWebURLInvalid, "provided: '%s'", c.WebURL) diff --git a/pkg/server/context/user.go b/pkg/server/context/user.go new file mode 100644 index 00000000..a8a808c6 --- /dev/null +++ b/pkg/server/context/user.go @@ -0,0 +1,30 @@ +package context + +import ( + "context" + + "github.com/dnote/dnote/pkg/server/database" +) + +const ( + userKey privateKey = "user" +) + +type privateKey string + +// WithUser creates a new context with the given user +func WithUser(ctx context.Context, user *database.User) context.Context { + return context.WithValue(ctx, userKey, user) +} + +// User retrieves a user from the given context. It returns a pointer to +// a user. If the context does not contain a user, it returns nil. +func User(ctx context.Context) *database.User { + if temp := ctx.Value(userKey); temp != nil { + if user, ok := temp.(*database.User); ok { + return user + } + } + + return nil +} diff --git a/pkg/server/controllers/controllers.go b/pkg/server/controllers/controllers.go new file mode 100644 index 00000000..01493403 --- /dev/null +++ b/pkg/server/controllers/controllers.go @@ -0,0 +1,21 @@ +package controllers + +import ( + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/config" + "github.com/jinzhu/gorm" +) + +// Controllers is a group of controllers +type Controllers struct { + Users *Users +} + +// New returns a new group of controllers +func New(cfg config.Config, db *gorm.DB, cl clock.Clock) *Controllers { + c := Controllers{} + + c.Users = NewUsers(cfg, db) + + return &c +} diff --git a/pkg/server/controllers/helpers.go b/pkg/server/controllers/helpers.go new file mode 100644 index 00000000..7ed09c66 --- /dev/null +++ b/pkg/server/controllers/helpers.go @@ -0,0 +1,181 @@ +package controllers + +import ( + "encoding/json" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gorilla/schema" + "github.com/pkg/errors" +) + +const ( + contentTypeForm = "application/x-www-form-urlencoded" + contentTypeJSON = "application/json" +) + +func parseRequestData(r *http.Request, dst interface{}) error { + ct := r.Header.Get("Content-Type") + + if ct == contentTypeForm { + if err := parseForm(r, dst); err != nil { + return errors.Wrap(err, "parsing form") + } + + return nil + } + + // default to JSON + if err := parseJSON(r, dst); err != nil { + return errors.Wrap(err, "parsing JSON") + } + + return nil +} + +func parseForm(r *http.Request, dst interface{}) error { + if err := r.ParseForm(); err != nil { + return err + } + return parseValues(r.PostForm, dst) +} + +func parseURLParams(r *http.Request, dst interface{}) error { + if err := r.ParseForm(); err != nil { + return err + } + return parseValues(r.Form, dst) +} + +func parseValues(values url.Values, dst interface{}) error { + dec := schema.NewDecoder() + + // Ignore CSRF token field + dec.IgnoreUnknownKeys(true) + + if err := dec.Decode(dst, values); err != nil { + return err + } + + return nil +} + +func parseJSON(r *http.Request, dst interface{}) error { + dec := json.NewDecoder(r.Body) + + if err := dec.Decode(dst); err != nil { + return err + } + + return nil +} + +// GetCredential extracts a session key from the request from the request header. Concretely, +// it first looks at the 'Cookie' and then the 'Authorization' header. If no credential is found, +// it returns an empty string. +func GetCredential(r *http.Request) (string, error) { + ret, err := getSessionKeyFromCookie(r) + if err != nil { + return "", errors.Wrap(err, "getting session key from cookie") + } + if ret != "" { + return ret, nil + } + + ret, err = getSessionKeyFromAuth(r) + if err != nil { + return "", errors.Wrap(err, "getting session key from Authorization header") + } + + return ret, nil +} + +// getSessionKeyFromCookie reads and returns a session key from the cookie sent by the +// request. If no session key is found, it returns an empty string +func getSessionKeyFromCookie(r *http.Request) (string, error) { + c, err := r.Cookie("id") + + if err == http.ErrNoCookie { + return "", nil + } else if err != nil { + return "", errors.Wrap(err, "reading cookie") + } + + return c.Value, nil +} + +// getSessionKeyFromAuth reads and returns a session key from the Authorization header +func getSessionKeyFromAuth(r *http.Request) (string, error) { + h := r.Header.Get("Authorization") + if h == "" { + return "", nil + } + + payload, err := parseAuthHeader(h) + if err != nil { + return "", errors.Wrap(err, "parsing the authorization header") + } + if payload.scheme != "Bearer" { + return "", errors.New("unsupported scheme") + } + + return payload.credential, nil +} + +func parseAuthHeader(h string) (authHeader, error) { + parts := strings.Split(h, " ") + + if len(parts) != 2 { + return authHeader{}, errors.New("Invalid authorization header") + } + + parsed := authHeader{ + scheme: parts[0], + credential: parts[1], + } + + return parsed, nil +} + +type authHeader struct { + scheme string + credential string +} + +const ( + sessionCookieName = "id" + sessionCookiePath = "/" +) + +func setSessionCookie(w http.ResponseWriter, key string, expires time.Time) { + cookie := http.Cookie{ + Name: sessionCookieName, + Value: key, + Expires: expires, + Path: sessionCookiePath, + HttpOnly: true, + } + http.SetCookie(w, &cookie) +} + +func unsetSessionCookie(w http.ResponseWriter) { + expires := time.Now().Add(time.Hour * -24 * 30) + cookie := http.Cookie{ + Name: sessionCookieName, + Value: "", + Expires: expires, + Path: sessionCookiePath, + HttpOnly: true, + } + + w.Header().Set("Cache-Control", "no-cache") + http.SetCookie(w, &cookie) +} + +// SessionResponse is a response containing a session information +type SessionResponse struct { + Key string `json:"key"` + ExpiresAt int64 `json:"expires_at"` +} diff --git a/pkg/server/controllers/users.go b/pkg/server/controllers/users.go new file mode 100644 index 00000000..7e9eaa60 --- /dev/null +++ b/pkg/server/controllers/users.go @@ -0,0 +1,45 @@ +package controllers + +import ( + "net/http" + + "github.com/dnote/dnote/pkg/server/config" + "github.com/dnote/dnote/pkg/server/views" + "github.com/jinzhu/gorm" +) + +// NewUsers creates a new Users controller. +// It panics if the necessary templates are not parsed. +func NewUsers(cfg config.Config, db *gorm.DB) *Users { + return &Users{ + NewView: views.NewView(cfg.PageTemplateDir, views.Config{Title: "Join", Layout: "base"}, "users/new"), + onPremise: cfg.OnPremise, + db: db, + } +} + +// Users is a user controller. +type Users struct { + NewView *views.View + db *gorm.DB + onPremise bool +} + +// New handles GET /register +func (u *Users) New(w http.ResponseWriter, r *http.Request) { + var form RegistrationForm + parseURLParams(r, &form) + u.NewView.Render(w, r, form) +} + +// RegistrationForm is the form data for registering +type RegistrationForm struct { + Email string `schema:"email"` + Password string `schema:"password"` +} + +// LoginForm is the form data for log in +type LoginForm struct { + Email string `schema:"email" json:"email"` + Password string `schema:"password" json:"password"` +} diff --git a/pkg/server/main.go b/pkg/server/main.go index a3108760..96af60ca 100644 --- a/pkg/server/main.go +++ b/pkg/server/main.go @@ -28,9 +28,11 @@ import ( "github.com/dnote/dnote/pkg/server/api" "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/config" + "github.com/dnote/dnote/pkg/server/controllers" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/job" "github.com/dnote/dnote/pkg/server/mailer" + "github.com/dnote/dnote/pkg/server/routes" "github.com/dnote/dnote/pkg/server/web" "github.com/jinzhu/gorm" @@ -42,6 +44,9 @@ var versionTag = "master" var port = flag.String("port", "3000", "port to connect to") var rootBox *packr.Box +var pageDir = flag.String("pageDir", "views", "the path to a directory containing page templates") +var staticDir = flag.String("staticDir", "./static/", "the path to the static directory ") + func init() { rootBox = packr.New("root", "../../web/public") } @@ -124,9 +129,11 @@ func runJob(a app.App) error { } func startCmd() { - c := config.Load() + cfg := config.Load() + cfg.SetPageTemplateDir(*pageDir) + cfg.SetStaticDir(*staticDir) - app := initApp(c) + app := initApp(cfg) defer app.DB.Close() if err := database.Migrate(app.DB); err != nil { @@ -137,13 +144,19 @@ func startCmd() { panic(errors.Wrap(err, "running job")) } - srv, err := initServer(app) - if err != nil { - panic(errors.Wrap(err, "initializing server")) + cl := clock.New() + ctl := controllers.New(cfg, app.DB, cl) + + rc := routes.RouteConfig{ + WebRoutes: routes.NewWebRoutes(cfg, ctl, cl), + APIRoutes: routes.NewAPIRoutes(cfg, ctl, cl), + Controllers: ctl, } + r := routes.New(cfg, rc) + log.Printf("Dnote version %s is running on port %s", versionTag, *port) - log.Fatalln(http.ListenAndServe(":"+*port, srv)) + log.Fatalln(http.ListenAndServe(fmt.Sprintf(":%s", cfg.Port), r)) } func versionCmd() { diff --git a/pkg/server/routes/logging.go b/pkg/server/routes/logging.go new file mode 100644 index 00000000..e457d878 --- /dev/null +++ b/pkg/server/routes/logging.go @@ -0,0 +1,41 @@ +package routes + +import ( + "fmt" + "net/http" + "time" + + "github.com/dnote/dnote/pkg/server/log" +) + +// logResponseWriter wraps http.ResponseWriter to expose HTTP status code for logging. +// The optional interfaces of http.ResponseWriter are lost because of the wrapping, and +// such interfaces should be implemented if needed. (i.e. http.Pusher, http.Flusher, etc.) +type logResponseWriter struct { + http.ResponseWriter + statusCode int +} + +func (w *logResponseWriter) WriteHeader(code int) { + w.statusCode = code + w.ResponseWriter.WriteHeader(code) +} + +// LoggingMw is a middleware that logs incoming HTTP requests +func LoggingMw(inner http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + lw := logResponseWriter{w, http.StatusOK} + inner.ServeHTTP(&lw, r) + + log.WithFields(log.Fields{ + "remoteAddr": lookupIP(r), + "uri": r.RequestURI, + "statusCode": lw.statusCode, + "method": r.Method, + "duration": fmt.Sprintf("%dms", time.Since(start)/1000000), + "userAgent": r.Header.Get("User-Agent"), + }).Info("incoming request") + } +} diff --git a/pkg/server/routes/middleware.go b/pkg/server/routes/middleware.go new file mode 100644 index 00000000..dc202b48 --- /dev/null +++ b/pkg/server/routes/middleware.go @@ -0,0 +1,37 @@ +package routes + +import ( + "net/http" + "strings" + + "github.com/dnote/dnote/pkg/server/config" +) + +type middleware func(h http.Handler, c config.Config, rateLimit bool) http.Handler + +// lookupIP returns the request's IP +func lookupIP(r *http.Request) string { + realIP := r.Header.Get("X-Real-IP") + forwardedFor := r.Header.Get("X-Forwarded-For") + + if forwardedFor != "" { + parts := strings.Split(forwardedFor, ",") + return parts[0] + } + + if realIP != "" { + return realIP + } + + return r.RemoteAddr +} + +// WebMw is the middleware for the web +func WebMw(h http.Handler, c config.Config, rateLimit bool) http.Handler { + return h +} + +// APIMw is the middleware for the API +func APIMw(h http.Handler, c config.Config, rateLimit bool) http.Handler { + return h +} diff --git a/pkg/server/routes/routes.go b/pkg/server/routes/routes.go new file mode 100644 index 00000000..3e3d83cb --- /dev/null +++ b/pkg/server/routes/routes.go @@ -0,0 +1,66 @@ +package routes + +import ( + "net/http" + + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/config" + "github.com/dnote/dnote/pkg/server/controllers" + "github.com/gorilla/mux" +) + +// Route represents a single route +type Route struct { + Method string + Pattern string + Handler http.Handler + RateLimit bool +} + +func registerRoutes(router *mux.Router, mw middleware, c config.Config, routes []Route) { + for _, route := range routes { + wrappedHandler := mw(route.Handler, c, route.RateLimit) + + router. + Handle(route.Pattern, wrappedHandler). + Methods(route.Method) + } +} + +// NewWebRoutes returns a new web routes +func NewWebRoutes(cfg config.Config, c *controllers.Controllers, cl clock.Clock) []Route { + return []Route{ + {"GET", "/", http.HandlerFunc(c.Users.New), true}, + } +} + +// NewAPIRoutes returns a new api routes +func NewAPIRoutes(cfg config.Config, c *controllers.Controllers, cl clock.Clock) []Route { + return []Route{} +} + +// RouteConfig is the configuration for routes +type RouteConfig struct { + Controllers *controllers.Controllers + WebRoutes []Route + APIRoutes []Route +} + +// New creates and returns a new router +func New(cfg config.Config, rc RouteConfig) http.Handler { + router := mux.NewRouter().StrictSlash(true) + + webRouter := router.PathPrefix("/").Subrouter() + apiRouter := router.PathPrefix("/api").Subrouter() + registerRoutes(webRouter, WebMw, cfg, rc.WebRoutes) + registerRoutes(apiRouter, APIMw, cfg, rc.APIRoutes) + + // static + staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir(cfg.StaticDir))) + router.PathPrefix("/static/").Handler(staticHandler) + + // catch-all + // router.PathPrefix("/").HandlerFunc(rc.Controllers.Static.NotFound) + + return LoggingMw(router) +} diff --git a/pkg/server/routes/user.go b/pkg/server/routes/user.go new file mode 100644 index 00000000..915d9e13 --- /dev/null +++ b/pkg/server/routes/user.go @@ -0,0 +1,13 @@ +package routes + +import ( + "net/http" +) + +func userMw(inner http.Handler) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // ctx = context.WithUser(ctx, user) + inner.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/pkg/server/views/data.go b/pkg/server/views/data.go new file mode 100644 index 00000000..95c20964 --- /dev/null +++ b/pkg/server/views/data.go @@ -0,0 +1,153 @@ +package views + +import ( + "html/template" + "net/http" + "time" + + "github.com/dnote/dnote/pkg/server/database" + "github.com/pkg/errors" +) + +const ( + // AlertLvlError is an alert level for error + AlertLvlError = "danger" + // AlertLvlWarning is an alert level for warning + AlertLvlWarning = "warning" + // AlertLvlInfo is an alert level for info + AlertLvlInfo = "info" + // AlertLvlSuccess is an alert level for success + AlertLvlSuccess = "success" + + // AlertMsgGeneric is a generic message for a server error + AlertMsgGeneric = "Something went wrong. Please try again." +) + +// Alert is used to render Bootstrap Alert messages in templates +type Alert struct { + Level string + Message string +} + +// Data is the top level structure that views expect data to come in. +type Data struct { + Alert *Alert + CSRF template.HTML + User *database.User + Yield interface{} +} + +func getErrMessage(err error) string { + if pErr, ok := err.(PublicError); ok { + return pErr.Public() + } + + return AlertMsgGeneric +} + +// SetAlert sets alert in the given data for given error. +func (d *Data) SetAlert(err error) { + errC := errors.Cause(err) + + if pErr, ok := errC.(PublicError); ok { + d.Alert = &Alert{ + Level: AlertLvlError, + Message: pErr.Public(), + } + } else { + d.Alert = &Alert{ + Level: AlertLvlError, + Message: AlertMsgGeneric, + } + } +} + +// AlertError returns a new error alert using the given message. +func (d *Data) AlertError(msg string) { + d.Alert = &Alert{ + Level: AlertLvlError, + Message: msg, + } +} + +func persistAlert(w http.ResponseWriter, alert Alert) { + expiresAt := time.Now().Add(5 * time.Minute) + lvl := http.Cookie{ + Name: "alert_level", + Value: alert.Level, + Expires: expiresAt, + HttpOnly: true, + } + msg := http.Cookie{ + Name: "alert_message", + Value: alert.Message, + Expires: expiresAt, + HttpOnly: true, + } + http.SetCookie(w, &lvl) + http.SetCookie(w, &msg) +} + +func clearAlert(w http.ResponseWriter) { + lvl := http.Cookie{ + Name: "alert_level", + Value: "", + Expires: time.Now(), + HttpOnly: true, + } + msg := http.Cookie{ + Name: "alert_message", + Value: "", + Expires: time.Now(), + HttpOnly: true, + } + http.SetCookie(w, &lvl) + http.SetCookie(w, &msg) +} + +func getAlert(r *http.Request) *Alert { + lvl, err := r.Cookie("alert_level") + if err != nil { + return nil + } + msg, err := r.Cookie("alert_message") + if err != nil { + return nil + } + alert := Alert{ + Level: lvl.Value, + Message: msg.Value, + } + return &alert +} + +// RedirectAlert redirects to a URL after persisting the provided alert data +// into a cookie so that it can be displayed when the page is rendered. +func RedirectAlert(w http.ResponseWriter, r *http.Request, urlStr string, code int, alert Alert) { + persistAlert(w, alert) + http.Redirect(w, r, urlStr, code) +} + +// PublicError is an error meant to be displayed to the public +type PublicError interface { + error + Public() string +} + +// BadRequestError is an error for bad request +type BadRequestError interface { + error + IsBadRequest() bool +} + +// ConflictError is an error for bad request +type ConflictError interface { + error + IsConflictError() bool +} + +// NotFoundError is an error for bad request +type NotFoundError interface { + error + IsNotFoundError() bool +} diff --git a/pkg/server/views/layouts/alert.gohtml b/pkg/server/views/layouts/alert.gohtml new file mode 100644 index 00000000..753b4858 --- /dev/null +++ b/pkg/server/views/layouts/alert.gohtml @@ -0,0 +1,8 @@ +{{define "alert"}} +{{if .}} + +{{end}} +{{end}} diff --git a/pkg/server/views/layouts/base.gohtml b/pkg/server/views/layouts/base.gohtml new file mode 100644 index 00000000..47f02840 --- /dev/null +++ b/pkg/server/views/layouts/base.gohtml @@ -0,0 +1,18 @@ +{{define "base"}} + + + + {{ title }} + + {{template "css" .}} + + + + {{template "header" .}} + + {{template "alert" .Alert}} + + {{template "yield" .Yield}} + + +{{end}} diff --git a/pkg/server/views/layouts/css.gohtml b/pkg/server/views/layouts/css.gohtml new file mode 100644 index 00000000..86415b9f --- /dev/null +++ b/pkg/server/views/layouts/css.gohtml @@ -0,0 +1,5 @@ +{{define "css"}} + {{range css}} + + {{end}} +{{end}} diff --git a/pkg/server/views/layouts/header.gohtml b/pkg/server/views/layouts/header.gohtml new file mode 100644 index 00000000..6d8451e7 --- /dev/null +++ b/pkg/server/views/layouts/header.gohtml @@ -0,0 +1,7 @@ +{{define "header"}} + +{{if eq headerTemplate "navbar"}} + {{ template "navbar" . }} +{{end}} + +{{end}} diff --git a/pkg/server/views/layouts/navbar.gohtml b/pkg/server/views/layouts/navbar.gohtml new file mode 100644 index 00000000..49ee9b49 --- /dev/null +++ b/pkg/server/views/layouts/navbar.gohtml @@ -0,0 +1,32 @@ +{{define "navbar"}} +
+
+
+
+ NAD + + +
+ +
+ {{if .User}} +
  • {{template "logoutForm"}}
  • + {{end}} +
    +
    +
    + +
    +{{end}} + +{{define "logoutForm"}} + +{{end}} diff --git a/pkg/server/views/users/new.gohtml b/pkg/server/views/users/new.gohtml new file mode 100644 index 00000000..b99b6497 --- /dev/null +++ b/pkg/server/views/users/new.gohtml @@ -0,0 +1,57 @@ +{{define "yield"}} +
    +
    +

    Register

    + +
    +
    + {{template "signupForm"}} +
    +
    + + +
    +
    +{{end}} + +{{define "signupForm"}} +
    + {{csrfField}} + +
    +
    + +
    + +
    + +
    + + +
    +
    +{{end}} diff --git a/pkg/server/views/view.go b/pkg/server/views/view.go new file mode 100644 index 00000000..6d3c41df --- /dev/null +++ b/pkg/server/views/view.go @@ -0,0 +1,155 @@ +package views + +import ( + "bytes" + "fmt" + "html/template" + "io" + "net/http" + "path/filepath" + "strings" + + "github.com/dnote/dnote/pkg/server/context" + "github.com/dnote/dnote/pkg/server/log" + "github.com/gorilla/csrf" + "github.com/pkg/errors" +) + +const ( + // templateExt is the template extension + templateExt string = ".gohtml" +) + +const ( + siteTitle = "Dnote" +) + +// Config is a view config +type Config struct { + Title string + Layout string + HeaderTemplate string +} + +func (c Config) getLayout() string { + if c.Layout == "" { + return "base" + } + + return c.Layout +} + +// NewView returns a new view by parsing the given layout and files +func NewView(baseDir string, c Config, files ...string) *View { + addTemplatePath(baseDir, files) + addTemplateExt(files) + files = append(files, layoutFiles(baseDir)...) + + t, err := template.New(c.Title).Funcs(template.FuncMap{ + "csrfField": func() (template.HTML, error) { + return "", errors.New("csrfField is not implemented") + }, + "css": func() []string { + return strings.Split("", ",") + }, + "title": func() string { + if c.Title != "" { + return fmt.Sprintf("%s | %s", c.Title, siteTitle) + } + + return siteTitle + }, + "headerTemplate": func() string { + return c.HeaderTemplate + }, + }).ParseFiles(files...) + if err != nil { + panic(errors.Wrap(err, "instantiating view.")) + } + + return &View{ + Template: t, + Layout: c.getLayout(), + } +} + +// View holds the information about a view +type View struct { + Template *template.Template + Layout string +} + +func (v *View) ServeHTTP(w http.ResponseWriter, r *http.Request) { + v.Render(w, r, nil) +} + +// Render is used to render the view with the predefined layout. +func (v *View) Render(w http.ResponseWriter, r *http.Request, data interface{}) { + w.Header().Set("Content-Type", "text/html") + + var vd Data + switch d := data.(type) { + case Data: + vd = d + // do nothing + default: + vd = Data{ + Yield: data, + } + } + + if alert := getAlert(r); alert != nil { + vd.Alert = alert + clearAlert(w) + } + + vd.User = context.User(r.Context()) + + var buf bytes.Buffer + csrfField := csrf.TemplateField(r) + tpl := v.Template.Funcs(template.FuncMap{ + "csrfField": func() template.HTML { + return csrfField + }, + }) + + if err := tpl.ExecuteTemplate(&buf, v.Layout, vd); err != nil { + log.ErrorWrap(err, fmt.Sprintf("executing a template '%s'", v.Template.Name())) + http.Error(w, AlertMsgGeneric, http.StatusInternalServerError) + return + } + io.Copy(w, &buf) +} + +// layoutFiles returns a slice of strings representing +// the layout files used in our application. +func layoutFiles(baseDir string) []string { + pattern := fmt.Sprintf("%s/layouts/*%s", baseDir, templateExt) + + files, err := filepath.Glob(pattern) + if err != nil { + panic(err) + } + + return files +} + +// addTemplatePath takes in a slice of strings +// representing file paths for templates. +func addTemplatePath(baseDir string, files []string) { + for i, f := range files { + files[i] = fmt.Sprintf("%s/%s", baseDir, f) + } +} + +// addTemplateExt takes in a slice of strings +// representing file paths for templates and it appends +// the templateExt extension to each string in the slice +// +// Eg the input {"home"} would result in the output +// {"home.gohtml"} if templateExt == ".gohtml" +func addTemplateExt(files []string) { + for i, f := range files { + files[i] = f + templateExt + } +}