diff --git a/app_test.go b/app_test.go index 1929aef..2644bbb 100644 --- a/app_test.go +++ b/app_test.go @@ -1,6 +1,6 @@ // @Copyright (c) 2016 mparaiso All rights reserved. -package gonews_test +package main_test import ( "database/sql" @@ -11,12 +11,11 @@ import ( "net/http/httptest" "os" "path" - "testing" "github.com/PuerkitoBio/goquery" _ "github.com/mattn/go-sqlite3" - "github.com/mparaiso/go-news" + "github.com/mparaiso/go-news/internal" "github.com/rubenv/sql-migrate" "net/url" @@ -151,6 +150,19 @@ func TestAppLogin_GET(t *testing.T) { } } +// TestAppLogin_POST logs a registered user into the application +func TestAppLogin_POST(t *testing.T) { + _, _, _, err := LoginUserHelper(t) + if err != nil { + t.Fatal(err) + } +} + +func TestAppLogout(t *testing.T) { + // db, server, user, err := LoginUserHelper(t) + +} + // TestAppLogin_POST_registration tests the registration process and verifies // the new user has been persisted into the db func TestAppLogin_POST_registration(t *testing.T) { @@ -183,7 +195,7 @@ func TestAppLogin_POST_registration(t *testing.T) { "registration_email": {"jefferson@acme.com"}, }) resp, err = http.Post(server.URL+"/register", "application/x-www-form-urlencoded", strings.NewReader(values.Encode())) - defer resp.Body.Close() + // defer resp.Body.Close() if err != nil { t.Fatal(err) } @@ -203,6 +215,70 @@ func TestAppLogin_POST_registration(t *testing.T) { ///http.CookieJar } +// LoginUserHelper logs a user before executing a test +func LoginUserHelper(t *testing.T) (*sql.DB, *httptest.Server, *gonews.User, error) { + // Setup + db := GetDB(t) + server := SetUp(t, db) + unencryptedPassword := "password" + user := &gonews.User{Username: "mike_doe", Email: "mike_doe@acme.com"} + user.CreateSecurePassword(unencryptedPassword) + result, err := db.Exec("INSERT INTO users(username,email,password) values(?,?,?);", user.Username, user.Email, user.Password) + if err != nil { + t.Fatal(err) + } + // t.Logf("%#v", user) + if n, err := result.RowsAffected(); err != nil || n != 1 { + t.Fatal(n, err) + } + defer server.Close() + http.DefaultClient.Jar = NewTestCookieJar() + // test + res, err := http.Get(server.URL + "/login") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + doc, err := goquery.NewDocumentFromResponse(res) + if err != nil { + t.Fatal(err) + } + selection := doc.Find("input[name='login_csrf']") + + csrf, ok := selection.First().Attr("value") + if !ok { + t.Fatal("csrf not found in HTML document", selection, ok) + } + if strings.Trim(csrf, " ") == "" { + t.Fatal("csrf not found") + } + formValues := url.Values{ + "login_username": {user.Username}, + "login_password": {unencryptedPassword}, + "login_csrf": {csrf}, + } + res, err = http.Post(server.URL+"/login", "application/x-www-form-urlencoded", strings.NewReader(formValues.Encode())) + + if err != nil { + t.Fatal(err) + } + + if expected, got := 200, res.StatusCode; expected != got { + //t.Logf(" %s %s", ioutil.ReadAll(res.Body)) + t.Fatalf("POST /login status : expected '%v' got '%v'", expected, got) + } + doc, err = goquery.NewDocumentFromResponse(res) + if err != nil { + t.Fatal(err) + } + selection = doc.Find(".current-user") + t.Log(doc.Html()) + if expected, got := 1, selection.Length(); expected != got { + t.Fatalf(".current-user length : expect '%v' got '%v' ", expected, got) + } + return db, server, user, err +} + func TestApp_404(t *testing.T) { server := SetUp(t) defer server.Close() @@ -238,7 +314,7 @@ var Directory = func() string { return dir }() -var MigrationDirectory = path.Join(Directory, "cmd", "go-news", "migrations", "development", "sqlite3") +var MigrationDirectory = path.Join(Directory, "migrations", "development", "sqlite3") func GetDB(t *testing.T) *sql.DB { db, err := sql.Open("sqlite3", ":memory:") @@ -258,7 +334,7 @@ func MigrateUp(db *sql.DB) *sql.DB { func TestingGetOptions(db *sql.DB) gonews.ContainerOptions { options := gonews.DefaultContainerOptions() options.Debug = DEBUG - options.TemplateDirectory = path.Join(Directory, "cmd", "go-news", options.TemplateDirectory) + options.TemplateDirectory = path.Join(Directory, options.TemplateDirectory) options.ConnectionFactory = func() (*sql.DB, error) { return db, nil } diff --git a/cmd/go-news/go-news.exe~ b/cmd/go-news/go-news.exe~ deleted file mode 100644 index d2eadca..0000000 Binary files a/cmd/go-news/go-news.exe~ and /dev/null differ diff --git a/cmd/go-news/db.sqlite3 b/db.sqlite3 similarity index 100% rename from cmd/go-news/db.sqlite3 rename to db.sqlite3 diff --git a/cmd/go-news/dbconfig.yml b/dbconfig.yml similarity index 100% rename from cmd/go-news/dbconfig.yml rename to dbconfig.yml diff --git a/interfaces.go b/interfaces.go deleted file mode 100644 index 0023fb7..0000000 --- a/interfaces.go +++ /dev/null @@ -1,12 +0,0 @@ -package gonews - -// CSRFProvider provide csrf tokens -type CSRFProvider interface { - Generate(userID, actionID string) string - Valid(token, userID, actionID string) bool -} - -type UserFinder interface { - GetOneByEmail(string) (*User, error) - GetOneByUsername(string) (*User, error) -} diff --git a/app.go b/internal/app.go similarity index 77% rename from app.go rename to internal/app.go index 8f5650f..3f65b40 100644 --- a/app.go +++ b/internal/app.go @@ -24,6 +24,9 @@ var DefaultContainerOptions = func() func() ContainerOptions { return func() ContainerOptions { return ContainerOptions{ Debug: true, + Title: "gonews", + Slogan: "the news site for gophers", + Description: "gonews is a site where gophers publish and discuss news about the go language", DataSource: "db.sqlite3", Driver: "sqlite3", TemplateDirectory: "templates", @@ -63,23 +66,25 @@ func GetApp(options ContainerOptions, appOptions AppOptions) http.Handler { } DefaultStack := &Stack{ Middlewares: []Middleware{ - StopWatchMiddleware, - LoggingMiddleware, - SessionMiddleware, - CSRFMiddleWare, - TemplateMiddleware, + StopWatchMiddleware, // Benchmarks the stack execution time + LoggingMiddleware, // Logs each request formatted by the common log format + SessionMiddleware, // Initialize the session + CSRFMiddleWare, // Initiliaze the CSRF functionality + RefreshUserMiddleware, // Refresh an authenticated user if user_id exists in session + TemplateMiddleware, // Configures templates environment }, ContainerFactory: containerFactory} // A middleware stack with request logging Default := DefaultStack.Build() // A middleware stack that extends Zero and handles requests for missing pages - Home := DefaultStack.Clone().Push(NotFoundMiddleware).Build() app := &App{http.NewServeMux()} // homepage - app.HandleFunc("/", Home(ThreadIndexController)) + app.HandleFunc("/", Default(NotFoundMiddleware, ThreadIndexController)) // thread app.HandleFunc("/thread", Default(ThreadShowController)) // login app.HandleFunc("/login", Default(LoginController)) + // logout + app.HandleFunc("/logout", Default(PostOnlyMiddleware, LogoutController)) // user app.HandleFunc("/user", Default(UserShowController)) // submitted user stories diff --git a/container.go b/internal/container.go similarity index 90% rename from container.go rename to internal/container.go index 7eaa20f..cbae374 100644 --- a/container.go +++ b/internal/container.go @@ -27,12 +27,16 @@ type ContainerOptions struct { DataSource, Driver, Secret, + Title, + Slogan, + Description, TemplateDirectory string Debug bool SessionStoreFactory func() (sessions.Store, error) ConnectionFactory func() (*sql.DB, error) LoggerFactory func() (LoggerInterface, error) csrfProvider CSRFProvider + user *User } // Container contains all the application dependencies @@ -46,6 +50,22 @@ type Container struct { session SessionInterface } +// HasAuthenticatedUser returns true if a user has been authenticated +func (c *Container) HasAuthenticatedUser() bool { + return c.user != nil +} + +// SetCurrentUser sets the authenticated user +func (c *Container) SetCurrentUser(u *User) { + c.user = u +} + +// CurrentUser returns an authenticated user +func (c *Container) CurrentUser() *User { + return c.user +} + +// GetSecret returns the secret key func (c *Container) GetSecret() string { return c.ContainerOptions.Secret } @@ -113,6 +133,7 @@ func (c *Container) GetThreadRepository() (*ThreadRepository, error) { return c.threadRepository, nil } +// MustGetThreadRepository panics on error func (c *Container) MustGetThreadRepository() *ThreadRepository { r, err := c.GetThreadRepository() if err != nil { @@ -153,6 +174,11 @@ func (c *Container) GetCSRFProvider(request *http.Request) CSRFProvider { return c.csrfProvider } +// GetOptions returns the container's options +func (c *Container) GetOptions() ContainerOptions { + return c.ContainerOptions +} + // GetLogger gets a logger func (c *Container) GetLogger() (LoggerInterface, error) { if c.logger == nil { diff --git a/controllers.go b/internal/controllers.go similarity index 63% rename from controllers.go rename to internal/controllers.go index dea9586..565d5f5 100644 --- a/controllers.go +++ b/internal/controllers.go @@ -11,21 +11,22 @@ import ( // ThreadIndexController displays a list of links func ThreadIndexController(c *Container, rw http.ResponseWriter, r *http.Request, next func()) { - repository, err := c.GetThreadRepository() - if err != nil { - c.HTTPError(rw, r, 500, err) - return - } - threads, err := repository.GetThreadsOrderedByVoteCount(100, 0) - if err != nil { - c.HTTPError(rw, r, 500, err) - return - } + var threads Threads - err = c.MustGetTemplate().ExecuteTemplate(rw, "thread_list.tpl.html", map[string]interface{}{"Threads": threads}) - if err != nil { - c.HTTPError(rw, r, 500, err) - } + repository, err := c.GetThreadRepository() + if err == nil { + threads, err = repository.GetThreadsOrderedByVoteCount(100, 0) + if err == nil { + err = c.MustGetTemplate().ExecuteTemplate(rw, "thread_list.tpl.html", map[string]interface{}{ + "Threads": threads, + "Title": "homepage", + }) + if err == nil { + return + } + } + } + c.HTTPError(rw, r, 500, err) } // ThreadListByAuthorIDController displays user's submitted stories @@ -86,21 +87,78 @@ func ThreadShowController(c *Container, rw http.ResponseWriter, r *http.Request, } } +// LogoutController logs out a user +func LogoutController(c *Container, rw http.ResponseWriter, r *http.Request, next func()) { + c.MustGetSession(r).Set("user_id", nil) + c.SetCurrentUser(nil) + http.Redirect(rw, r, "/", http.StatusOK) +} + // LoginController displays the login/signup page func LoginController(c *Container, rw http.ResponseWriter, r *http.Request, next func()) { - loginCSRF := c.GetCSRFProvider(r).Generate( r.RemoteAddr, "login") - loginForm := &LoginForm{CSRF: loginCSRF, Name: "login"} - registrationCSRF := c.GetCSRFProvider(r).Generate( r.RemoteAddr, "registration") - registrationForm := &RegistrationForm{CSRF: registrationCSRF, Name: "registration"} - err := c.MustGetTemplate().ExecuteTemplate(rw, "login.tpl.html", map[string]interface{}{ - "LoginForm": loginForm, - "RegistrationForm": registrationForm, - }) - if err != nil { - c.HTTPError(rw, r, 500, err) + switch r.Method { + case "GET": + loginCSRF := c.GetCSRFProvider(r).Generate(r.RemoteAddr, "login") + loginForm := &LoginForm{CSRF: loginCSRF, Name: "login"} + registrationCSRF := c.GetCSRFProvider(r).Generate(r.RemoteAddr, "registration") + registrationForm := &RegistrationForm{CSRF: registrationCSRF, Name: "registration"} + err := c.MustGetTemplate().ExecuteTemplate(rw, "login.tpl.html", map[string]interface{}{ + "LoginForm": loginForm, + "RegistrationForm": registrationForm, + }) + if err != nil { + c.HTTPError(rw, r, 500, err) + } + return + case "POST": + var loginErrorMessage string + var candidate *User + err := r.ParseForm() + if err != nil { + c.HTTPError(rw, r, 500, err) + return + } + loginForm := &LoginForm{} + err = loginForm.HandleRequest(r) + if err != nil { + c.HTTPError(rw, r, 500, err) + return + } + loginFormValidator := &LoginFormValidator{c.GetCSRFProvider(r), r} + err = loginFormValidator.Validate(loginForm) + // authenticate user + if err == nil { + user := loginForm.Model() + userRepository := c.MustGetUserRepository() + candidate, err = userRepository.GetOneByUsername(user.Username) + if err == nil && candidate != nil { + err = candidate.Authenticate(user.Password) + if err == nil { + // authenticated + c.MustGetSession(r).Set("user_id", candidate.ID) + http.Redirect(rw, r, "/", http.StatusOK) + return + } + } else if candidate == nil { + loginErrorMessage = "Invalid Credentials" + } + } + rw.WriteHeader(http.StatusBadRequest) + registrationCSRF := c.GetCSRFProvider(r).Generate(r.RemoteAddr, "registration") + registrationForm := &RegistrationForm{CSRF: registrationCSRF, Name: "registration"} + err = c.MustGetTemplate().ExecuteTemplate(rw, "login.tpl.html", map[string]interface{}{ + "LoginForm": loginForm, + "RegistrationForm": registrationForm, + "LoginErrorMessage": loginErrorMessage, + }) + return + + default: + c.HTTPError(rw, r, http.StatusNotFound, http.StatusText(http.StatusNotFound)) } } +// RegistrationController handles user registration func RegistrationController(c *Container, rw http.ResponseWriter, r *http.Request, next func()) { // Parse form err := r.ParseForm() diff --git a/csrf.go b/internal/csrf.go similarity index 68% rename from csrf.go rename to internal/csrf.go index b1ad756..4bb74e4 100644 --- a/csrf.go +++ b/internal/csrf.go @@ -14,16 +14,16 @@ type DefaultCSRFProvider struct { // Generate generates a new token func (d *DefaultCSRFProvider) Generate(userID, actionID string) string { t := xsrftoken.Generate(d.Secret, userID, actionID) - sessionName := fmt.Sprintf("%v-%v", userID, actionID) - d.Session.Set(sessionName, t) + tokenNameInSession := fmt.Sprintf("%v-%v", userID, actionID) + d.Session.Set(tokenNameInSession, t) return t } // Valid valides a token func (d *DefaultCSRFProvider) Valid(token, userID, actionID string) bool { - sessionName := fmt.Sprintf("%v-%v", userID, actionID) - t := fmt.Sprint(d.Session.Get(sessionName)) - d.Session.Set(sessionName, nil) + tokenNameInSession := fmt.Sprintf("%v-%v", userID, actionID) + t := fmt.Sprint(d.Session.Get(tokenNameInSession)) + d.Session.Set(tokenNameInSession, nil) if t != token { return false } diff --git a/db_tools.go b/internal/db_tools.go similarity index 100% rename from db_tools.go rename to internal/db_tools.go diff --git a/forms.go b/internal/forms.go similarity index 66% rename from forms.go rename to internal/forms.go index d139bc7..dfb1b76 100644 --- a/forms.go +++ b/internal/forms.go @@ -35,11 +35,28 @@ func (form *RegistrationForm) HandleRequest(r *http.Request) error { return decoder.Decode(form, r.PostForm) } -// LoginForm is a login form +// LoginForm implements Form type LoginForm struct { Name string - CSRF string - Username string - Password string + CSRF string `schema:"login_csrf"` + Username string `schema:"login_username"` + Password string `schema:"login_password"` Errors map[string][]string + model *User +} + +// HandleRequest deserialize the request body into a form struct +func (form *LoginForm) HandleRequest(r *http.Request) error { + return decoder.Decode(form, r.PostForm) +} + +// Model return the underlying form model +func (form *LoginForm) Model() *User { + if form.model == nil { + form.model = &User{ + Username: form.Username, + Password: form.Password, + } + } + return form.model } diff --git a/internal/interfaces.go b/internal/interfaces.go new file mode 100644 index 0000000..cfae07e --- /dev/null +++ b/internal/interfaces.go @@ -0,0 +1,30 @@ +package gonews + +import ( + "net/http" +) + +// Form interface is a form +type Form interface { + // HandleRequest deserialize the request body into a form struct + HandleRequest(r *http.Request) error +} + +// CSRFProvider provide csrf tokens +type CSRFProvider interface { + Generate(userID, actionID string) string + Valid(token, userID, actionID string) bool +} + +// UserFinder can find users from a datasource +type UserFinder interface { + GetOneByEmail(string) (*User, error) + GetOneByUsername(string) (*User, error) +} + +// ValidationError is a validation error +type ValidationError interface { + HasErrors() bool + Append(key, value string) + Error() string +} diff --git a/logger.go b/internal/logger.go similarity index 100% rename from logger.go rename to internal/logger.go diff --git a/middlewares.go b/internal/middlewares.go similarity index 78% rename from middlewares.go rename to internal/middlewares.go index e94edfc..73afaa4 100644 --- a/middlewares.go +++ b/internal/middlewares.go @@ -22,6 +22,28 @@ func (h HandlerFunc) ServeHTTP(rw http.ResponseWriter, r *http.Request) { h(rw, r) } +// RefreshUserMiddleware keeps the application aware of the current user but does not authenticate or authorize +func RefreshUserMiddleware(c *Container, rw http.ResponseWriter, r *http.Request, next func()) { + session := c.MustGetSession(r) + + if session.Has("user_id") { + userID := c.MustGetSession(r).Get("user_id").(int64) + user, err := c.MustGetUserRepository().GetById(userID) + if err == nil { + if user != nil { + c.SetCurrentUser(user) + } + } else { + c.HTTPError(rw, r, 500, err) + return + } + } else { + c.SetCurrentUser(nil) + } + next() + +} + //PostOnlyMiddleware filters post requests func PostOnlyMiddleware(c *Container, rw http.ResponseWriter, r *http.Request, next func()) { if r.Method == "POST" { @@ -39,14 +61,15 @@ func TemplateMiddleware(c *Container, rw http.ResponseWriter, r *http.Request, n requestDump = bytes.NewBuffer(dump).String() } - c.MustGetTemplate().SetEnvironment(struct { - FlashMessages []interface{} - Request string - Description struct{ Title string } - }{ - c.MustGetSession(r).Flashes(), - requestDump, - struct{ Title string }{"GoNews"}, + c.MustGetTemplate().SetEnvironment(&TemplateEnvironment{ + FlashMessages: c.MustGetSession(r).Flashes(), + Request: requestDump, + Description: struct{ Title, Slogan, Description string }{ + c.GetOptions().Title, + c.GetOptions().Slogan, + c.GetOptions().Description, + }, + CurrentUser: c.CurrentUser(), }) next() } diff --git a/models.go b/internal/models.go similarity index 100% rename from models.go rename to internal/models.go diff --git a/repositories.go b/internal/repositories.go similarity index 98% rename from repositories.go rename to internal/repositories.go index 83b783f..94f1308 100644 --- a/repositories.go +++ b/internal/repositories.go @@ -76,7 +76,7 @@ func (ur *UserRepository) GetOneByUsername(username string) (user *User, err err ur.debug(query, username) row := ur.DB.QueryRow(query, username) user = new(User) - err = MapRowToStruct([]string{"ID", "Password", "Email", "Created", "Updated"}, row, user, true) + err = MapRowToStruct([]string{"ID", "Username", "Password", "Email", "Created", "Updated"}, row, user, true) if err != nil { switch err { case sql.ErrNoRows: diff --git a/response.go b/internal/response.go similarity index 100% rename from response.go rename to internal/response.go diff --git a/session.go b/internal/session.go similarity index 100% rename from session.go rename to internal/session.go diff --git a/stack.go b/internal/stack.go similarity index 100% rename from stack.go rename to internal/stack.go diff --git a/template.go b/internal/template.go similarity index 70% rename from template.go rename to internal/template.go index 41cfdfd..f127c6d 100644 --- a/template.go +++ b/internal/template.go @@ -5,6 +5,17 @@ import ( "io" ) +// TemplateEnvironment is used to store +// global data common to all templates . +// it is available as .Environment variable in all templates . +// Data specific to a controller are available through .Data variable . +type TemplateEnvironment struct { + FlashMessages []interface{} + Request string + Description struct{ Title, Slogan, Description string } + CurrentUser *User +} + // TemplateProvider provides templates type TemplateProvider interface { ExecuteTemplate(io.Writer, string, interface{}) error diff --git a/internal/validators.go b/internal/validators.go new file mode 100644 index 0000000..fa67975 --- /dev/null +++ b/internal/validators.go @@ -0,0 +1,175 @@ +package gonews + +import ( + "fmt" + "net/http" + "regexp" + "strings" +) + +// ConcreteValidationError holds errors in a map +type ConcreteValidationError map[string][]string + +// Append adds an new error to a map +func (v ConcreteValidationError) Append(key string, value string) { + v[key] = append(v[key], value) +} + +func (v ConcreteValidationError) Error() string { + return fmt.Sprintf("%#v", v) +} + +// HasErrors returns true if error exists +func (v ConcreteValidationError) HasErrors() bool { + return len(v) != 0 +} + +// UserValidator is a User validator +type UserValidator struct { +} + +// Validate validates a user +func (uv UserValidator) Validate(u *User) ValidationError { + + errors := ConcreteValidationError{} + + StringNotEmptyValidator("Username", u.Username, &errors) + StringMinLengthValidator("Username", u.Username, 6, &errors) + StringMaxLengthValidator("Username", u.Username, 100, &errors) + + StringNotEmptyValidator("Email", u.Email, &errors) + StringMinLengthValidator("Email", u.Email, 6, &errors) + StringMaxLengthValidator("Email", u.Email, 100, &errors) + EmailValidator("Email", u.Email, &errors) + + StringNotEmptyValidator("Password", u.Password, &errors) + StringMinLengthValidator("Password", u.Password, 8, &errors) + StringMaxLengthValidator("Password", u.Password, 255, &errors) + + if !errors.HasErrors() { + return nil + } + return errors +} + +// RegistrationFormValidator is a RegistrationForm validator +type RegistrationFormValidator struct { + request *http.Request + csrfProvider CSRFProvider + userRepository UserFinder +} + +// NewRegistrationFormValidator creates an new RegistrationFormValidator +func NewRegistrationFormValidator(request *http.Request, csrfProvider CSRFProvider, userFinder UserFinder) *RegistrationFormValidator { + return &RegistrationFormValidator{request, csrfProvider, userFinder} +} + +// Validate returns nil if no error were found +func (validator *RegistrationFormValidator) Validate(form *RegistrationForm) ValidationError { + errors := ConcreteValidationError{} + // CSRF + StringNotEmptyValidator("CSRF", form.CSRF, &errors) + CSRFValidator("CSRF", form.CSRF, validator.csrfProvider, validator.request.RemoteAddr, "registration", &errors) + form.CSRF = validator.csrfProvider.Generate(validator.request.RemoteAddr, "registration") + // Username + StringNotEmptyValidator("Username", form.Username, &errors) + StringMinLengthValidator("Username", form.Username, 5, &errors) + StringMaxLengthValidator("Username", form.Username, 100, &errors) + // validate unique username + if user, err := validator.userRepository.GetOneByUsername(form.Username); user != nil && err == nil { + errors.Append("Username", "invalid, please choose another username") + } + // Email + StringNotEmptyValidator("Email", form.Email, &errors) + StringMinLengthValidator("Email", form.Email, 5, &errors) + StringMaxLengthValidator("Email", form.Email, 100, &errors) + EmailValidator("Email", form.Email, &errors) + // validate unique email + if user, err := validator.userRepository.GetOneByEmail(form.Email); user != nil && err == nil { + errors.Append("Email", "invalid, please choose another email") + } + // Password + StringNotEmptyValidator("Password", form.Password, &errors) + StringMinLengthValidator("Password", form.Password, 7, &errors) + StringMaxLengthValidator("Password", form.Password, 255, &errors) + MatchValidator("Password", "PasswordConfirmation", form.Password, form.PasswordConfirmation, &errors) + + if !errors.HasErrors() { + return nil + } + form.Errors = errors + return errors +} + +// LoginFormValidator is a validator for LoginForm +type LoginFormValidator struct { + csrfProvider CSRFProvider + request *http.Request +} + +// Validate validates a login form +func (validator *LoginFormValidator) Validate(form *LoginForm) ValidationError { + errors := ConcreteValidationError{} + StringNotEmptyValidator("Username", form.Username, &errors) + StringNotEmptyValidator("Password", form.Password, &errors) + CSRFValidator("CSRF", form.CSRF, validator.csrfProvider, validator.request.RemoteAddr, "login", &errors) + form.CSRF = validator.csrfProvider.Generate(validator.request.RemoteAddr, "login") + + if !errors.HasErrors() { + return nil + } + form.Errors = errors + return errors +} + +/* + +HELPER FUNCTIONS + +*/ + +// StringNotEmptyValidator checks if a string is empty +func StringNotEmptyValidator(field string, value string, errors ValidationError) { + if strings.Trim(value, " ") == "" { + errors.Append(field, "should not be empty") + } +} + +// StringMinLengthValidator validates a string by minimum length +func StringMinLengthValidator(field, value string, minlength int, errors ValidationError) { + if len(value) < minlength { + errors.Append(field, fmt.Sprintf("should be at least %d character long", minlength)) + } +} + +// StringMaxLengthValidator validates a string by maximum length +func StringMaxLengthValidator(field, value string, maxlength int, errors ValidationError) { + if len(value) > maxlength { + errors.Append(field, "should be at most %d character long") + } +} + +// MatchValidator validates a string by an expected value +func MatchValidator(field1 string, field2 string, value1, value2 interface{}, errors ValidationError) { + if value1 != value2 { + errors.Append(field1, fmt.Sprintf("should match %s ", field2)) + } +} + +// EmailValidator validates an email +func EmailValidator(field, value string, errors ValidationError) { + if !isEmail(value) { + errors.Append(field, "should be a valid email") + } +} + +// CSRFValidator validates a CSRF Token +func CSRFValidator(field string, value string, csrfProvider CSRFProvider, remoteAddr, action string, errors ValidationError) { + if !csrfProvider.Valid(value, remoteAddr, action) { + errors.Append(field, "invalid token") + } +} + +func isEmail(candidate string) bool { + return regexp.MustCompile(`\w+@\w+\.\w+`).MatchString(candidate) +} diff --git a/cmd/go-news/main.go b/main.go similarity index 94% rename from cmd/go-news/main.go rename to main.go index 7afddfa..437c56a 100644 --- a/cmd/go-news/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "net/http" _ "github.com/mattn/go-sqlite3" - gonews "github.com/mparaiso/go-news" + gonews "github.com/mparaiso/go-news/internal" ) const version = "0.0.1-alpha" diff --git a/cmd/go-news/migrations/development/sqlite3/001-initial-migration.sql b/migrations/development/sqlite3/001-initial-migration.sql similarity index 100% rename from cmd/go-news/migrations/development/sqlite3/001-initial-migration.sql rename to migrations/development/sqlite3/001-initial-migration.sql diff --git a/cmd/go-news/migrations/development/sqlite3/002-other-tables.sql b/migrations/development/sqlite3/002-other-tables.sql similarity index 100% rename from cmd/go-news/migrations/development/sqlite3/002-other-tables.sql rename to migrations/development/sqlite3/002-other-tables.sql diff --git a/cmd/go-news/migrations/development/sqlite3/003-fixtures.sql b/migrations/development/sqlite3/003-fixtures.sql similarity index 100% rename from cmd/go-news/migrations/development/sqlite3/003-fixtures.sql rename to migrations/development/sqlite3/003-fixtures.sql diff --git a/models_test.go b/models_test.go index f8377a9..f3294c4 100644 --- a/models_test.go +++ b/models_test.go @@ -1,9 +1,9 @@ -package gonews_test +package main_test import ( "testing" - "github.com/mparaiso/go-news" + "github.com/mparaiso/go-news/internal" ) func Test_Comments_GetTree(t *testing.T) { diff --git a/cmd/go-news/public/css/styles.css b/public/css/styles.css similarity index 100% rename from cmd/go-news/public/css/styles.css rename to public/css/styles.css diff --git a/cmd/go-news/public/favicon.ico b/public/favicon.ico similarity index 100% rename from cmd/go-news/public/favicon.ico rename to public/favicon.ico diff --git a/repositories_test.go b/repositories_test.go index 6203541..7c91d2e 100644 --- a/repositories_test.go +++ b/repositories_test.go @@ -1,7 +1,7 @@ -package gonews_test +package main_test import "testing" -import gonews "github.com/mparaiso/go-news" +import gonews "github.com/mparaiso/go-news/internal" func TestThreadRepository_GetByAuthorID(t *testing.T) { // DEBUG = true diff --git a/stack_test.go b/stack_test.go index 5366f85..d612e85 100644 --- a/stack_test.go +++ b/stack_test.go @@ -1,4 +1,4 @@ -package gonews_test +package main_test import ( "fmt" @@ -6,7 +6,7 @@ import ( "net/http/httptest" "time" - "github.com/mparaiso/go-news" + "github.com/mparaiso/go-news/internal" ) // ExampleStack_first demonstrates the use of the middleware stack diff --git a/cmd/go-news/templates/error.tpl.html b/templates/error.tpl.html similarity index 100% rename from cmd/go-news/templates/error.tpl.html rename to templates/error.tpl.html diff --git a/cmd/go-news/templates/layout.tpl.html b/templates/layout.tpl.html similarity index 81% rename from cmd/go-news/templates/layout.tpl.html rename to templates/layout.tpl.html index 5b0e2eb..0cbb53a 100644 --- a/cmd/go-news/templates/layout.tpl.html +++ b/templates/layout.tpl.html @@ -13,12 +13,13 @@ {{ end }} {{ block "scripts" . }} - + {{ .Environment.Description.Title }} - {{ or .Data.Title .Environment.Description.Slogan }} {{ end }} {{ block "navigation" . }} +