Implement MVC

This commit is contained in:
Sung Won Cho 2021-01-03 17:51:57 +11:00
commit bb0ef20c72
20 changed files with 918 additions and 14 deletions

7
go.mod
View file

@ -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

19
go.sum
View file

@ -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=

View file

@ -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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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"`
}

View file

@ -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"`
}

View file

@ -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() {

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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)
}

13
pkg/server/routes/user.go Normal file
View file

@ -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))
})
}

153
pkg/server/views/data.go Normal file
View file

@ -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
}

View file

@ -0,0 +1,8 @@
{{define "alert"}}
{{if .}}
<div class="alert alert-{{.Level}} alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
{{.Message}}
</div>
{{end}}
{{end}}

View file

@ -0,0 +1,18 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ title }}</title>
{{template "css" .}}
</head>
<body>
{{template "header" .}}
{{template "alert" .Alert}}
{{template "yield" .Yield}}
</body>
</html>
{{end}}

View file

@ -0,0 +1,5 @@
{{define "css"}}
{{range css}}
<link rel="stylesheet" href="/static/{{ . }}">
{{end}}
{{end}}

View file

@ -0,0 +1,7 @@
{{define "header"}}
{{if eq headerTemplate "navbar"}}
{{ template "navbar" . }}
{{end}}
{{end}}

View file

@ -0,0 +1,32 @@
{{define "navbar"}}
<header class="header-wrapper">
<div class="container">
<div class="header-content">
<div class="left">
<a href="/">NAD</a>
<nav>
<ul class="list-unstyled main-nav">
<li><a href="/new">New</a></li>
<li><a href="/books">Books</a></li>
</ul>
</nav>
</div>
<div>
{{if .User}}
<li>{{template "logoutForm"}}</li>
{{end}}
</div>
</div>
</div>
</header>
{{end}}
{{define "logoutForm"}}
<form class="navbar-form navbar-left" action="/logout" method="POST">
{{csrfField}}
<button type="submit" class="btn btn-default">Log out</button>
</form>
{{end}}

View file

@ -0,0 +1,57 @@
{{define "yield"}}
<div class="auth-page">
<div class="container">
<h1 class="heading">Register</h1>
<div class="body">
<div class="panel">
{{template "signupForm"}}
</div>
</div>
<div class="footer">
<div class="callout">Already have an account?</div>
<a href="/login" class="cta">
Sign in
</a>
</div>
</div>
</div>
{{end}}
{{define "signupForm"}}
<form action="/register" method="POST">
{{csrfField}}
<div class="form-group">
<div class="input-row">
<label for="email-input" class="label">
Email
<input
id="email-input"
name="email"
type="email"
placeholder="you@example.com"
class="form-control"
value="{{.Email}}"
/>
</label>
</div>
<div class="input-row">
<label for="password-input" class="label">
Password
<input
id="password-input"
name="password"
type="password"
placeholder="&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;"
class="form-control"
/>
</label>
</div>
<button type="submit" class="auth-button button button-normal button-stretch button-third">Register</button>
</div>
</form>
{{end}}

155
pkg/server/views/view.go Normal file
View file

@ -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
}
}