├── .buffalo.dev.yml ├── .codeclimate.yml ├── .dockerignore ├── .github └── workflows │ └── main.yml ├── .gitignore ├── Dockerfile ├── README.md ├── actions ├── actions_test.go ├── app.go ├── articles.go ├── auth.go ├── auth_test.go ├── home.go ├── home_test.go ├── render.go ├── users.go └── users_test.go ├── config ├── buffalo-app.toml └── buffalo-plugins.toml ├── database.yml ├── docker-compose.yml ├── fixtures └── basics.toml ├── go.mod ├── go.sum ├── grifts ├── db.go └── init.go ├── heroku.yml ├── inflections.json ├── locales └── all.en-us.yaml ├── logo.png ├── main.go ├── migrations ├── 20201014180138_create_users.down.fizz ├── 20201014180138_create_users.up.fizz ├── 20201017150233_create_articles.down.fizz ├── 20201017150233_create_articles.up.fizz ├── 20201017195238_create_comments.down.fizz ├── 20201017195238_create_comments.up.fizz ├── 20201018160448_create_article_favorites.down.fizz ├── 20201018160448_create_article_favorites.up.fizz ├── 20201021191845_create_follows.down.fizz ├── 20201021191845_create_follows.up.fizz ├── 20201024085556_create_tags.down.fizz ├── 20201024085556_create_tags.up.fizz ├── 20201024085916_create_article_tags.down.fizz ├── 20201024085916_create_article_tags.up.fizz └── schema.sql ├── models ├── article.go ├── article_favorite.go ├── article_favorite_test.go ├── article_tag.go ├── article_tag_test.go ├── article_test.go ├── comment.go ├── comment_test.go ├── follow.go ├── follow_test.go ├── models.go ├── models_test.go ├── tag.go ├── tag_test.go ├── user.go └── user_test.go ├── public ├── embed.go └── robots.txt ├── renovate.json └── templates ├── _flash.html ├── _flash.plush.html ├── application.html ├── application.plush.html ├── articles ├── _list.html ├── _list.plush.html ├── _meta.html ├── _meta.plush.html ├── edit.html ├── edit.plush.html ├── new.html ├── new.plush.html ├── read.html └── read.plush.html ├── auth ├── login.html └── login.plush.html ├── embed.go ├── index.html ├── index.plush.html └── users ├── profile.html ├── profile.plush.html ├── register.html └── register.plush.html /.buffalo.dev.yml: -------------------------------------------------------------------------------- 1 | app_root: . 2 | ignored_folders: 3 | - vendor 4 | - log 5 | - logs 6 | - assets 7 | - public 8 | - grifts 9 | - tmp 10 | - bin 11 | - node_modules 12 | - .sass-cache 13 | included_extensions: 14 | - .go 15 | - .env 16 | build_path: tmp 17 | build_delay: 200ns 18 | binary_name: gobuff-realworld-example-app-build 19 | command_flags: [] 20 | enable_colors: true 21 | log_name: buffalo 22 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | fixme: 3 | enabled: true 4 | gofmt: 5 | enabled: true 6 | golint: 7 | enabled: true 8 | govet: 9 | enabled: true 10 | exclude_paths: 11 | - grifts/**/* 12 | - "**/*_test.go" 13 | - "*_test.go" 14 | - "**_test.go" 15 | - logs/* 16 | - public/* 17 | - templates/* 18 | ratings: 19 | paths: 20 | - "**.go" 21 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log 3 | bin/ 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | - name: Publish to Registry 15 | uses: elgohr/Publish-Docker-Github-Action@v5 16 | with: 17 | name: remast/gobuff_realworld_example_app/app 18 | username: ${{ github.repository_owner }} 19 | password: ${{ secrets.CR_PAT }} 20 | registry: ghcr.io 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | **/*.log 3 | **/*.sqlite 4 | .idea/ 5 | bin/ 6 | tmp/ 7 | node_modules/ 8 | .sass-cache/ 9 | *-packr.go 10 | public/assets/ 11 | .vscode/ 12 | .grifter/ 13 | .env 14 | **/.DS_Store 15 | *.pid 16 | coverage 17 | coverage.data 18 | .svn 19 | .console_history 20 | .sass-cache/* 21 | .jhw-cache/ 22 | jhw.* 23 | *.sublime* 24 | node_modules/ 25 | dist/ 26 | generated/ 27 | .vendor/ 28 | 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This is a multi-stage Dockerfile and requires >= Docker 17.05 2 | # https://docs.docker.com/engine/userguide/eng-image/multistage-build/ 3 | FROM gobuffalo/buffalo:v0.18.1 as builder 4 | 5 | RUN mkdir -p $GOPATH/src/gobuff_realworld_example_app 6 | WORKDIR $GOPATH/src/gobuff_realworld_example_app 7 | 8 | ADD . . 9 | ENV GO111MODULES=on 10 | RUN go get ./... 11 | RUN buffalo build --static -o /bin/app 12 | 13 | FROM alpine 14 | RUN apk add --no-cache bash 15 | RUN apk add --no-cache ca-certificates 16 | 17 | WORKDIR /bin/ 18 | 19 | COPY --from=builder /bin/app . 20 | 21 | # Uncomment to run the binary in "production" mode: 22 | # ENV GO_ENV=production 23 | 24 | # Bind the app to 0.0.0.0 so it can be seen from outside the container 25 | ENV ADDR=0.0.0.0 26 | 27 | EXPOSE 3000 28 | 29 | ## Add the wait script to the image 30 | ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.9.0/wait /wait 31 | RUN chmod +x /wait 32 | 33 | # Run the migrations before running the binary: 34 | CMD /wait; /bin/app migrate; /bin/app 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |  2 | 3 | #  4 | 5 | > ### Buffalo codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 6 | 7 | 8 | ### [Demo](https://gobuff-realworld-example-app.herokuapp.com/) [RealWorld](https://github.com/gothinkster/realworld) 9 | 10 | 11 | This codebase was created to demonstrate a fully fledged fullstack application built with **[Buffalo](http://gobuffalo.io)** including CRUD operations, authentication, routing, pagination, and more. 12 | 13 | We've gone to great lengths to adhere to the **[Buffalo](http://gobuffalo.io)** community styleguides & best practices. 14 | 15 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 16 | 17 | # Getting started 18 | 19 | ## 1. Start the app 20 | 21 | buffalo dev 22 | 23 | ## 2. Start the database 24 | 25 | docker run --name rw_db -e POSTGRES_DB=gobuff_realworld_example_app_development -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -p 5432:5432 -d postgres 26 | 27 | ## 3. Update the database 28 | 29 | buffalo pop migrate 30 | 31 | If you point your browser to [http://127.0.0.1:3000](http://127.0.0.1:3000) you should see the home page. 32 | 33 | ## Running the tests 34 | 35 | buffalo test --force-migrations 36 | buffalo test models -m "ArticleFavorite" --force-migrations 37 | 38 | # How it works 39 | 40 | [Buffalo](http://gobuffalo.io) web application with server side rendering, server side user session and PostgreSQL database. 41 | 42 | ## Authentication 43 | Authentication is generated by [Auth Generator for Buffalo](https://github.com/gobuffalo/buffalo-auth). 44 | 45 | ## Pagination of Articles 46 | Uses [pop](https://github.com/gobuffalo/pop)'s [paginator](https://github.com/gobuffalo/pop/blob/master/paginator.go) as described in [Pagination](https://github.com/gobuffalo/tags/wiki/Pagination). 47 | -------------------------------------------------------------------------------- /actions/actions_test.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gobuffalo/packr/v2" 7 | "github.com/gobuffalo/suite/v3" 8 | ) 9 | 10 | type ActionSuite struct { 11 | *suite.Action 12 | } 13 | 14 | func Test_ActionSuite(t *testing.T) { 15 | action, err := suite.NewActionWithFixtures(App(), packr.New("Test_ActionSuite", "../fixtures")) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | as := &ActionSuite{ 21 | Action: action, 22 | } 23 | suite.Run(t, as) 24 | } 25 | -------------------------------------------------------------------------------- /actions/app.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "github.com/gobuffalo/buffalo" 5 | "github.com/gobuffalo/envy" 6 | forcessl "github.com/gobuffalo/mw-forcessl" 7 | paramlogger "github.com/gobuffalo/mw-paramlogger" 8 | "github.com/unrolled/secure" 9 | 10 | "gobuff_realworld_example_app/models" 11 | 12 | "github.com/gobuffalo/buffalo-pop/v3/pop/popmw" 13 | csrf "github.com/gobuffalo/mw-csrf" 14 | i18n "github.com/gobuffalo/mw-i18n" 15 | "github.com/gobuffalo/packr/v2" 16 | ) 17 | 18 | // ENV is used to help switch settings based on where the 19 | // application is being run. Default is "development". 20 | var ENV = envy.Get("GO_ENV", "development") 21 | var app *buffalo.App 22 | var T *i18n.Translator 23 | 24 | // App is where all routes and middleware for buffalo 25 | // should be defined. This is the nerve center of your 26 | // application. 27 | // 28 | // Routing, middleware, groups, etc... are declared TOP -> DOWN. 29 | // This means if you add a middleware to `app` *after* declaring a 30 | // group, that group will NOT have that new middleware. The same 31 | // is true of resource declarations as well. 32 | // 33 | // It also means that routes are checked in the order they are declared. 34 | // `ServeFiles` is a CATCH-ALL route, so it should always be 35 | // placed last in the route declarations, as it will prevent routes 36 | // declared after it to never be called. 37 | func App() *buffalo.App { 38 | if app == nil { 39 | app = buffalo.New(buffalo.Options{ 40 | Env: ENV, 41 | SessionName: "_gobuff_realworld_app_session", 42 | }) 43 | 44 | // Automatically redirect to SSL 45 | app.Use(forceSSL()) 46 | 47 | // Log request parameters (filters apply). 48 | app.Use(paramlogger.ParameterLogger) 49 | 50 | // Protect against CSRF attacks. https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF) 51 | // Remove to disable this. 52 | app.Use(csrf.New) 53 | 54 | // Wraps each request in a transaction. 55 | // c.Value("tx").(*pop.Connection) 56 | // Remove to disable this. 57 | app.Use(popmw.Transaction(models.DB)) 58 | 59 | // Setup and use translations: 60 | app.Use(translations()) 61 | 62 | app.GET("/", HomeHandler) 63 | 64 | //AuthMiddlewares 65 | app.Use(SetCurrentUserMiddleware) 66 | app.Use(AuthorizeMiddleware) 67 | 68 | app.Middleware.Skip(AuthorizeMiddleware, HomeHandler) 69 | 70 | //Routes for Auth 71 | auth := app.Group("/auth") 72 | auth.GET("/login", AuthLoginHandler) 73 | auth.POST("/", AuthCreateHandler) 74 | auth.GET("/logout", AuthLogoutHandler) 75 | auth.Middleware.Skip(AuthorizeMiddleware, AuthLoginHandler, AuthCreateHandler) 76 | 77 | //Routes for User registration 78 | users := app.Group("/users") 79 | users.GET("/register", UsersRegisterHandler) 80 | users.GET("/profile/{user_email}", UsersProfileHandler).Name("userProfilePath") 81 | users.POST("/register", UsersCreateHandler) 82 | users.Middleware.Remove(AuthorizeMiddleware) 83 | 84 | // Routes for Following 85 | app.POST("/follow", UsersFollow) 86 | 87 | // Routes for Articles 88 | articles := app.Group("/articles") 89 | articles.POST("/new", ArticlesCreateHandler) 90 | articles.GET("/new", ArticlesNewHandler) 91 | articles.POST("/{slug}/comment", ArticlesCommentHandler).Name("articleCommentPath") 92 | articles.GET("/{slug}/delete", ArticlesDeleteHandler).Name("deleteArticlePath") 93 | articles.GET("/{slug}/edit", ArticlesEditHandler).Name("editArticlePath") 94 | articles.PUT("/{slug}/edit", ArticlesUpdateHandler).Name("editArticlePath") 95 | articles.POST("/star", ArticlesStarHandler) 96 | articles.GET("/{slug}", ArticlesReadHandler).Name("articlePath") 97 | articles.Middleware.Skip(AuthorizeMiddleware, ArticlesReadHandler) 98 | 99 | app.ServeFiles("/", assetsBox) // serve files from the public directory 100 | } 101 | 102 | return app 103 | } 104 | 105 | // translations will load locale files, set up the translator `actions.T`, 106 | // and will return a middleware to use to load the correct locale for each 107 | // request. 108 | // for more information: https://gobuffalo.io/en/docs/localization 109 | func translations() buffalo.MiddlewareFunc { 110 | var err error 111 | if T, err = i18n.New(packr.New("app:locales", "../locales"), "en-US"); err != nil { 112 | app.Stop(err) 113 | } 114 | return T.Middleware() 115 | } 116 | 117 | // forceSSL will return a middleware that will redirect an incoming request 118 | // if it is not HTTPS. "http://example.com" => "https://example.com". 119 | // This middleware does **not** enable SSL. for your application. To do that 120 | // we recommend using a proxy: https://gobuffalo.io/en/docs/proxy 121 | // for more information: https://github.com/unrolled/secure/ 122 | func forceSSL() buffalo.MiddlewareFunc { 123 | return forcessl.Middleware(secure.Options{ 124 | SSLRedirect: ENV == "production", 125 | SSLProxyHeaders: map[string]string{"X-Forwarded-Proto": "https"}, 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /actions/articles.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "fmt" 5 | "gobuff_realworld_example_app/models" 6 | "strings" 7 | 8 | "github.com/gobuffalo/buffalo" 9 | "github.com/gobuffalo/pop/v6" 10 | "github.com/gofrs/uuid" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | // ArticlesReadHandler renders the article 15 | func ArticlesReadHandler(c buffalo.Context) error { 16 | slug := c.Param("slug") 17 | 18 | a := []models.Article{} 19 | tx := c.Value("tx").(*pop.Connection) 20 | tx.Where("slug = ?", slug).Eager("ArticleFavorites").Eager("ArticleTags").Eager("ArticleTags.Tag").All(&a) 21 | 22 | // article not found so redirect to home 23 | if len(a) == 0 { 24 | c.Flash().Add("warning", "Article not found.") 25 | return c.Redirect(302, "/") 26 | } 27 | 28 | article := &a[0] 29 | 30 | c.Set("source_page", c.Request().URL) 31 | c.Set("article", article) 32 | c.Set("comment", &models.Comment{}) 33 | 34 | comments := []models.Comment{} 35 | tx.Where("article_id = ?", article.ID).Order("created_at desc").Limit(20).Eager().All(&comments) 36 | c.Set("comments", comments) 37 | 38 | author := &models.User{} 39 | tx.Eager("Followers").Find(author, article.UserID) 40 | c.Set("author", author) 41 | 42 | return c.Render(200, r.HTML("articles/read.html")) 43 | } 44 | 45 | // ArticlesCommentHandler renders the article 46 | func ArticlesCommentHandler(c buffalo.Context) error { 47 | u := c.Value("current_user").(*models.User) 48 | slug := c.Param("slug") 49 | 50 | comment := &models.Comment{} 51 | 52 | if err := c.Bind(comment); err != nil { 53 | return errors.WithStack(err) 54 | } 55 | 56 | if comment.Body == "" { 57 | return c.Redirect(302, fmt.Sprintf("/articles/%v", slug)) 58 | } 59 | 60 | a := []models.Article{} 61 | tx := c.Value("tx").(*pop.Connection) 62 | tx.Where("slug = ?", slug).All(&a) 63 | 64 | // article not found so redirect to home 65 | if len(a) == 0 { 66 | return c.Redirect(302, "/") 67 | } 68 | 69 | article := a[0] 70 | 71 | comment.UserID = u.ID 72 | comment.ArticleID = article.ID 73 | 74 | verrs, err := comment.Create(tx) 75 | if err != nil { 76 | return errors.WithStack(err) 77 | } 78 | 79 | if verrs.HasAny() { 80 | c.Set("errors", verrs) 81 | } 82 | 83 | return c.Redirect(302, fmt.Sprintf("/articles/%v", slug)) 84 | } 85 | 86 | // ArticlesDeleteHandler deletes an article 87 | func ArticlesDeleteHandler(c buffalo.Context) error { 88 | u := c.Value("current_user").(*models.User) 89 | slug := c.Param("slug") 90 | 91 | a := []models.Article{} 92 | 93 | tx := c.Value("tx").(*pop.Connection) 94 | tx.Where("slug = ? and user_id = ?", slug, u.ID).Eager().All(&a) 95 | 96 | if len(a) > 0 { 97 | err := a[0].Destroy(tx) 98 | if err != nil { 99 | return errors.WithStack(err) 100 | } 101 | 102 | c.Flash().Add("success", "Article deleted") 103 | } 104 | 105 | return c.Redirect(302, "/") 106 | } 107 | 108 | // ArticlesNewHandler renders the article form 109 | func ArticlesNewHandler(c buffalo.Context) error { 110 | a := models.Article{} 111 | c.Set("article", a) 112 | return c.Render(200, r.HTML("articles/new.html")) 113 | } 114 | 115 | // ArticlesStarHandler stars an article 116 | func ArticlesStarHandler(c buffalo.Context) error { 117 | userID := c.Value("current_user").(*models.User).ID 118 | articleID := uuid.FromStringOrNil(c.Request().Form.Get("ArticleID")) 119 | 120 | articleFavorite := &models.ArticleFavorite{} 121 | tx := c.Value("tx").(*pop.Connection) 122 | articleStarredAlready, err := tx.Where("user_id = ? and article_id = ?", userID, articleID).Exists(articleFavorite) 123 | if err != nil { 124 | return errors.WithStack(err) 125 | } 126 | 127 | if articleStarredAlready { 128 | articleFavorite = &models.ArticleFavorite{} 129 | tx.Where("user_id = ? and article_id = ?", userID, articleID).First(articleFavorite) 130 | err = tx.Destroy(articleFavorite) 131 | if err != nil { 132 | return errors.WithStack(err) 133 | } 134 | } else { 135 | articleFavorite = &models.ArticleFavorite{ 136 | UserID: userID, 137 | ArticleID: articleID, 138 | } 139 | 140 | _, err := articleFavorite.Create(tx) 141 | if err != nil { 142 | return errors.WithStack(err) 143 | } 144 | } 145 | 146 | sourcePage := c.Request().Form.Get("SourcePage") 147 | return c.Redirect(302, sourcePage) 148 | } 149 | 150 | // ArticlesEditHandler renders the edit article form 151 | func ArticlesEditHandler(c buffalo.Context) error { 152 | u := c.Value("current_user").(*models.User) 153 | slug := c.Param("slug") 154 | 155 | a := []models.Article{} 156 | 157 | tx := c.Value("tx").(*pop.Connection) 158 | tx.Where("slug = ? and user_id = ?", slug, u.ID).Eager("ArticleTags").Eager("ArticleTags.Tag").Eager().All(&a) 159 | 160 | if len(a) == 0 { 161 | return c.Redirect(302, "/") 162 | } 163 | 164 | article := a[0] 165 | 166 | tags := []string{} 167 | for _, articleTag := range article.ArticleTags { 168 | tags = append(tags, articleTag.Tag.Name) 169 | } 170 | article.Tags = strings.Join(tags, ", ") 171 | 172 | c.Set("article", article) 173 | 174 | return c.Render(200, r.HTML("articles/edit.html")) 175 | } 176 | 177 | // ArticlesCreateHandler creates a new article 178 | func ArticlesCreateHandler(c buffalo.Context) error { 179 | u := c.Value("current_user").(*models.User) 180 | 181 | a := &models.Article{} 182 | a.UserID = u.ID 183 | 184 | if err := c.Bind(a); err != nil { 185 | return errors.WithStack(err) 186 | } 187 | 188 | tx := c.Value("tx").(*pop.Connection) 189 | verrs, err := a.Create(tx) 190 | if err != nil { 191 | return errors.WithStack(err) 192 | } 193 | 194 | if verrs.HasAny() { 195 | c.Set("article", a) 196 | c.Set("errors", verrs) 197 | return c.Render(200, r.HTML("articles/new.html")) 198 | } 199 | 200 | c.Flash().Add("success", "Article created") 201 | 202 | return c.Redirect(302, fmt.Sprintf("/articles/%v", a.Slug)) 203 | } 204 | 205 | // ArticlesUpdateHandler updates an article 206 | func ArticlesUpdateHandler(c buffalo.Context) error { 207 | u := c.Value("current_user").(*models.User) 208 | slug := c.Param("slug") 209 | 210 | tx := c.Value("tx").(*pop.Connection) 211 | article := &models.Article{} 212 | article.UserID = u.ID 213 | 214 | if err := c.Bind(article); err != nil { 215 | return errors.WithStack(err) 216 | } 217 | 218 | verrs, err := article.Update(tx) 219 | if err != nil { 220 | return errors.WithStack(err) 221 | } 222 | 223 | if verrs.HasAny() { 224 | c.Set("article", article) 225 | c.Set("errors", verrs) 226 | return c.Redirect(302, fmt.Sprintf("/articles/%v/edit", slug)) 227 | } 228 | 229 | c.Flash().Add("success", "Article updated") 230 | 231 | return c.Redirect(302, fmt.Sprintf("/articles/%v", article.Slug)) 232 | } 233 | -------------------------------------------------------------------------------- /actions/auth.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "database/sql" 5 | "net/http" 6 | "strings" 7 | 8 | "gobuff_realworld_example_app/models" 9 | 10 | "github.com/gobuffalo/buffalo" 11 | "github.com/gobuffalo/pop/v6" 12 | "github.com/gobuffalo/validate/v3" 13 | "github.com/pkg/errors" 14 | "golang.org/x/crypto/bcrypt" 15 | ) 16 | 17 | // AuthLoginHandler loads the signin page 18 | func AuthLoginHandler(c buffalo.Context) error { 19 | c.Set("user", models.User{}) 20 | return c.Render(200, r.HTML("auth/login.html")) 21 | } 22 | 23 | // AuthCreateHandler attempts to log the user in with an existing account. 24 | func AuthCreateHandler(c buffalo.Context) error { 25 | u := &models.User{} 26 | if err := c.Bind(u); err != nil { 27 | return errors.WithStack(err) 28 | } 29 | 30 | tx := c.Value("tx").(*pop.Connection) 31 | 32 | // find a user by email 33 | err := tx.Where("email = ?", strings.ToLower(strings.TrimSpace(u.Email))).First(u) 34 | 35 | // helper function to handle bad attempts 36 | bad := func() error { 37 | verrs := validate.NewErrors() 38 | verrs.Add("email", "invalid email/password") 39 | 40 | c.Set("errors", verrs) 41 | c.Set("user", u) 42 | 43 | return c.Render(http.StatusUnauthorized, r.HTML("auth/login.html")) 44 | } 45 | 46 | if err != nil { 47 | if errors.Cause(err) == sql.ErrNoRows { 48 | // couldn't find an user with the supplied email address. 49 | return bad() 50 | } 51 | return errors.WithStack(err) 52 | } 53 | 54 | // confirm that the given password matches the hashed password from the db 55 | err = bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(u.Password)) 56 | if err != nil { 57 | return bad() 58 | } 59 | c.Session().Set("current_user_id", u.ID) 60 | c.Flash().Add("success", "Welcome Back to Buffalo!") 61 | 62 | redirectURL := "/" 63 | if redir, ok := c.Session().Get("redirectURL").(string); ok && redir != "" { 64 | redirectURL = redir 65 | } 66 | 67 | return c.Redirect(302, redirectURL) 68 | } 69 | 70 | // AuthLogoutHandler clears the session and logs a user out 71 | func AuthLogoutHandler(c buffalo.Context) error { 72 | c.Session().Clear() 73 | c.Flash().Add("success", "You have been logged out!") 74 | return c.Redirect(302, "/") 75 | } 76 | -------------------------------------------------------------------------------- /actions/auth_test.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "gobuff_realworld_example_app/models" 5 | "net/http" 6 | ) 7 | 8 | func (as *ActionSuite) readUser() *models.User { 9 | as.LoadFixture("basics") 10 | 11 | u := &models.User{} 12 | as.DB.Where("email = ?", "sarah@sample.de").First(u) 13 | 14 | return u 15 | } 16 | 17 | func (as *ActionSuite) Test_Auth_Signin() { 18 | res := as.HTML("/auth/login").Get() 19 | as.Equal(200, res.Code) 20 | as.Contains(res.Body.String(), ``) 21 | } 22 | 23 | func (as *ActionSuite) Test_Auth_Register() { 24 | res := as.HTML("/users/register").Get() 25 | as.Equal(200, res.Code) 26 | as.Contains(res.Body.String(), "Sign In") 27 | } 28 | 29 | func (as *ActionSuite) Test_Auth_SignUp() { 30 | tcases := []struct { 31 | Name string 32 | Email string 33 | Password string 34 | Status int 35 | 36 | Identifier string 37 | }{ 38 | {"Mia Mice", "mia@mice.com", "cat", http.StatusFound, "Valid"}, 39 | {"My Maik", "my.maik@test.com", "", http.StatusOK, "Password Invalid"}, 40 | } 41 | 42 | for _, tcase := range tcases { 43 | as.Run(tcase.Identifier, func() { 44 | res := as.HTML("/users/register").Post(&models.User{ 45 | Name: tcase.Name, 46 | Email: tcase.Email, 47 | Password: tcase.Password, 48 | }) 49 | 50 | as.Equal(tcase.Status, res.Code) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /actions/home.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "gobuff_realworld_example_app/models" 5 | 6 | "github.com/gobuffalo/buffalo" 7 | "github.com/gobuffalo/pop/v6" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | // HomeHandler is a default handler to serve the home page. 12 | func HomeHandler(c buffalo.Context) error { 13 | a := []models.Article{} 14 | tx := c.Value("tx").(*pop.Connection) 15 | 16 | q := tx.PaginateFromParams(c.Params()) 17 | 18 | // Filter tags 19 | tagParam := c.Params().Get("tag") 20 | if tagParam != "" { 21 | tag := &models.Tag{} 22 | 23 | exists, err := tx.Where("name = ?", tagParam).Exists(tag) 24 | if err == nil && exists { 25 | tx.Where("name = ?", tagParam).First(tag) 26 | q = q.LeftJoin("article_tags", "article_tags.article_id=articles.id").Where("article_tags.tag_id = ?", tag.ID) 27 | } 28 | } 29 | 30 | err := q.Order("created_at desc").Eager("User").Eager("ArticleFavorites").Eager("ArticleTags").Eager("ArticleTags.Tag").All(&a) 31 | if err != nil { 32 | return errors.WithStack(err) 33 | } 34 | c.Set("paginator", q.Paginator) 35 | c.Set("articles", a) 36 | 37 | tags, err := models.LoadPopularArticleTags(tx, 20) 38 | if err != nil { 39 | return errors.WithStack(err) 40 | } 41 | c.Set("tags", tags) 42 | 43 | c.Set("source_page", c.Request().URL) 44 | 45 | return c.Render(200, r.HTML("index.html")) 46 | } 47 | -------------------------------------------------------------------------------- /actions/home_test.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import "gobuff_realworld_example_app/models" 4 | 5 | func (as *ActionSuite) Test_HomeHandler_LoggedIn() { 6 | // Arrange 7 | as.LoadFixture("basics") 8 | 9 | u := &models.User{} 10 | as.DB.Where("email = ?", "sarah@sample.de").First(u) 11 | 12 | as.Session.Set("current_user_id", u.ID) 13 | 14 | // Act 15 | res := as.HTML("/articles/new").Get() 16 | 17 | // Assert 18 | as.Equal(200, res.Code) 19 | as.Contains(res.Body.String(), "Sign Out") 20 | 21 | // Arrange 22 | as.Session.Clear() 23 | 24 | // Act 25 | res = as.HTML("/articles/new").Get() 26 | 27 | // Assert 28 | as.Equal(302, res.Code) 29 | } 30 | -------------------------------------------------------------------------------- /actions/render.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "time" 5 | 6 | "gobuff_realworld_example_app/public" 7 | "gobuff_realworld_example_app/templates" 8 | 9 | "github.com/gobuffalo/buffalo/render" 10 | "github.com/gobuffalo/packr/v2" 11 | ) 12 | 13 | var r *render.Engine 14 | var assetsBox = packr.New("app:assets", "../public") 15 | 16 | func init() { 17 | r = render.New(render.Options{ 18 | // HTML layout to be used for all HTML requests: 19 | HTMLLayout: "application.html", 20 | 21 | // Box containing all of the templates: 22 | TemplatesFS: templates.FS(), 23 | AssetsFS: public.FS(), 24 | 25 | // Add template helpers here: 26 | Helpers: render.Helpers{ 27 | "as_date": func(date time.Time) string { 28 | return date.Format("January 02, 2006") 29 | }, 30 | // uncomment for non-Bootstrap form helpers: 31 | // "form": plush.FormHelper, 32 | // "form_for": plush.FormForHelper, 33 | }, 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /actions/users.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "gobuff_realworld_example_app/models" 5 | 6 | "github.com/gobuffalo/buffalo" 7 | "github.com/gobuffalo/pop/v6" 8 | "github.com/gofrs/uuid" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | //UsersRegisterHandler renders the users form 13 | func UsersRegisterHandler(c buffalo.Context) error { 14 | c.Set("user", models.User{}) 15 | return c.Render(200, r.HTML("users/register.html")) 16 | } 17 | 18 | //UsersProfileHandler renders the user profile 19 | func UsersProfileHandler(c buffalo.Context) error { 20 | email := c.Param("user_email") 21 | 22 | u := []models.User{} 23 | tx := c.Value("tx").(*pop.Connection) 24 | tx.Where("email = ?", email).Eager("Followers").All(&u) 25 | 26 | // user not found so redirect to home 27 | if len(u) == 0 { 28 | return c.Redirect(302, "/") 29 | } 30 | 31 | user := u[0] 32 | c.Set("source_page", c.Request().URL) 33 | c.Set("profile_user", user) 34 | 35 | a := []models.Article{} 36 | 37 | q := tx.PaginateFromParams(c.Params()) 38 | q.Where("user_id = ?", user.ID).Order("created_at desc").Limit(10).Eager("User").Eager("ArticleFavorites").All(&a) 39 | 40 | c.Set("articles", a) 41 | 42 | return c.Render(200, r.HTML("users/profile.html")) 43 | } 44 | 45 | // UsersCreateHandler registers a new user with the application. 46 | func UsersCreateHandler(c buffalo.Context) error { 47 | u := &models.User{} 48 | if err := c.Bind(u); err != nil { 49 | return errors.WithStack(err) 50 | } 51 | 52 | tx := c.Value("tx").(*pop.Connection) 53 | verrs, err := u.Create(tx) 54 | if err != nil { 55 | return errors.WithStack(err) 56 | } 57 | 58 | if verrs.HasAny() { 59 | c.Set("user", u) 60 | c.Set("errors", verrs) 61 | return c.Render(200, r.HTML("users/register.html")) 62 | } 63 | 64 | c.Session().Set("current_user_id", u.ID) 65 | c.Flash().Add("success", "Welcome to RealWorld!") 66 | 67 | return c.Redirect(302, "/") 68 | } 69 | 70 | // UsersFollow creates a follow relation 71 | func UsersFollow(c buffalo.Context) error { 72 | userID := c.Value("current_user").(*models.User).ID 73 | followID := uuid.FromStringOrNil(c.Request().Form.Get("FollowID")) 74 | 75 | follow := &models.Follow{} 76 | tx := c.Value("tx").(*pop.Connection) 77 | found, err := tx.Where("user_id = ? and follow_id = ?", userID, followID).Exists(follow) 78 | if err != nil { 79 | return errors.WithStack(err) 80 | } 81 | 82 | if found { 83 | follow = &models.Follow{} 84 | tx.Where("user_id = ? and follow_id = ?", userID, followID).First(follow) 85 | err = tx.Destroy(follow) 86 | if err != nil { 87 | return errors.WithStack(err) 88 | } 89 | } else { 90 | follow = &models.Follow{ 91 | UserID: userID, 92 | FollowID: followID, 93 | } 94 | 95 | _, err := follow.Create(tx) 96 | if err != nil { 97 | return errors.WithStack(err) 98 | } 99 | } 100 | 101 | sourcePage := c.Request().Form.Get("SourcePage") 102 | return c.Redirect(302, sourcePage) 103 | } 104 | 105 | // SetCurrentUserMiddleware attempts to find a user based on the current_user_id 106 | // in the session. If one is found it is set on the context. 107 | func SetCurrentUserMiddleware(next buffalo.Handler) buffalo.Handler { 108 | return func(c buffalo.Context) error { 109 | if uid := c.Session().Get("current_user_id"); uid != nil { 110 | u := &models.User{} 111 | tx := c.Value("tx").(*pop.Connection) 112 | err := tx.Find(u, uid) 113 | if err != nil { 114 | // user not found and might have been deleted 115 | c.Session().Clear() 116 | return next(c) 117 | } 118 | c.Set("current_user", u) 119 | } 120 | return next(c) 121 | } 122 | } 123 | 124 | // AuthorizeMiddleware require a user be logged in before accessing a route 125 | func AuthorizeMiddleware(next buffalo.Handler) buffalo.Handler { 126 | return func(c buffalo.Context) error { 127 | if uid := c.Session().Get("current_user_id"); uid == nil { 128 | c.Session().Set("redirectURL", c.Request().URL.String()) 129 | 130 | err := c.Session().Save() 131 | if err != nil { 132 | return errors.WithStack(err) 133 | } 134 | 135 | c.Flash().Add("danger", "You must be authorized to see that page") 136 | return c.Redirect(302, "/auth/login") 137 | } 138 | return next(c) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /actions/users_test.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "gobuff_realworld_example_app/models" 5 | ) 6 | 7 | func (as *ActionSuite) Test_Users_Register() { 8 | res := as.HTML("/users/register").Get() 9 | as.Equal(200, res.Code) 10 | } 11 | 12 | func (as *ActionSuite) Test_Users_Create() { 13 | // Arrange 14 | count, err := as.DB.Count("users") 15 | as.NoError(err) 16 | as.Equal(0, count) 17 | 18 | u := &models.User{ 19 | Name: "Mark Example", 20 | Email: "mark@example.com", 21 | Password: "password", 22 | } 23 | 24 | // Act 25 | res := as.HTML("/users/register").Post(u) 26 | 27 | // Assert 28 | as.Equal(302, res.Code) 29 | 30 | count, err = as.DB.Count("users") 31 | as.NoError(err) 32 | as.Equal(1, count) 33 | } 34 | -------------------------------------------------------------------------------- /config/buffalo-app.toml: -------------------------------------------------------------------------------- 1 | name = "gobuff-realworld-example-app" 2 | bin = "bin\\gobuff-realworld-example-app" 3 | vcs = "git" 4 | with_pop = true 5 | with_sqlite = false 6 | with_dep = false 7 | with_webpack = false 8 | with_nodejs = false 9 | with_yarn = false 10 | with_docker = true 11 | with_grifts = true 12 | as_web = true 13 | as_api = false 14 | -------------------------------------------------------------------------------- /config/buffalo-plugins.toml: -------------------------------------------------------------------------------- 1 | [[plugin]] 2 | binary = "buffalo-auth" 3 | go_get = "github.com/gobuffalo/buffalo-auth" 4 | 5 | [[plugin]] 6 | binary = "buffalo-plugins" 7 | go_get = "github.com/gobuffalo/buffalo-plugins" 8 | 9 | [[plugin]] 10 | binary = "buffalo-pop" 11 | go_get = "github.com/gobuffalo/buffalo-pop/v2" 12 | 13 | [[plugin]] 14 | binary = "buffalo-pop" 15 | go_get = "github.com/gobuffalo/buffalo-pop/v3@latest" 16 | -------------------------------------------------------------------------------- /database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | dialect: postgres 3 | database: gobuff_realworld_example_app_development 4 | user: postgres 5 | password: postgres 6 | host: {{envOr "DATABASE_HOST" "127.0.0.1"}} 7 | pool: 5 8 | 9 | test: 10 | url: {{envOr "TEST_DATABASE_URL" "postgres://postgres:postgres@127.0.0.1:5432/gobuff_realworld_example_app_test?sslmode=disable"}} 11 | 12 | production: 13 | url: {{envOr "DATABASE_URL" "postgres://postgres:postgres@127.0.0.1:5432/gobuff_realworld_example_app_production?sslmode=disable"}} 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | 4 | app 5 | image: ghcr.io/remast/gobuff_realworld_example_app/app:latest 6 | container_name: gobuf___app 7 | hostname: app 8 | ports: 9 | - "3000:3000" 10 | depends_on: 11 | - db 12 | environment: 13 | - "WAIT_HOSTS=db:5432" 14 | - "DATABASE_HOST=db" 15 | 16 | db: 17 | image: postgres:latest 18 | container_name: gobuf___db 19 | hostname: db 20 | environment: 21 | - "POSTGRES_DB=gobuff_realworld_example_app_development" 22 | - "POSTGRES_PASSWORD=postgres" 23 | - "POSTGRES_USER=postgres" 24 | ports: 25 | - "5432:5432" 26 | -------------------------------------------------------------------------------- /fixtures/basics.toml: -------------------------------------------------------------------------------- 1 | [[scenario]] 2 | name = "basics" 3 | 4 | [[scenario.table]] 5 | name = "users" 6 | 7 | [[scenario.table.row]] 8 | id = "<%= uuidNamed("user1") %>" 9 | name = "Sarah Sample" 10 | email = "sarah@sample.de" 11 | password_hash = "$2a$10$DxUw36aEz9nLI.iWFqOqEu50uJJDrqp1V/K866cWm2KxEGE/Plrpq" 12 | created_at = "<%= now() %>" 13 | updated_at = "<%= now() %>" 14 | 15 | [[scenario.table.row]] 16 | id = "<%= uuidNamed("user2") %>" 17 | name = "Max Marah" 18 | email = "max@sample.de" 19 | password_hash = "$2a$10$DxUw36aEz9nLI.iWFqOqEu50uJJDrqp1V/K866cWm2KxEGE/Plrpq" 20 | created_at = "<%= now() %>" 21 | updated_at = "<%= now() %>" 22 | 23 | [[scenario.table]] 24 | name = "articles" 25 | 26 | [[scenario.table.row]] 27 | id = "<%= uuidNamed("article") %>" 28 | user_id = "<%= uuidNamed("user1") %>" 29 | title = "Sarah Super Title" 30 | slug = "sarah-super-slug" 31 | description = "Some super Description" 32 | body = "Some Sara story Body" 33 | created_at = "<%= now() %>" 34 | updated_at = "<%= now() %>" 35 | 36 | [[scenario.table]] 37 | name = "tags" 38 | 39 | [[scenario.table.row]] 40 | id = "<%= uuidNamed("tag") %>" 41 | name = "beginner" 42 | 43 | [[scenario.table]] 44 | name = "article_tags" 45 | 46 | [[scenario.table.row]] 47 | id = "<%= uuidNamed() %>" 48 | article_id = "<%= uuidNamed("article") %>" 49 | tag_id = "<%= uuidNamed("tag") %>" 50 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gobuff_realworld_example_app 2 | 3 | // +heroku goVersion go1.16 4 | go 1.16 5 | 6 | require ( 7 | github.com/felixge/httpsnoop v1.0.2 // indirect 8 | github.com/gobuffalo/buffalo v0.18.1 9 | github.com/gobuffalo/buffalo-pop/v3 v3.0.2 10 | github.com/gobuffalo/envy v1.10.1 11 | github.com/gobuffalo/mw-csrf v1.0.0 12 | github.com/gobuffalo/mw-forcessl v0.0.0-20200131175327-94b2bd771862 13 | github.com/gobuffalo/mw-i18n v1.1.0 14 | github.com/gobuffalo/mw-paramlogger v1.0.0 15 | github.com/gobuffalo/packr/v2 v2.8.0 16 | github.com/gobuffalo/pop/v6 v6.0.1 17 | github.com/gobuffalo/suite/v3 v3.0.0 18 | github.com/gobuffalo/validate/v3 v3.3.1 19 | github.com/gofrs/uuid v4.2.0+incompatible 20 | github.com/gosimple/slug v1.10.0 21 | github.com/markbates/grift v1.5.0 22 | github.com/pkg/errors v0.9.1 23 | github.com/unrolled/secure v1.0.9 24 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 25 | ) 26 | -------------------------------------------------------------------------------- /grifts/db.go: -------------------------------------------------------------------------------- 1 | package grifts 2 | 3 | import ( 4 | "github.com/markbates/grift/grift" 5 | ) 6 | 7 | var _ = grift.Namespace("db", func() { 8 | 9 | grift.Desc("seed", "Seeds a database") 10 | grift.Add("seed", func(c *grift.Context) error { 11 | // Add DB seeding stuff here 12 | return nil 13 | }) 14 | 15 | }) 16 | -------------------------------------------------------------------------------- /grifts/init.go: -------------------------------------------------------------------------------- 1 | package grifts 2 | 3 | import ( 4 | "gobuff_realworld_example_app/actions" 5 | 6 | "github.com/gobuffalo/buffalo" 7 | ) 8 | 9 | func init() { 10 | buffalo.Grifts(actions.App()) 11 | } 12 | -------------------------------------------------------------------------------- /heroku.yml: -------------------------------------------------------------------------------- 1 | build: 2 | docker: 3 | web: Dockerfile 4 | -------------------------------------------------------------------------------- /inflections.json: -------------------------------------------------------------------------------- 1 | { 2 | "singular": "plural" 3 | } 4 | -------------------------------------------------------------------------------- /locales/all.en-us.yaml: -------------------------------------------------------------------------------- 1 | # For more information on using i18n see: https://github.com/nicksnyder/go-i18n 2 | - id: welcome_greeting 3 | translation: "Welcome to Buffalo (EN)" 4 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remast/gobuff_realworld_example_app/3e1cf70c31d4927cd78a9cc42810f24ed2ca9aae/logo.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "gobuff_realworld_example_app/actions" 7 | ) 8 | 9 | // main is the starting point for your Buffalo application. 10 | // You can feel free and add to this `main` method, change 11 | // what it does, etc... 12 | // All we ask is that, at some point, you make sure to 13 | // call `app.Serve()`, unless you don't want to start your 14 | // application that is. :) 15 | func main() { 16 | app := actions.App() 17 | if err := app.Serve(); err != nil { 18 | log.Fatal(err) 19 | } 20 | } 21 | 22 | /* 23 | # Notes about `main.go` 24 | 25 | ## SSL Support 26 | 27 | We recommend placing your application behind a proxy, such as 28 | Apache or Nginx and letting them do the SSL heavy lifting 29 | for you. https://gobuffalo.io/en/docs/proxy 30 | 31 | ## Buffalo Build 32 | 33 | When `buffalo build` is run to compile your binary, this `main` 34 | function will be at the heart of that binary. It is expected 35 | that your `main` function will start your application using 36 | the `app.Serve()` method. 37 | 38 | */ 39 | -------------------------------------------------------------------------------- /migrations/20201014180138_create_users.down.fizz: -------------------------------------------------------------------------------- 1 | drop_table("users") -------------------------------------------------------------------------------- /migrations/20201014180138_create_users.up.fizz: -------------------------------------------------------------------------------- 1 | create_table("users"){ 2 | t.Column("id", "uuid", {"primary": true}) 3 | t.Column("name", "string", {}) 4 | t.Column("email", "string", {}) 5 | t.Column("password_hash", "string", {}) 6 | } 7 | 8 | add_index("users", "email", {"unique": true}) -------------------------------------------------------------------------------- /migrations/20201017150233_create_articles.down.fizz: -------------------------------------------------------------------------------- 1 | drop_table("articles") -------------------------------------------------------------------------------- /migrations/20201017150233_create_articles.up.fizz: -------------------------------------------------------------------------------- 1 | create_table("articles") { 2 | t.Column("id", "uuid", {primary: true}) 3 | t.Column("title", "string", {"null": false}) 4 | t.Column("slug", "string", {"null": false}) 5 | t.Column("description", "string", {"null": false}) 6 | t.Column("body", "text", {"null": false}) 7 | t.Column("user_id", "uuid", {"null": false}) 8 | t.Timestamps() 9 | } 10 | 11 | add_foreign_key("articles", "user_id", {"users": ["id"]}) 12 | add_index("articles", "slug", {"unique": true}) -------------------------------------------------------------------------------- /migrations/20201017195238_create_comments.down.fizz: -------------------------------------------------------------------------------- 1 | drop_table("comments") -------------------------------------------------------------------------------- /migrations/20201017195238_create_comments.up.fizz: -------------------------------------------------------------------------------- 1 | create_table("comments") { 2 | t.Column("id", "uuid", {primary: true}) 3 | t.Column("body", "text", {"null": false}) 4 | t.Column("article_id", "uuid", {"null": false}) 5 | t.Column("user_id", "uuid", {"null": false}) 6 | t.Timestamps() 7 | } 8 | 9 | add_index("comments", "article_id", {"unique": false}) 10 | add_index("comments", "user_id", {"unique": false}) -------------------------------------------------------------------------------- /migrations/20201018160448_create_article_favorites.down.fizz: -------------------------------------------------------------------------------- 1 | drop_table("article_favorites") -------------------------------------------------------------------------------- /migrations/20201018160448_create_article_favorites.up.fizz: -------------------------------------------------------------------------------- 1 | create_table("article_favorites") { 2 | t.Column("id", "uuid", {primary: true}) 3 | t.Column("article_id", "uuid", {"null": false}) 4 | t.Column("user_id", "uuid", {"null": false}) 5 | t.DisableTimestamps() 6 | } 7 | 8 | add_index("article_favorites", "article_id") 9 | add_index("article_favorites", [ "article_id", "user_id" ], {"unique": true}) -------------------------------------------------------------------------------- /migrations/20201021191845_create_follows.down.fizz: -------------------------------------------------------------------------------- 1 | drop_table("follows") -------------------------------------------------------------------------------- /migrations/20201021191845_create_follows.up.fizz: -------------------------------------------------------------------------------- 1 | create_table("follows") { 2 | t.Column("id", "uuid", {primary: true}) 3 | t.Column("user_id", "uuid", {"null": false}) 4 | t.Column("follow_id", "uuid", {"null": false}) 5 | t.DisableTimestamps() 6 | } 7 | 8 | add_index("follows", "user_id") 9 | add_index("follows", [ "user_id", "follow_id" ], {"unique": true}) -------------------------------------------------------------------------------- /migrations/20201024085556_create_tags.down.fizz: -------------------------------------------------------------------------------- 1 | drop_table("tags") -------------------------------------------------------------------------------- /migrations/20201024085556_create_tags.up.fizz: -------------------------------------------------------------------------------- 1 | create_table("tags") { 2 | t.Column("id", "uuid", {primary: true}) 3 | t.Column("name", "string", {"null": false}) 4 | t.DisableTimestamps() 5 | } 6 | 7 | add_index("tags", "name", {"unique": true}) -------------------------------------------------------------------------------- /migrations/20201024085916_create_article_tags.down.fizz: -------------------------------------------------------------------------------- 1 | drop_table("article_tags") -------------------------------------------------------------------------------- /migrations/20201024085916_create_article_tags.up.fizz: -------------------------------------------------------------------------------- 1 | create_table("article_tags") { 2 | t.Column("id", "uuid", {primary: true}) 3 | t.Column("article_id", "uuid", {"null": false}) 4 | t.Column("tag_id", "uuid", {"null": false}) 5 | t.DisableTimestamps() 6 | } 7 | 8 | add_foreign_key("article_tags", "article_id", {"articles": ["id"]}) 9 | add_foreign_key("article_tags", "tag_id", {"tags": ["id"]}) 10 | add_index("article_tags", "article_id", {"unique": false}) -------------------------------------------------------------------------------- /migrations/schema.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remast/gobuff_realworld_example_app/3e1cf70c31d4927cd78a9cc42810f24ed2ca9aae/migrations/schema.sql -------------------------------------------------------------------------------- /models/article.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "time" 7 | 8 | "github.com/gobuffalo/pop/v6" 9 | "github.com/gobuffalo/validate/v3" 10 | "github.com/gobuffalo/validate/v3/validators" 11 | "github.com/gofrs/uuid" 12 | "github.com/gosimple/slug" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | // Article is used by pop to map your .model.Name.Proper.Pluralize.Underscore database table to your go code. 17 | type Article struct { 18 | ID uuid.UUID `json:"id" db:"id"` 19 | Title string `json:"title" db:"title"` 20 | Slug string `json:"slug" db:"slug"` 21 | Description string `json:"description" db:"description"` 22 | Body string `json:"body" db:"body"` 23 | CreatedAt time.Time `json:"created_at" db:"created_at"` 24 | UpdatedAt time.Time `json:"updated_at" db:"updated_at"` 25 | User User `belongs_to:"user"` 26 | UserID uuid.UUID `db:"user_id"` 27 | ArticleFavorites []ArticleFavorite `has_many:"favorites" fk_id:"article_id"` 28 | ArticleTags []ArticleTag `has_many:"tags" fk_id:"article_id"` 29 | Tags string `json:"-" db:"-"` 30 | } 31 | 32 | // String is not required by pop and may be deleted 33 | func (a Article) String() string { 34 | ja, _ := json.Marshal(a) 35 | return string(ja) 36 | } 37 | 38 | // Articles is not required by pop and may be deleted 39 | type Articles []Article 40 | 41 | // String is not required by pop and may be deleted 42 | func (a Articles) String() string { 43 | ja, _ := json.Marshal(a) 44 | return string(ja) 45 | } 46 | 47 | // ParseTags parses the string of tags 48 | func (a *Article) ParseTags() []string { 49 | tagsString := strings.ToLower(strings.Replace(a.Tags, "#", "", 0)) 50 | 51 | // Try with separator ',' 52 | tagsToNormalize := strings.Split(tagsString, ",") 53 | if len(tagsToNormalize) == 1 { 54 | // Try with separator ' ' 55 | tagsToNormalize = strings.Split(tagsString, " ") 56 | } 57 | 58 | tagsUnique := map[string]bool{} 59 | tagsNormalized := []string{} 60 | for _, tagToNormalize := range tagsToNormalize { 61 | _, ok := tagsUnique[tagToNormalize] // check for existence 62 | if ok { 63 | continue 64 | } 65 | 66 | tagsUnique[tagToNormalize] = true // add tag 67 | tagsNormalized = append(tagsNormalized, strings.TrimSpace(tagToNormalize)) 68 | } 69 | return tagsNormalized 70 | } 71 | 72 | // Create an article with slug 73 | func (a *Article) Create(tx *pop.Connection) (*validate.Errors, error) { 74 | a.Slug = slug.Make(a.Title) 75 | verrs, err := tx.ValidateAndCreate(a) 76 | if err != nil { 77 | return verrs, errors.WithStack(err) 78 | } 79 | if verrs.HasAny() { 80 | return verrs, err 81 | } 82 | 83 | err = a.updateTags(tx) 84 | return verrs, err 85 | } 86 | 87 | func (a *Article) updateTags(tx *pop.Connection) error { 88 | tags, err := LoadOrCreateTags(tx, a.ParseTags()) 89 | if err != nil { 90 | return errors.WithStack(err) 91 | } 92 | 93 | articleTags := []ArticleTag{} 94 | for _, tag := range tags { 95 | articleTag := &ArticleTag{ 96 | ArticleID: a.ID, 97 | TagID: tag.ID, 98 | } 99 | articleTags = append(articleTags, *articleTag) 100 | } 101 | 102 | // 1. Delete all tags of this article 103 | q := tx.RawQuery("delete from article_tags where article_id = ?", a.ID) 104 | err = q.Exec() 105 | if err != nil { 106 | return errors.WithStack(err) 107 | } 108 | 109 | // 2. Insert all tags of this article 110 | for _, articleTag := range articleTags { 111 | _, err = articleTag.Create(tx) 112 | if err != nil { 113 | return errors.WithStack(err) 114 | } 115 | } 116 | 117 | return nil 118 | } 119 | 120 | // Destroy an article 121 | func (a *Article) Destroy(tx *pop.Connection) error { 122 | // Delete all tags of this article 123 | q := tx.RawQuery("delete from article_tags where article_id = ?", a.ID) 124 | err := q.Exec() 125 | if err != nil { 126 | return errors.WithStack(err) 127 | } 128 | 129 | // Delete all favorites of this article 130 | q = tx.RawQuery("delete from article_favorites where article_id = ?", a.ID) 131 | err = q.Exec() 132 | if err != nil { 133 | return errors.WithStack(err) 134 | } 135 | 136 | return tx.Destroy(a) 137 | } 138 | 139 | // Update an article with slug 140 | func (a *Article) Update(tx *pop.Connection) (*validate.Errors, error) { 141 | a.Slug = slug.Make(a.Title) 142 | err := a.updateTags(tx) 143 | if err != nil { 144 | return nil, errors.WithStack(err) 145 | } 146 | return tx.ValidateAndUpdate(a) 147 | } 148 | 149 | // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. 150 | // This method is not required and may be deleted. 151 | func (a *Article) Validate(tx *pop.Connection) (*validate.Errors, error) { 152 | return validate.Validate( 153 | &validators.StringIsPresent{Field: a.Title, Name: "Title"}, 154 | &validators.StringIsPresent{Field: a.Description, Name: "Description"}, 155 | &validators.StringIsPresent{Field: a.Body, Name: "Body"}, 156 | ), nil 157 | } 158 | 159 | // ValidateCreate gets run every time you call "pop.ValidateAndCreate" method. 160 | // This method is not required and may be deleted. 161 | func (a *Article) ValidateCreate(tx *pop.Connection) (*validate.Errors, error) { 162 | return validate.NewErrors(), nil 163 | } 164 | 165 | // ValidateUpdate gets run every time you call "pop.ValidateAndUpdate" method. 166 | // This method is not required and may be deleted. 167 | func (a *Article) ValidateUpdate(tx *pop.Connection) (*validate.Errors, error) { 168 | return validate.NewErrors(), nil 169 | } 170 | -------------------------------------------------------------------------------- /models/article_favorite.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/gobuffalo/pop/v6" 7 | "github.com/gobuffalo/validate/v3" 8 | "github.com/gofrs/uuid" 9 | ) 10 | 11 | // ArticleFavorite is used by pop to map your article_favorites database table to your go code. 12 | type ArticleFavorite struct { 13 | ID uuid.UUID `json:"id" db:"id"` 14 | User User `belongs_to:"user"` 15 | UserID uuid.UUID `db:"user_id"` 16 | Article Article `belongs_to:"article"` 17 | ArticleID uuid.UUID `db:"article_id"` 18 | } 19 | 20 | // String is not required by pop and may be deleted 21 | func (a ArticleFavorite) String() string { 22 | ja, _ := json.Marshal(a) 23 | return string(ja) 24 | } 25 | 26 | // ArticleFavorites is not required by pop and may be deleted 27 | type ArticleFavorites []ArticleFavorite 28 | 29 | // String is not required by pop and may be deleted 30 | func (a ArticleFavorites) String() string { 31 | ja, _ := json.Marshal(a) 32 | return string(ja) 33 | } 34 | 35 | // Create an article with slug 36 | func (a *ArticleFavorite) Create(tx *pop.Connection) (*validate.Errors, error) { 37 | return tx.ValidateAndCreate(a) 38 | } 39 | 40 | // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. 41 | // This method is not required and may be deleted. 42 | func (a *ArticleFavorite) Validate(tx *pop.Connection) (*validate.Errors, error) { 43 | return validate.NewErrors(), nil 44 | } 45 | 46 | // ValidateCreate gets run every time you call "pop.ValidateAndCreate" method. 47 | // This method is not required and may be deleted. 48 | func (a *ArticleFavorite) ValidateCreate(tx *pop.Connection) (*validate.Errors, error) { 49 | return validate.NewErrors(), nil 50 | } 51 | 52 | // ValidateUpdate gets run every time you call "pop.ValidateAndUpdate" method. 53 | // This method is not required and may be deleted. 54 | func (a *ArticleFavorite) ValidateUpdate(tx *pop.Connection) (*validate.Errors, error) { 55 | return validate.NewErrors(), nil 56 | } 57 | -------------------------------------------------------------------------------- /models/article_favorite_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | func (ms *ModelSuite) Test_ArticleFavorite() { 4 | // Arrange 5 | ms.LoadFixture("basics") 6 | 7 | article := &Article{} 8 | ms.DB.First(article) 9 | 10 | articleFavorite := &ArticleFavorite{ 11 | UserID: article.UserID, 12 | ArticleID: article.ID, 13 | } 14 | 15 | // Act 16 | verrs, err := articleFavorite.Create(ms.DB) 17 | 18 | // Assert 19 | ms.NoError(err) 20 | ms.False(verrs.HasAny()) 21 | 22 | articleWithFav := &Article{} 23 | ms.DB.Eager("ArticleFavorites").Find(articleWithFav, article.ID) 24 | ms.Equal(1, len(articleWithFav.ArticleFavorites)) 25 | } 26 | -------------------------------------------------------------------------------- /models/article_tag.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/gobuffalo/pop/v6" 7 | "github.com/gobuffalo/validate/v3" 8 | "github.com/gofrs/uuid" 9 | ) 10 | 11 | // ArticleTag is used by pop to map your article_tags database table to your go code. 12 | type ArticleTag struct { 13 | ID uuid.UUID `json:"id" db:"id"` 14 | Tag Tag `belongs_to:"tag"` 15 | TagID uuid.UUID `db:"tag_id"` 16 | Article Article `belongs_to:"article"` 17 | ArticleID uuid.UUID `db:"article_id"` 18 | } 19 | 20 | // String is not required by pop and may be deleted 21 | func (a ArticleTag) String() string { 22 | ja, _ := json.Marshal(a) 23 | return string(ja) 24 | } 25 | 26 | // ArticleTags is not required by pop and may be deleted 27 | type ArticleTags []ArticleTag 28 | 29 | // String is not required by pop and may be deleted 30 | func (a ArticleTags) String() string { 31 | ja, _ := json.Marshal(a) 32 | return string(ja) 33 | } 34 | 35 | // LoadPopularArticleTags loads the most popular tags 36 | func LoadPopularArticleTags(tx *pop.Connection, limit int) ([]Tag, error) { 37 | tagCounts := []struct { 38 | TagID uuid.UUID `db:"tag_id"` 39 | Count int64 `db:"count"` 40 | }{} 41 | 42 | q := tx.RawQuery("SELECT COUNT(*) AS count, tag_id AS tag_id FROM article_tags GROUP BY (article_tags.tag_id) ORDER BY COUNT desc") 43 | err := q.Limit(limit).All(&tagCounts) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | tagIds := []uuid.UUID{} 49 | 50 | for _, tagCount := range tagCounts { 51 | tagIds = append(tagIds, tagCount.TagID) 52 | } 53 | 54 | tags := []Tag{} 55 | if len(tagIds) == 0 { 56 | return tags, nil 57 | } 58 | 59 | err = tx.Where("id in (?)", tagIds).All(&tags) 60 | 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | return tags, nil 66 | } 67 | 68 | // Create an article tag 69 | func (a *ArticleTag) Create(tx *pop.Connection) (*validate.Errors, error) { 70 | return tx.ValidateAndCreate(a) 71 | } 72 | 73 | // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. 74 | // This method is not required and may be deleted. 75 | func (a *ArticleTag) Validate(tx *pop.Connection) (*validate.Errors, error) { 76 | return validate.NewErrors(), nil 77 | } 78 | 79 | // ValidateCreate gets run every time you call "pop.ValidateAndCreate" method. 80 | // This method is not required and may be deleted. 81 | func (a *ArticleTag) ValidateCreate(tx *pop.Connection) (*validate.Errors, error) { 82 | return validate.NewErrors(), nil 83 | } 84 | 85 | // ValidateUpdate gets run every time you call "pop.ValidateAndUpdate" method. 86 | // This method is not required and may be deleted. 87 | func (a *ArticleTag) ValidateUpdate(tx *pop.Connection) (*validate.Errors, error) { 88 | return validate.NewErrors(), nil 89 | } 90 | -------------------------------------------------------------------------------- /models/article_tag_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | func (ms *ModelSuite) Test_ArticleTag_LoadWithTag() { 4 | // Arrange 5 | ms.LoadFixture("basics") 6 | 7 | article := &Article{} 8 | 9 | // Act 10 | err := ms.DB.Eager("ArticleTags").Eager("ArticleTags.Tag").First(article) 11 | 12 | // Assert 13 | ms.NoError(err) 14 | ms.Equal(1, len(article.ArticleTags)) 15 | 16 | tag := article.ArticleTags[0].Tag 17 | ms.Equal("beginner", tag.Name) 18 | } 19 | 20 | func (ms *ModelSuite) Test_ArticleTag_AddTag() { 21 | // Arrange 22 | ms.LoadFixture("basics") 23 | 24 | newTag := &Tag{ 25 | Name: "new_tag", 26 | } 27 | _, err := newTag.Create(ms.DB) 28 | ms.NoError(err) 29 | 30 | article := &Article{} 31 | err = ms.DB.Eager("ArticleTags").First(article) 32 | ms.NoError(err) 33 | 34 | ms.Equal(1, len(article.ArticleTags)) 35 | 36 | newArticleTag := &ArticleTag{ 37 | ArticleID: article.ID, 38 | Article: *article, 39 | TagID: newTag.ID, 40 | Tag: *newTag, 41 | } 42 | 43 | // Act 44 | _, err = newArticleTag.Create(ms.DB) 45 | 46 | // Assert 47 | ms.NoError(err) 48 | articleWithNewTag := &Article{} 49 | err = ms.DB.Eager("ArticleTags").Eager("ArticleTags.Tag").Find(articleWithNewTag, article.ID) 50 | ms.NoError(err) 51 | 52 | ms.Equal(2, len(articleWithNewTag.ArticleTags)) 53 | 54 | ms.Equal("beginner", articleWithNewTag.ArticleTags[0].Tag.Name) 55 | ms.Equal(newTag.Name, articleWithNewTag.ArticleTags[1].Tag.Name) 56 | } 57 | 58 | func (ms *ModelSuite) Test_ArticleFavorite_PopularTags() { 59 | // Arrange 60 | ms.LoadFixture("basics") 61 | 62 | // Act 63 | tags, err := LoadPopularArticleTags(ms.DB, 10) 64 | 65 | // Assert 66 | ms.NoError(err) 67 | ms.Equal(1, len(tags)) 68 | } 69 | -------------------------------------------------------------------------------- /models/article_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | func (ms *ModelSuite) Test_Article() { 4 | // Arrange 5 | ms.LoadFixture("basics") 6 | 7 | u := &User{} 8 | ms.DB.Where("email = ?", "sarah@sample.de").First(u) 9 | 10 | countBefore, _ := ms.DB.Count(&Article{}) 11 | 12 | article := &Article{ 13 | UserID: u.ID, 14 | Title: "Title", 15 | Body: "Body", 16 | Description: "Description", 17 | } 18 | 19 | // Act 20 | verrs, err := article.Create(ms.DB) 21 | 22 | // Assert 23 | ms.NoError(err) 24 | ms.False(verrs.HasAny()) 25 | 26 | countAfter, _ := ms.DB.Count(&Article{}) 27 | ms.Equal(countBefore+1, countAfter) 28 | } 29 | -------------------------------------------------------------------------------- /models/comment.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | "github.com/gobuffalo/pop/v6" 8 | "github.com/gobuffalo/validate/v3" 9 | "github.com/gofrs/uuid" 10 | ) 11 | 12 | // Comment is used by pop to map your comments database table to your go code. 13 | type Comment struct { 14 | ID uuid.UUID `json:"id" db:"id"` 15 | Body string `json:"body" db:"body"` 16 | User User `belongs_to:"user"` 17 | UserID uuid.UUID `db:"user_id"` 18 | //Article Article `belongs_to:"article"` 19 | ArticleID uuid.UUID `db:"article_id"` 20 | CreatedAt time.Time `json:"created_at" db:"created_at"` 21 | UpdatedAt time.Time `json:"updated_at" db:"updated_at"` 22 | } 23 | 24 | // String is not required by pop and may be deleted 25 | func (c Comment) String() string { 26 | jc, _ := json.Marshal(c) 27 | return string(jc) 28 | } 29 | 30 | // Comments is not required by pop and may be deleted 31 | type Comments []Comment 32 | 33 | // String is not required by pop and may be deleted 34 | func (c Comments) String() string { 35 | jc, _ := json.Marshal(c) 36 | return string(jc) 37 | } 38 | 39 | // Create a comment 40 | func (c *Comment) Create(tx *pop.Connection) (*validate.Errors, error) { 41 | return tx.ValidateAndCreate(c) 42 | } 43 | 44 | // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. 45 | // This method is not required and may be deleted. 46 | func (c *Comment) Validate(tx *pop.Connection) (*validate.Errors, error) { 47 | return validate.NewErrors(), nil 48 | } 49 | 50 | // ValidateCreate gets run every time you call "pop.ValidateAndCreate" method. 51 | // This method is not required and may be deleted. 52 | func (c *Comment) ValidateCreate(tx *pop.Connection) (*validate.Errors, error) { 53 | return validate.NewErrors(), nil 54 | } 55 | 56 | // ValidateUpdate gets run every time you call "pop.ValidateAndUpdate" method. 57 | // This method is not required and may be deleted. 58 | func (c *Comment) ValidateUpdate(tx *pop.Connection) (*validate.Errors, error) { 59 | return validate.NewErrors(), nil 60 | } 61 | -------------------------------------------------------------------------------- /models/comment_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | func (ms *ModelSuite) Test_Comment() { 4 | // Arrange 5 | ms.LoadFixture("basics") 6 | 7 | article := &Article{} 8 | ms.DB.First(article) 9 | 10 | comment := &Comment{ 11 | UserID: article.UserID, 12 | ArticleID: article.ID, 13 | Body: "My Comment", 14 | } 15 | 16 | // Act 17 | verrs, err := comment.Create(ms.DB) 18 | 19 | // Assert 20 | ms.NoError(err) 21 | ms.False(verrs.HasAny()) 22 | 23 | comments := []Comment{} 24 | ms.DB.Where("article_id = ?", article.ID).Order("created_at desc").Limit(20).Eager().All(&comments) 25 | 26 | ms.Equal(1, len(comments)) 27 | } 28 | -------------------------------------------------------------------------------- /models/follow.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/gobuffalo/pop/v6" 7 | "github.com/gobuffalo/validate/v3" 8 | "github.com/gofrs/uuid" 9 | ) 10 | 11 | // Follow is used by pop to map your follows database table to your go code. 12 | type Follow struct { 13 | ID uuid.UUID `json:"id" db:"id"` 14 | UserID uuid.UUID `db:"user_id"` 15 | FollowID uuid.UUID `db:"follow_id"` 16 | } 17 | 18 | // String is not required by pop and may be deleted 19 | func (f Follow) String() string { 20 | jf, _ := json.Marshal(f) 21 | return string(jf) 22 | } 23 | 24 | // Follows is not required by pop and may be deleted 25 | type Follows []Follow 26 | 27 | // String is not required by pop and may be deleted 28 | func (f Follows) String() string { 29 | jf, _ := json.Marshal(f) 30 | return string(jf) 31 | } 32 | 33 | // Create a follow relation 34 | func (f *Follow) Create(tx *pop.Connection) (*validate.Errors, error) { 35 | return tx.ValidateAndCreate(f) 36 | } 37 | 38 | // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. 39 | // This method is not required and may be deleted. 40 | func (f *Follow) Validate(tx *pop.Connection) (*validate.Errors, error) { 41 | return validate.NewErrors(), nil 42 | } 43 | 44 | // ValidateCreate gets run every time you call "pop.ValidateAndCreate" method. 45 | // This method is not required and may be deleted. 46 | func (f *Follow) ValidateCreate(tx *pop.Connection) (*validate.Errors, error) { 47 | return validate.NewErrors(), nil 48 | } 49 | 50 | // ValidateUpdate gets run every time you call "pop.ValidateAndUpdate" method. 51 | // This method is not required and may be deleted. 52 | func (f *Follow) ValidateUpdate(tx *pop.Connection) (*validate.Errors, error) { 53 | return validate.NewErrors(), nil 54 | } 55 | -------------------------------------------------------------------------------- /models/follow_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | func (ms *ModelSuite) Test_Follow() { 4 | // Arrange 5 | ms.LoadFixture("basics") 6 | 7 | u1 := &User{} 8 | ms.DB.Where("email = ?", "sarah@sample.de").First(u1) 9 | 10 | u2 := &User{} 11 | ms.DB.Where("email = ?", "max@sample.de").First(u2) 12 | 13 | follow := &Follow{ 14 | UserID: u2.ID, 15 | FollowID: u1.ID, 16 | } 17 | 18 | // Act 19 | verrs, err := follow.Create(ms.DB) 20 | 21 | // Assert 22 | ms.NoError(err) 23 | ms.False(verrs.HasAny()) 24 | 25 | u1WithFollowers := &User{} 26 | ms.DB.Where("email = ?", "sarah@sample.de").Eager("Followers").First(u1WithFollowers) 27 | 28 | ms.Equal(1, len(u1WithFollowers.Followers)) 29 | } 30 | -------------------------------------------------------------------------------- /models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/gobuffalo/envy" 7 | "github.com/gobuffalo/pop/v6" 8 | ) 9 | 10 | // DB is a connection to your database to be used 11 | // throughout your application. 12 | var DB *pop.Connection 13 | 14 | func init() { 15 | var err error 16 | env := envy.Get("GO_ENV", "development") 17 | DB, err = pop.Connect(env) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | pop.Debug = env == "development" 22 | } 23 | -------------------------------------------------------------------------------- /models/models_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gobuffalo/packr/v2" 7 | "github.com/gobuffalo/suite/v3" 8 | ) 9 | 10 | type ModelSuite struct { 11 | *suite.Model 12 | } 13 | 14 | func Test_ModelSuite(t *testing.T) { 15 | model, err := suite.NewModelWithFixtures(packr.New("fixtures", "../fixtures")) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | as := &ModelSuite{ 21 | Model: model, 22 | } 23 | suite.Run(t, as) 24 | } 25 | -------------------------------------------------------------------------------- /models/tag.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/gobuffalo/pop/v6" 7 | "github.com/gobuffalo/validate/v3" 8 | "github.com/gobuffalo/validate/v3/validators" 9 | "github.com/gofrs/uuid" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // Tag is used by pop to map your tags database table to your go code. 14 | type Tag struct { 15 | ID uuid.UUID `json:"id" db:"id"` 16 | Name string `json:"name" db:"name"` 17 | } 18 | 19 | // String is not required by pop and may be deleted 20 | func (t Tag) String() string { 21 | jt, _ := json.Marshal(t) 22 | return string(jt) 23 | } 24 | 25 | // Tags is not required by pop and may be deleted 26 | type Tags []Tag 27 | 28 | // String is not required by pop and may be deleted 29 | func (t Tags) String() string { 30 | jt, _ := json.Marshal(t) 31 | return string(jt) 32 | } 33 | 34 | // LoadOrCreateTags loads given tags or creates them if not present yet 35 | func LoadOrCreateTags(tx *pop.Connection, tagNames []string) ([]Tag, error) { 36 | tags := []Tag{} 37 | err := tx.Where("name in (?)", tagNames).All(&tags) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | for _, tagName := range tagNames { 43 | 44 | tagPresent := false 45 | for _, tag := range tags { 46 | if tag.Name == tagName { 47 | tagPresent = true 48 | break 49 | } 50 | } 51 | 52 | if tagPresent { 53 | continue 54 | } 55 | 56 | // create Tag 57 | t := &Tag{ 58 | Name: tagName, 59 | } 60 | 61 | _, err := t.Create(tx) 62 | if err != nil { 63 | return nil, errors.WithStack(err) 64 | } 65 | 66 | tags = append(tags, *t) 67 | } 68 | 69 | return tags, err 70 | } 71 | 72 | // Create a tag 73 | func (t *Tag) Create(tx *pop.Connection) (*validate.Errors, error) { 74 | return tx.ValidateAndCreate(t) 75 | } 76 | 77 | // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. 78 | // This method is not required and may be deleted. 79 | func (t *Tag) Validate(tx *pop.Connection) (*validate.Errors, error) { 80 | return validate.Validate( 81 | &validators.StringIsPresent{Field: t.Name, Name: "Name"}, 82 | ), nil 83 | } 84 | 85 | // ValidateCreate gets run every time you call "pop.ValidateAndCreate" method. 86 | // This method is not required and may be deleted. 87 | func (t *Tag) ValidateCreate(tx *pop.Connection) (*validate.Errors, error) { 88 | return validate.NewErrors(), nil 89 | } 90 | 91 | // ValidateUpdate gets run every time you call "pop.ValidateAndUpdate" method. 92 | // This method is not required and may be deleted. 93 | func (t *Tag) ValidateUpdate(tx *pop.Connection) (*validate.Errors, error) { 94 | return validate.NewErrors(), nil 95 | } 96 | -------------------------------------------------------------------------------- /models/tag_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | func (ms *ModelSuite) Test_Tag_Create() { 4 | // Arrange 5 | countBefore, err := ms.DB.Count("tags") 6 | ms.NoError(err) 7 | 8 | t := &Tag{ 9 | Name: "mytag", 10 | } 11 | 12 | // Act 13 | verrs, err := t.Create(ms.DB) 14 | 15 | // Assert 16 | ms.False(verrs.HasAny()) 17 | ms.NoError(err) 18 | 19 | count, err := ms.DB.Count("tags") 20 | ms.NoError(err) 21 | ms.Equal(countBefore+1, count) 22 | } 23 | -------------------------------------------------------------------------------- /models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "time" 7 | 8 | "github.com/gobuffalo/pop/v6" 9 | "github.com/gobuffalo/validate/v3" 10 | "github.com/gobuffalo/validate/v3/validators" 11 | "github.com/gofrs/uuid" 12 | "github.com/pkg/errors" 13 | "golang.org/x/crypto/bcrypt" 14 | ) 15 | 16 | //User is a generated model from buffalo-auth, it serves as the base for username/password authentication. 17 | type User struct { 18 | ID uuid.UUID `json:"id" db:"id"` 19 | CreatedAt time.Time `json:"created_at" db:"created_at"` 20 | UpdatedAt time.Time `json:"updated_at" db:"updated_at"` 21 | Name string `json:"name" db:"name"` 22 | Email string `json:"email" db:"email"` 23 | PasswordHash string `json:"password_hash" db:"password_hash"` 24 | Password string `json:"-" db:"-"` 25 | PasswordConfirmation string `json:"-" db:"-"` 26 | Followers []Follow `has_many:"followers" fk_id:"follow_id"` 27 | } 28 | 29 | // Create wraps up the pattern of encrypting the password and 30 | // running validations. Useful when writing tests. 31 | func (u *User) Create(tx *pop.Connection) (*validate.Errors, error) { 32 | u.Email = strings.ToLower(u.Email) 33 | ph, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) 34 | if err != nil { 35 | return validate.NewErrors(), errors.WithStack(err) 36 | } 37 | u.PasswordHash = string(ph) 38 | return tx.ValidateAndCreate(u) 39 | } 40 | 41 | // String is not required by pop and may be deleted 42 | func (u User) String() string { 43 | ju, _ := json.Marshal(u) 44 | return string(ju) 45 | } 46 | 47 | // Users is not required by pop and may be deleted 48 | type Users []User 49 | 50 | // String is not required by pop and may be deleted 51 | func (u Users) String() string { 52 | ju, _ := json.Marshal(u) 53 | return string(ju) 54 | } 55 | 56 | // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. 57 | // This method is not required and may be deleted. 58 | func (u *User) Validate(tx *pop.Connection) (*validate.Errors, error) { 59 | var err error 60 | return validate.Validate( 61 | &validators.StringIsPresent{Field: u.Name, Name: "Name"}, 62 | &validators.StringIsPresent{Field: u.Email, Name: "Email"}, 63 | &validators.StringIsPresent{Field: u.PasswordHash, Name: "PasswordHash"}, 64 | // check to see if the email address is already taken: 65 | &validators.FuncValidator{ 66 | Field: u.Email, 67 | Name: "Email", 68 | Message: "%s is already taken", 69 | Fn: func() bool { 70 | var b bool 71 | q := tx.Where("email = ?", u.Email) 72 | if u.ID != uuid.Nil { 73 | q = q.Where("id != ?", u.ID) 74 | } 75 | b, err = q.Exists(u) 76 | if err != nil { 77 | return false 78 | } 79 | return !b 80 | }, 81 | }, 82 | ), err 83 | } 84 | 85 | // ValidateCreate gets run every time you call "pop.ValidateAndCreate" method. 86 | // This method is not required and may be deleted. 87 | func (u *User) ValidateCreate(tx *pop.Connection) (*validate.Errors, error) { 88 | var err error 89 | return validate.Validate( 90 | &validators.StringIsPresent{Field: u.Password, Name: "Password"}, 91 | ), err 92 | } 93 | 94 | // ValidateUpdate gets run every time you call "pop.ValidateAndUpdate" method. 95 | // This method is not required and may be deleted. 96 | func (u *User) ValidateUpdate(tx *pop.Connection) (*validate.Errors, error) { 97 | return validate.NewErrors(), nil 98 | } 99 | -------------------------------------------------------------------------------- /models/user_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | func (ms *ModelSuite) Test_User_Create() { 4 | count, err := ms.DB.Count("users") 5 | ms.NoError(err) 6 | ms.Equal(0, count) 7 | 8 | u := &User{ 9 | Name: "Mark Example", 10 | Email: "mark@example.com", 11 | Password: "password", 12 | PasswordConfirmation: "password", 13 | } 14 | 15 | ms.Zero(u.PasswordHash) 16 | 17 | verrs, err := u.Create(ms.DB) 18 | ms.NoError(err) 19 | ms.False(verrs.HasAny()) 20 | ms.NotZero(u.PasswordHash) 21 | 22 | count, err = ms.DB.Count("users") 23 | ms.NoError(err) 24 | ms.Equal(1, count) 25 | } 26 | 27 | func (ms *ModelSuite) Test_User_Create_ValidationErrors() { 28 | count, err := ms.DB.Count("users") 29 | ms.NoError(err) 30 | ms.Equal(0, count) 31 | 32 | u := &User{ 33 | Password: "password", 34 | } 35 | 36 | ms.Zero(u.PasswordHash) 37 | 38 | verrs, err := u.Create(ms.DB) 39 | ms.NoError(err) 40 | ms.True(verrs.HasAny()) 41 | 42 | count, err = ms.DB.Count("users") 43 | ms.NoError(err) 44 | ms.Equal(0, count) 45 | } 46 | 47 | func (ms *ModelSuite) Test_User_Create_UserExists() { 48 | count, err := ms.DB.Count("users") 49 | ms.NoError(err) 50 | ms.Equal(0, count) 51 | 52 | u := &User{ 53 | Email: "mark@example.com", 54 | Name: "Mark Example", 55 | Password: "password", 56 | PasswordConfirmation: "password", 57 | } 58 | 59 | ms.Zero(u.PasswordHash) 60 | 61 | verrs, err := u.Create(ms.DB) 62 | ms.NoError(err) 63 | ms.False(verrs.HasAny()) 64 | ms.NotZero(u.PasswordHash) 65 | 66 | count, err = ms.DB.Count("users") 67 | ms.NoError(err) 68 | ms.Equal(1, count) 69 | 70 | u = &User{ 71 | Email: "mark@example.com", 72 | Password: "password", 73 | } 74 | 75 | verrs, err = u.Create(ms.DB) 76 | ms.NoError(err) 77 | ms.True(verrs.HasAny()) 78 | 79 | count, err = ms.DB.Count("users") 80 | ms.NoError(err) 81 | ms.Equal(1, count) 82 | } 83 | -------------------------------------------------------------------------------- /public/embed.go: -------------------------------------------------------------------------------- 1 | package public 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | 7 | "github.com/gobuffalo/buffalo" 8 | ) 9 | 10 | //go:embed * 11 | var files embed.FS 12 | 13 | func FS() fs.FS { 14 | return buffalo.NewFS(files, "public") 15 | } 16 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /templates/_flash.html: -------------------------------------------------------------------------------- 1 |
<%= comment.Body %>
51 |<%= comment.Body %>
51 |8 | <%= linkTo(usersRegisterPath()){ %>Need an account?<% } %> 9 |
10 | 11 | <%= form_for(user, {action: authPath()}) { %> 12 | <%= f.InputTag("Email", {class:"form-control-lg", placeholder: "Email"}) %> 13 | <%= f.InputTag("Password", {type: "password", class:"form-control-lg", placeholder: "Password"}) %> 14 | 15 | <% } %> 16 | 17 |8 | <%= linkTo(usersRegisterPath()){ %>Need an account?<% } %> 9 |
10 | 11 | <%= form_for(user, {action: authPath()}) { %> 12 | <%= f.InputTag("Email", {class:"form-control-lg", placeholder: "Email"}) %> 13 | <%= f.InputTag("Password", {type: "password", class:"form-control-lg", placeholder: "Password"}) %> 14 | 15 | <% } %> 16 | 17 |8 | <%= linkTo(authLoginPath()){ %>Have an account?<% } %> 9 |
10 | 11 | <%= form_for(user, {action: usersRegisterPath(), method: "POST"}) { %> 12 | <%= f.InputTag("Name", {placeholder: "Your Name", class: "form-control-lg" }) %> 13 | <%= f.InputTag("Email", {placeholder: "Email", class: "form-control-lg" }) %> 14 | <%= f.InputTag("Password", {type: "password", placeholder: "Password", class: "form-control-lg" }) %> 15 | 16 | 19 | <% } %> 20 | 21 | 26 | 27 |8 | <%= linkTo(authLoginPath()){ %>Have an account?<% } %> 9 |
10 | 11 | <%= form_for(user, {action: usersRegisterPath(), method: "POST"}) { %> 12 | <%= f.InputTag("Name", {placeholder: "Your Name", class: "form-control-lg" }) %> 13 | <%= f.InputTag("Email", {placeholder: "Email", class: "form-control-lg" }) %> 14 | <%= f.InputTag("Password", {type: "password", placeholder: "Password", class: "form-control-lg" }) %> 15 | 16 | 19 | <% } %> 20 | 21 | 26 | 27 |