1
0
mirror of https://github.com/raseels-repos/golang-saas-starter-kit.git synced 2025-08-08 22:36:41 +02:00

Merge branch 'master' into issue8/datadog-lambda-func

This commit is contained in:
Lee Brown
2019-08-21 16:31:11 -08:00
69 changed files with 1995 additions and 1147 deletions

View File

@ -1,32 +1,148 @@
# SaaS Web App
Copyright 2019, Geeks Accelerator
accelerator@geeksinthewoods.com
twins@geeksaccelerator.com
## Description
Responsive web application that renders HTML using the `html/template` package from the standard library to enable
direct interaction with clients and their users. It allows clients to sign up new accounts and provides user
authentication with HTTP sessions. To see screen captures of the web app, check out this Google Slides deck:
authentication with HTTP sessions.
The web-app service is a fully functioning example. To see screen captures of the Golang web app, check out this Google
Slides deck:
https://docs.google.com/presentation/d/1WGYqMZ-YUOaNxlZBfU4srpN8i86MU0ppWWSBb3pkejM/edit#slide=id.p
*You are welcome to add comments to the Google Slides.*
[![Google Slides of Screen Captures for SaaS Starter Kit web app](resources/images/saas-webapp-screencapture-01.jpg)](https://docs.google.com/presentation/d/1WGYqMZ-YUOaNxlZBfU4srpN8i86MU0ppWWSBb3pkejM/edit#slide=id.p)
We have also deployed this example Go web app to production here:
https://example.saasstartupkit.com
[![Example Golang web app deployed](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-projects.png)](https://example.saasstartupkit.com)
The web app relies on the Golang business logic packages developed to provide an API for internal requests.
Once the web-app service is running it will be available on port 3000.
Once the web-app service is running, it will be available on port 3000.
http://127.0.0.1:3000/
While the web-api service has
significant functionality, this web-app service is still in development. Currently this web-app services only resizes
an image and displays resized versions of it on the index page. See section below on Future Functionality.
If you would like to help, please email twins@geeksinthewoods.com.
## Web App functionality
This example web app allows customers to subscribe to the SaaS. Once subscribed they can authenticate with the web app
and the business value can be delivered as a service. The business value of the example web app allows users to manage
projects. Users with access to the project can perform CRUD operations on the record.
This web-app service includes the following pages and corresponding functionality:
[![Example Golang web app deployed](../../resources/images/saas-starter-kit-go-web-app-pages.png)](../../resources/images/saas-starter-kit-go-web-app-pages.png)
### landing pages
The example web-app service in the SaaS Startup Kit includes typical pages for new customers to learn about your
service. It allows new customers to review a pricing page as well as signup. Existing customers of your SaaS can login
or connect with your support resources. The static web page for your SaaS website also includes a page for your web API
service. These are working example pages that a typical SaaS product usually include.
[![Golang landing page of Go pricing page](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-pricing.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-pricing.png)
### signup
In order for your SaaS offering to deliver its value to your customer, they need to subscribe first. Users can subscribe
using this signup page.
[![Golang web app signup and Go SaaS app sign up](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-signup.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-signup.png)
The signup page creates an account and a user associated with the new account. This signup page
also uses some cool inline validation.
### authentication
Software-as-a-Service usually provides its service after a user has created an account and authenticated. After a user
has an account, they can login to your web app. Once logged in they will have access to all pages that require
authentication. This login page also uses some cool inline validation.
[![Golang web app authentication and Go web app login](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-login.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-login.png)
The GO web app implements Role-based access control (RBAC). The example web app has two basic roles for users: admin
and user.
* The role of admin provides the ability to perform all CRUD actions on projects and users.
* The role of user limits users to only view projects and users.
Once a user is logged in, then RBAC is enforced and users only can access projects they have access to.
The web-app service also includes functionality for logout and forgot password. The forgot password functionality
send an email to the user with a link to web page that allows them to change their password.
### projects
The example code for the web-app service exposes business value to authenticated users. This business value is coded into
various business logic packages. One example business logic package is the one to create and manage Projects. In the
SaaS Startup Kit, projects represent the highest level of business value. Users can perform CRUD on project records.
The web app includes this index page that lists all records for projects. This index page uses Datatables to demonstrate
providing advanced interactivity to HTML tables. This index page then allows users to view, update and delete an object.
[![Golang web app object list and Go app object search](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-projects.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-projects.png)
From the projects index page, users can click the button to create a new record. This create page demonstrates how a new
record can be created for projects and also demonstrates inline validation.
The view page for an object displays the fields for the object as read-only. The page then includes links to edit or
archive the object. The archive functionality demonstrates how a soft-delete can be performed. While the web app does
not expose functionality to delete a record, the internal API does support the delete operation.
[![Golang web app object list and Go app object search](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-project-view.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-project-view.png)
You can easily modify the projects package to support your own requirements. If you were providing a software-as-a-service
similar to Github, Projects could be changed to be 'repositories'. If you were providing software-as-a-service similar
to Slack, Projects could be modified to be 'channels', etc.
### user (profile)
After users authenticate with the web app, there is example code for them to view their user details (view their profile).
[![Golang web app authentication and Go web app login](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-profile-view2.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-profile-view2.png)
A user can then update the details for the record of their user. This another example demonstration the update operation.
There is also functionality for the user to change their password.
### account (management)
When a user signups to your SaaS via the web app, an account is created. Authenticated users can then view the details
of their account.
[![Golang app account management and Go web app update account](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-account-update2.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-account-update2.png)
Users with role of admin can view and update the details of their account, while non-admins can only view the details
of their account.
### users (management)
Users with role of admin have access to functionality that allows them to manage the users associated with their account.
This index page uses Datatables to demonstrate providing advanced interactivity to HTML tables.
[![Golang app users management and Go web app update user](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-users.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-users.png)
From the users index page, users can access the functionality to create a new record. This create page demonstrates how
a new record can be created for users. The create functionality also allows one or more roles to be applied for ACLs.
[![Golang app create user and Go web app create user](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-users-create.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-users-create.png)
If the admin would rather the new users provide their own user details, there is Go code demonstrating how users can be
invited. The invite functionality allows users to specifiy one or more email addresses. Once submitted, the web app will
send email invites to allow the users to activate their user.
From the users index page, admins for an account can view users details. This page also provides access to update the
user as well as archive it.
## Local Installation
@ -38,8 +154,8 @@ go build .
### Docker
To build using the docker file, need to be in the project root directory. `Dockerfile` references go.mod in root directory.
To build using the docker file, you need to be in the project root directory since the `Dockerfile` references
Go Modules that are located there.
```bash
docker build -f cmd/web-app/Dockerfile -t saas-web-app .
@ -60,7 +176,7 @@ http://127.0.0.1:3000/signup?test-web-error=1
### Localization
Test a specific language by appending the locale to the request URL.
127.0.0.1:3000/signup?local=fr
http://127.0.0.1:3000/signup?local=fr
[github.com/go-playground/validator](https://github.com/go-playground/validator) supports the following languages.
@ -71,47 +187,25 @@ Test a specific language by appending the locale to the request URL.
- nl - Dutch
- zh - Chinese
### HTTP Pipeline (Middleware)
In any production ready web application there're many concerns that should be handle it correctly such as:
* logging
* tracing
* error handling
* observability metrics
* security
All these responsabilities are orthogonal between each other, and in particular, to the business logic. In `saas-starter-kit` these responsabilities are handeled in a chained set of middlewares which allow a clear separation of concerns and it avoids polluting business-rule code.
We can separate existing middlewares in two dimensions: cross-cutting application middlewares, and middlewares for particular routes. Middlewares such as tracing, error handling, and metrics belong to the former category, whereas authentication/authorization to the latter.
If you want to dig into the details regarding these configurations, refer to `handlers/routes.go` where you can find the application middleware chaining, and the particular middlewares per route when adding handlers with `app.Handle(...)`.
### Routes
Every valid URL route can be found in `handlers/route.go`.
Notice that every handler is grouped by business-context (`Projects`, `Users`, `Account`) compared to sharing a single big struct. This allows to limit the scope of action of handlers regarding other actions that are far from its reponsability, and facilitates testing since less mockups will be necessary to test the handlers.
### Future Functionality
This example Web App is going to allow users to manage checklists. Users with role of admin will be allowed to
create new checklists (projects). Each checklist will have tasks (items) associated with it. Tasks can be assigned to
users with access to the checklist. Users can then update the status of a task.
We are referring to "checklists" as "projects" and "tasks" as "items" so this example web-app service will be generic
enough for you to leverage and build upon without lots of renaming.
The initial contributors to this project created a similar service like this: [standard operating procedure software](https://keeni.space/procedures/software)
for Keeni.Space. Its' Golang web app for [standard operating procedures software](https://keeni.space/procedures/software) is available at [app.keeni.space](https://app.keeni.space) They plan on leveraging this experience and boil it down into a simplified set of functionality
and corresponding web pages that will be a solid examples for building enterprise SaaS web apps with Golang.
This web-app service eventually will include the following:
- authentication
- signup (creates user and account records)
- login
- with role-based access
- logout
- forgot password
- user management
- update user and password
- account management
- update account
- manage user
- view user
- create and invite user
- update user
- projects (checklists)
- index of projects
- browse, filter, search
- manage projects
- view project
- with project items
- create project
- update project
- user access
- project items (tasks)
- view item
- create item (adds task to checklist)
- update item

View File

@ -34,7 +34,7 @@
{"name": "ECS_SERVICE", "value": "{ECS_SERVICE}"},
{"name": "WEB_APP_HTTP_HOST", "value": "{HTTP_HOST}"},
{"name": "WEB_APP_HTTPS_HOST", "value": "{HTTPS_HOST}"},
{"name": "WEB_APP_SERVICE_PROJECT", "value": "{APP_PROJECT}"},
{"name": "WEB_APP_SERVICE_SERVICE_NAME", "value": "{SERVICE}"},
{"name": "WEB_APP_SERVICE_BASE_URL", "value": "{APP_BASE_URL}"},
{"name": "WEB_APP_SERVICE_HOST_NAMES", "value": "{HOST_NAMES}"},
{"name": "WEB_APP_SERVICE_ENABLE_HTTPS", "value": "{HTTPS_ENABLED}"},
@ -42,8 +42,9 @@
{"name": "WEB_APP_SERVICE_STATICFILES_S3_PREFIX", "value": "{STATIC_FILES_S3_PREFIX}"},
{"name": "WEB_APP_SERVICE_STATICFILES_CLOUDFRONT_ENABLED", "value": "{STATIC_FILES_CLOUDFRONT_ENABLED}"},
{"name": "WEB_APP_SERVICE_STATICFILES_IMG_RESIZE_ENABLED", "value": "{STATIC_FILES_IMG_RESIZE_ENABLED}"},
{"name": "WEB_APP_SERVICE_EMAIL_SENDER", "value": "{EMAIL_SENDER}"},
{"name": "WEB_APP_SERVICE_WEB_API_BASE_URL", "value": "{WEB_API_BASE_URL}"},
{"name": "WEB_APP_PROJECT_PROJECT_NAME", "value": "{APP_PROJECT}"},
{"name": "WEB_APP_PROJECT_EMAIL_SENDER", "value": "{EMAIL_SENDER}"},
{"name": "WEB_APP_PROJECT_WEB_API_BASE_URL", "value": "{WEB_API_BASE_URL}"},
{"name": "WEB_APP_REDIS_HOST", "value": "{CACHE_HOST}"},
{"name": "WEB_APP_DB_HOST", "value": "{DB_HOST}"},
{"name": "WEB_APP_DB_USER", "value": "{DB_USER}"},

View File

@ -2,9 +2,7 @@ package handlers
import (
"context"
"net/http"
"time"
"geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers"
"geeks-accelerator/oss/saas-starter-kit/internal/account"
"geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference"
"geeks-accelerator/oss/saas-starter-kit/internal/geonames"
@ -12,16 +10,22 @@ import (
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"net/http"
"time"
"github.com/gorilla/schema"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
)
// Account represents the Account API method handler set.
type Account struct {
MasterDB *sqlx.DB
Renderer web.Renderer
Authenticator *auth.Authenticator
AccountRepo handlers.AccountRepository
AccountPrefRepo handlers.AccountPrefRepository
AuthRepo handlers.UserAuthRepository
GeoRepo GeoRepository
Authenticator *auth.Authenticator
Renderer web.Renderer
}
// View handles displaying the current account profile.
@ -35,7 +39,7 @@ func (h *Account) View(ctx context.Context, w http.ResponseWriter, r *http.Reque
return err
}
acc, err := account.ReadByID(ctx, claims, h.MasterDB, claims.Audience)
acc, err := h.AccountRepo.ReadByID(ctx, claims, claims.Audience)
if err != nil {
return err
}
@ -77,7 +81,7 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
return false, err
}
prefs, err := account_preference.FindByAccountID(ctx, claims, h.MasterDB, account_preference.AccountPreferenceFindByAccountIDRequest{
prefs, err := h.AccountPrefRepo.FindByAccountID(ctx, claims, account_preference.AccountPreferenceFindByAccountIDRequest{
AccountID: claims.Audience,
})
if err != nil {
@ -115,7 +119,7 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
}
req.ID = claims.Audience
err = account.Update(ctx, claims, h.MasterDB, req.AccountUpdateRequest, ctxValues.Now)
err = h.AccountRepo.Update(ctx, claims, req.AccountUpdateRequest, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
default:
@ -135,7 +139,7 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
}
if preferenceDatetimeFormat != req.PreferenceDatetimeFormat {
err = account_preference.Set(ctx, claims, h.MasterDB, account_preference.AccountPreferenceSetRequest{
err = h.AccountPrefRepo.Set(ctx, claims, account_preference.AccountPreferenceSetRequest{
AccountID: claims.Audience,
Name: account_preference.AccountPreference_Datetime_Format,
Value: req.PreferenceDatetimeFormat,
@ -156,7 +160,7 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
}
if preferenceDateFormat != req.PreferenceDateFormat {
err = account_preference.Set(ctx, claims, h.MasterDB, account_preference.AccountPreferenceSetRequest{
err = h.AccountPrefRepo.Set(ctx, claims, account_preference.AccountPreferenceSetRequest{
AccountID: claims.Audience,
Name: account_preference.AccountPreference_Date_Format,
Value: req.PreferenceDateFormat,
@ -177,7 +181,7 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
}
if preferenceTimeFormat != req.PreferenceTimeFormat {
err = account_preference.Set(ctx, claims, h.MasterDB, account_preference.AccountPreferenceSetRequest{
err = h.AccountPrefRepo.Set(ctx, claims, account_preference.AccountPreferenceSetRequest{
AccountID: claims.Audience,
Name: account_preference.AccountPreference_Time_Format,
Value: req.PreferenceTimeFormat,
@ -213,7 +217,7 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
return true, web.Redirect(ctx, w, r, "/account", http.StatusFound)
}
acc, err := account.ReadByID(ctx, claims, h.MasterDB, claims.Audience)
acc, err := h.AccountRepo.ReadByID(ctx, claims, claims.Audience)
if err != nil {
return false, err
}
@ -244,14 +248,14 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
data["account"] = acc.Response(ctx)
data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB)
data["timezones"], err = h.GeoRepo.ListTimezones(ctx)
if err != nil {
return false, err
}
data["geonameCountries"] = geonames.ValidGeonameCountries(ctx)
data["countries"], err = geonames.FindCountries(ctx, h.MasterDB, "name", "")
data["countries"], err = h.GeoRepo.FindCountries(ctx, "name", "")
if err != nil {
return false, err
}

View File

@ -8,14 +8,24 @@ import (
"geeks-accelerator/oss/saas-starter-kit/internal/geonames"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"github.com/jmoiron/sqlx"
//"github.com/jmoiron/sqlx"
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
)
// Check provides support for orchestration geo endpoints.
type Geo struct {
MasterDB *sqlx.DB
Redis *redis.Client
Redis *redis.Client
GeoRepo GeoRepository
}
type GeoRepository interface {
FindGeonames(ctx context.Context, orderBy, where string, args ...interface{}) ([]*geonames.Geoname, error)
FindGeonamePostalCodes(ctx context.Context, where string, args ...interface{}) ([]string, error)
FindGeonameRegions(ctx context.Context, orderBy, where string, args ...interface{}) ([]*geonames.Region, error)
FindCountries(ctx context.Context, orderBy, where string, args ...interface{}) ([]*geonames.Country, error)
FindCountryTimezones(ctx context.Context, orderBy, where string, args ...interface{}) ([]*geonames.CountryTimezone, error)
ListTimezones(ctx context.Context) ([]string, error)
}
// GeonameByPostalCode...
@ -39,7 +49,7 @@ func (h *Geo) GeonameByPostalCode(ctx context.Context, w http.ResponseWriter, r
where := strings.Join(filters, " AND ")
res, err := geonames.FindGeonames(ctx, h.MasterDB, "postal_code", where, args...)
res, err := h.GeoRepo.FindGeonames(ctx, "postal_code", where, args...)
if err != nil {
fmt.Printf("%+v", err)
return web.RespondJsonError(ctx, w, err)
@ -74,7 +84,7 @@ func (h *Geo) PostalCodesAutocomplete(ctx context.Context, w http.ResponseWriter
where := strings.Join(filters, " AND ")
res, err := geonames.FindGeonamePostalCodes(ctx, h.MasterDB, where, args...)
res, err := h.GeoRepo.FindGeonamePostalCodes(ctx, where, args...)
if err != nil {
return web.RespondJsonError(ctx, w, err)
}
@ -101,7 +111,7 @@ func (h *Geo) RegionsAutocomplete(ctx context.Context, w http.ResponseWriter, r
where := strings.Join(filters, " AND ")
res, err := geonames.FindGeonameRegions(ctx, h.MasterDB, "state_name", where, args...)
res, err := h.GeoRepo.FindGeonameRegions(ctx, "state_name", where, args...)
if err != nil {
fmt.Printf("%+v", err)
return web.RespondJsonError(ctx, w, err)
@ -144,7 +154,7 @@ func (h *Geo) CountryTimezones(ctx context.Context, w http.ResponseWriter, r *ht
where := strings.Join(filters, " AND ")
res, err := geonames.FindCountryTimezones(ctx, h.MasterDB, "timezone_id", where, args...)
res, err := h.GeoRepo.FindCountryTimezones(ctx, "timezone_id", where, args...)
if err != nil {
return web.RespondJsonError(ctx, w, err)
}

View File

@ -3,6 +3,7 @@ package handlers
import (
"context"
"fmt"
"geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers"
"net/http"
"strings"
@ -12,17 +13,17 @@ import (
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"geeks-accelerator/oss/saas-starter-kit/internal/project"
"github.com/gorilla/schema"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
)
// Projects represents the Projects API method handler set.
type Projects struct {
MasterDB *sqlx.DB
Redis *redis.Client
Renderer web.Renderer
ProjectRepo handlers.ProjectRepository
Redis *redis.Client
Renderer web.Renderer
}
func urlProjectsIndex() string {
@ -73,7 +74,7 @@ func (h *Projects) Index(ctx context.Context, w http.ResponseWriter, r *http.Req
var v datatable.ColumnValue
switch col.Field {
case "id":
v.Value = fmt.Sprintf("%d", q.ID)
v.Value = fmt.Sprintf("%s", q.ID)
case "name":
v.Value = q.Name
v.Formatted = fmt.Sprintf("<a href='%s'>%s</a>", urlProjectsView(q.ID), v.Value)
@ -110,7 +111,7 @@ func (h *Projects) Index(ctx context.Context, w http.ResponseWriter, r *http.Req
}
loadFunc := func(ctx context.Context, sorting string, fields []datatable.DisplayField) (resp [][]datatable.ColumnValue, err error) {
res, err := project.Find(ctx, claims, h.MasterDB, project.ProjectFindRequest{
res, err := h.ProjectRepo.Find(ctx, claims, project.ProjectFindRequest{
Where: "account_id = ?",
Args: []interface{}{claims.Audience},
Order: strings.Split(sorting, ","),
@ -186,7 +187,7 @@ func (h *Projects) Create(ctx context.Context, w http.ResponseWriter, r *http.Re
}
req.AccountID = claims.Audience
usr, err := project.Create(ctx, claims, h.MasterDB, *req, ctxValues.Now)
usr, err := h.ProjectRepo.Create(ctx, claims, *req, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
default:
@ -251,7 +252,7 @@ func (h *Projects) View(ctx context.Context, w http.ResponseWriter, r *http.Requ
switch r.PostForm.Get("action") {
case "archive":
err = project.Archive(ctx, claims, h.MasterDB, project.ProjectArchiveRequest{
err = h.ProjectRepo.Archive(ctx, claims, project.ProjectArchiveRequest{
ID: projectID,
}, ctxValues.Now)
if err != nil {
@ -276,7 +277,7 @@ func (h *Projects) View(ctx context.Context, w http.ResponseWriter, r *http.Requ
return nil
}
prj, err := project.ReadByID(ctx, claims, h.MasterDB, projectID)
prj, err := h.ProjectRepo.ReadByID(ctx, claims, projectID)
if err != nil {
return err
}
@ -320,7 +321,7 @@ func (h *Projects) Update(ctx context.Context, w http.ResponseWriter, r *http.Re
}
req.ID = projectID
err = project.Update(ctx, claims, h.MasterDB, *req, ctxValues.Now)
err = h.ProjectRepo.Update(ctx, claims, *req, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
default:
@ -351,7 +352,7 @@ func (h *Projects) Update(ctx context.Context, w http.ResponseWriter, r *http.Re
return nil
}
prj, err := project.ReadByID(ctx, claims, h.MasterDB, projectID)
prj, err := h.ProjectRepo.ReadByID(ctx, claims, projectID)
if err != nil {
return err
}

View File

@ -8,9 +8,8 @@ import (
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes"
"geeks-accelerator/oss/saas-starter-kit/internal/project_route"
"github.com/ikeikeikeike/go-sitemap-generator/v2/stm"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"github.com/sethgrid/pester"
"io/ioutil"
@ -19,10 +18,9 @@ import (
// Root represents the Root API method handler set.
type Root struct {
MasterDB *sqlx.DB
Renderer web.Renderer
Sitemap *stm.Sitemap
ProjectRoutes project_routes.ProjectRoutes
Renderer web.Renderer
Sitemap *stm.Sitemap
ProjectRoute project_route.ProjectRoute
}
// Index determines if the user has authentication and loads the associated page.
@ -57,7 +55,7 @@ func (h *Root) SitePage(ctx context.Context, w http.ResponseWriter, r *http.Requ
tmpName = "site-api.gohtml"
// http://127.0.0.1:3001/docs/doc.json
swaggerJsonUrl := h.ProjectRoutes.ApiDocsJson()
swaggerJsonUrl := h.ProjectRoute.ApiDocsJson()
// Load the json file from the API service.
res, err := pester.Get(swaggerJsonUrl)
@ -93,8 +91,8 @@ func (h *Root) SitePage(ctx context.Context, w http.ResponseWriter, r *http.Requ
return errors.WithStack(err)
}
data["urlApiBaseUri"] = h.ProjectRoutes.WebApiUrl(doc.BasePath)
data["urlApiDocs"] = h.ProjectRoutes.ApiDocs()
data["urlApiBaseUri"] = h.ProjectRoute.WebApiUrl(doc.BasePath)
data["urlApiDocs"] = h.ProjectRoute.ApiDocs()
case "/pricing":
tmpName = "site-pricing.gohtml"
@ -123,7 +121,7 @@ func (h *Root) RobotTxt(ctx context.Context, w http.ResponseWriter, r *http.Requ
return web.RespondText(ctx, w, txt, http.StatusOK)
}
sitemapUrl := h.ProjectRoutes.WebAppUrl("/sitemap.xml")
sitemapUrl := h.ProjectRoute.WebAppUrl("/sitemap.xml")
txt := fmt.Sprintf("User-agent: *\nDisallow: /ping\nDisallow: /status\nDisallow: /debug/\nSitemap: %s", sitemapUrl)
return web.RespondText(ctx, w, txt, http.StatusOK)

View File

@ -9,13 +9,23 @@ import (
"path/filepath"
"time"
"geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers"
//"geeks-accelerator/oss/saas-starter-kit/internal/account"
//"geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference"
"geeks-accelerator/oss/saas-starter-kit/internal/mid"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/notify"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes"
//"geeks-accelerator/oss/saas-starter-kit/internal/project"
"geeks-accelerator/oss/saas-starter-kit/internal/project_route"
// "geeks-accelerator/oss/saas-starter-kit/internal/signup"
// "geeks-accelerator/oss/saas-starter-kit/internal/user"
// "geeks-accelerator/oss/saas-starter-kit/internal/user_account"
// "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite"
// "geeks-accelerator/oss/saas-starter-kit/internal/user_auth"
"github.com/ikeikeikeike/go-sitemap-generator/v2/stm"
"github.com/jmoiron/sqlx"
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
@ -27,30 +37,59 @@ const (
TmplContentErrorGeneric = "error-generic.gohtml"
)
type AppContext struct {
Log *log.Logger
Env webcontext.Env
MasterDB *sqlx.DB
Redis *redis.Client
UserRepo handlers.UserRepository
UserAccountRepo handlers.UserAccountRepository
AccountRepo handlers.AccountRepository
AccountPrefRepo handlers.AccountPrefRepository
AuthRepo handlers.UserAuthRepository
SignupRepo handlers.SignupRepository
InviteRepo handlers.UserInviteRepository
ProjectRepo handlers.ProjectRepository
GeoRepo GeoRepository
Authenticator *auth.Authenticator
StaticDir string
TemplateDir string
Renderer web.Renderer
ProjectRoute project_route.ProjectRoute
PreAppMiddleware []web.Middleware
PostAppMiddleware []web.Middleware
}
// API returns a handler for a set of routes.
func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir, templateDir string, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, projectRoutes project_routes.ProjectRoutes, secretKey string, notifyEmail notify.Email, renderer web.Renderer, globalMids ...web.Middleware) http.Handler {
func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler {
// Define base middlewares applied to all requests.
middlewares := []web.Middleware{
mid.Trace(), mid.Logger(log), mid.Errors(log, renderer), mid.Metrics(), mid.Panics(),
}
// Include the pre middlewares first.
middlewares := appCtx.PreAppMiddleware
// Append any global middlewares if they were included.
if len(globalMids) > 0 {
middlewares = append(middlewares, globalMids...)
// Define app middlewares applied to all requests.
middlewares = append(middlewares,
mid.Trace(),
mid.Logger(appCtx.Log),
mid.Errors(appCtx.Log, appCtx.Renderer),
mid.Metrics(),
mid.Panics())
// Append any global middlewares that should be included after the app middlewares.
if len(appCtx.PostAppMiddleware) > 0 {
middlewares = append(middlewares, appCtx.PostAppMiddleware...)
}
// Construct the web.App which holds all routes as well as common Middleware.
app := web.NewApp(shutdown, log, env, middlewares...)
app := web.NewApp(shutdown, appCtx.Log, appCtx.Env, middlewares...)
// Build a sitemap.
sm := stm.NewSitemap(1)
sm.SetVerbose(false)
sm.SetDefaultHost(projectRoutes.WebAppUrl(""))
sm.SetDefaultHost(appCtx.ProjectRoute.WebAppUrl(""))
sm.Create()
smLocAddModified := func(loc stm.URL, filename string) {
contentPath := filepath.Join(templateDir, "content", filename)
contentPath := filepath.Join(appCtx.TemplateDir, "content", filename)
file, err := os.Stat(contentPath)
if err != nil {
@ -64,48 +103,48 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
// Register project management pages.
p := Projects{
MasterDB: masterDB,
Redis: redis,
Renderer: renderer,
ProjectRepo: appCtx.ProjectRepo,
Redis: appCtx.Redis,
Renderer: appCtx.Renderer,
}
app.Handle("POST", "/projects/:project_id/update", p.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/projects/:project_id/update", p.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/projects/:project_id", p.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/projects/:project_id", p.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("POST", "/projects/create", p.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/projects/create", p.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/projects", p.Index, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("POST", "/projects/:project_id/update", p.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/projects/:project_id/update", p.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/projects/:project_id", p.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/projects/:project_id", p.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
app.Handle("POST", "/projects/create", p.Create, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/projects/create", p.Create, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/projects", p.Index, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
// Register user management pages.
us := Users{
MasterDB: masterDB,
Redis: redis,
Renderer: renderer,
Authenticator: authenticator,
ProjectRoutes: projectRoutes,
NotifyEmail: notifyEmail,
SecretKey: secretKey,
UserRepo: appCtx.UserRepo,
UserAccountRepo: appCtx.UserAccountRepo,
AuthRepo: appCtx.AuthRepo,
InviteRepo: appCtx.InviteRepo,
GeoRepo: appCtx.GeoRepo,
Redis: appCtx.Redis,
Renderer: appCtx.Renderer,
}
app.Handle("POST", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("POST", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
app.Handle("POST", "/users/invite/:hash", us.InviteAccept)
app.Handle("GET", "/users/invite/:hash", us.InviteAccept)
app.Handle("POST", "/users/invite", us.Invite, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/invite", us.Invite, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/users/create", us.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/create", us.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users", us.Index, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("POST", "/users/invite", us.Invite, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/invite", us.Invite, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/users/create", us.Create, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/create", us.Create, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users", us.Index, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
// Register user management and authentication endpoints.
u := User{
MasterDB: masterDB,
Renderer: renderer,
Authenticator: authenticator,
ProjectRoutes: projectRoutes,
NotifyEmail: notifyEmail,
SecretKey: secretKey,
u := UserRepos{
UserRepo: appCtx.UserRepo,
UserAccountRepo: appCtx.UserAccountRepo,
AccountRepo: appCtx.AccountRepo,
AuthRepo: appCtx.AuthRepo,
GeoRepo: appCtx.GeoRepo,
Renderer: appCtx.Renderer,
}
app.Handle("POST", "/user/login", u.Login)
app.Handle("GET", "/user/login", u.Login)
@ -114,35 +153,39 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
app.Handle("GET", "/user/reset-password/:hash", u.ResetConfirm)
app.Handle("POST", "/user/reset-password", u.ResetPassword)
app.Handle("GET", "/user/reset-password", u.ResetPassword)
app.Handle("POST", "/user/update", u.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("GET", "/user/update", u.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("GET", "/user/account", u.Account, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("GET", "/user/virtual-login/:user_id", u.VirtualLogin, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/user/virtual-login", u.VirtualLogin, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/user/virtual-login", u.VirtualLogin, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/user/virtual-logout", u.VirtualLogout, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("GET", "/user/switch-account/:account_id", u.SwitchAccount, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("POST", "/user/switch-account", u.SwitchAccount, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("GET", "/user/switch-account", u.SwitchAccount, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("POST", "/user", u.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("GET", "/user", u.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("POST", "/user/update", u.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
app.Handle("GET", "/user/update", u.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
app.Handle("GET", "/user/account", u.Account, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
app.Handle("GET", "/user/virtual-login/:user_id", u.VirtualLogin, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/user/virtual-login", u.VirtualLogin, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/user/virtual-login", u.VirtualLogin, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/user/virtual-logout", u.VirtualLogout, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
app.Handle("GET", "/user/switch-account/:account_id", u.SwitchAccount, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
app.Handle("POST", "/user/switch-account", u.SwitchAccount, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
app.Handle("GET", "/user/switch-account", u.SwitchAccount, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
app.Handle("POST", "/user", u.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
app.Handle("GET", "/user", u.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth())
// Register account management endpoints.
acc := Account{
MasterDB: masterDB,
Renderer: renderer,
Authenticator: authenticator,
AccountRepo: appCtx.AccountRepo,
AccountPrefRepo: appCtx.AccountPrefRepo,
AuthRepo: appCtx.AuthRepo,
Authenticator: appCtx.Authenticator,
GeoRepo: appCtx.GeoRepo,
Renderer: appCtx.Renderer,
}
app.Handle("POST", "/account/update", acc.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/account/update", acc.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/account", acc.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/account", acc.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/account/update", acc.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/account/update", acc.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/account", acc.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/account", acc.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin))
// Register user management and authentication endpoints.
// Register signup endpoints.
s := Signup{
MasterDB: masterDB,
Renderer: renderer,
Authenticator: authenticator,
SignupRepo: appCtx.SignupRepo,
AuthRepo: appCtx.AuthRepo,
GeoRepo: appCtx.GeoRepo,
Renderer: appCtx.Renderer,
}
// This route is not authenticated
app.Handle("POST", "/signup", s.Step1)
@ -150,16 +193,16 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
// Register example endpoints.
ex := Examples{
Renderer: renderer,
Renderer: appCtx.Renderer,
}
app.Handle("POST", "/examples/flash-messages", ex.FlashMessages, mid.AuthenticateSessionOptional(authenticator))
app.Handle("GET", "/examples/flash-messages", ex.FlashMessages, mid.AuthenticateSessionOptional(authenticator))
app.Handle("GET", "/examples/images", ex.Images, mid.AuthenticateSessionOptional(authenticator))
app.Handle("POST", "/examples/flash-messages", ex.FlashMessages, mid.AuthenticateSessionOptional(appCtx.Authenticator))
app.Handle("GET", "/examples/flash-messages", ex.FlashMessages, mid.AuthenticateSessionOptional(appCtx.Authenticator))
app.Handle("GET", "/examples/images", ex.Images, mid.AuthenticateSessionOptional(appCtx.Authenticator))
// Register geo
g := Geo{
MasterDB: masterDB,
Redis: redis,
GeoRepo: appCtx.GeoRepo,
Redis: appCtx.Redis,
}
app.Handle("GET", "/geo/regions/autocomplete", g.RegionsAutocomplete)
app.Handle("GET", "/geo/postal_codes/autocomplete", g.PostalCodesAutocomplete)
@ -168,17 +211,16 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
// Register root
r := Root{
MasterDB: masterDB,
Renderer: renderer,
ProjectRoutes: projectRoutes,
Sitemap: sm,
Renderer: appCtx.Renderer,
ProjectRoute: appCtx.ProjectRoute,
Sitemap: sm,
}
app.Handle("GET", "/api", r.SitePage)
app.Handle("GET", "/pricing", r.SitePage)
app.Handle("GET", "/support", r.SitePage)
app.Handle("GET", "/legal/privacy", r.SitePage)
app.Handle("GET", "/legal/terms", r.SitePage)
app.Handle("GET", "/", r.Index, mid.AuthenticateSessionOptional(authenticator))
app.Handle("GET", "/", r.Index, mid.AuthenticateSessionOptional(appCtx.Authenticator))
app.Handle("GET", "/index.html", r.IndexHtml)
app.Handle("GET", "/robots.txt", r.RobotTxt)
app.Handle("GET", "/sitemap.xml", r.SitemapXml)
@ -193,14 +235,14 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
// Register health check endpoint. This route is not authenticated.
check := Check{
MasterDB: masterDB,
Redis: redis,
MasterDB: appCtx.MasterDB,
Redis: appCtx.Redis,
}
app.Handle("GET", "/v1/health", check.Health)
// Handle static files/pages. Render a custom 404 page when file not found.
static := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
err := web.StaticHandler(ctx, w, r, params, staticDir, "")
err := web.StaticHandler(ctx, w, r, params, appCtx.StaticDir, "")
if err != nil {
if os.IsNotExist(err) {
rmsg := fmt.Sprintf("%s %s not found", r.Method, r.RequestURI)
@ -209,7 +251,7 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
err = weberror.NewError(ctx, err, http.StatusInternalServerError)
}
return web.RenderError(ctx, w, r, err, renderer, TmplLayoutBase, TmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
return web.RenderError(ctx, w, r, err, appCtx.Renderer, TmplLayoutBase, TmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
}
return nil

View File

@ -2,6 +2,7 @@ package handlers
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers"
"net/http"
"time"
@ -13,6 +14,7 @@ import (
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"geeks-accelerator/oss/saas-starter-kit/internal/signup"
"geeks-accelerator/oss/saas-starter-kit/internal/user_auth"
"github.com/gorilla/schema"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
@ -20,9 +22,11 @@ import (
// Signup represents the Signup API method handler set.
type Signup struct {
MasterDB *sqlx.DB
Renderer web.Renderer
Authenticator *auth.Authenticator
SignupRepo handlers.SignupRepository
AuthRepo handlers.UserAuthRepository
GeoRepo GeoRepository
MasterDB *sqlx.DB
Renderer web.Renderer
}
// Step1 handles collecting the first detailed needed to create a new account.
@ -52,7 +56,7 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque
}
// Execute the account / user signup.
_, err = signup.Signup(ctx, claims, h.MasterDB, *req, ctxValues.Now)
_, err = h.SignupRepo.Signup(ctx, claims, *req, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
case account.ErrForbidden:
@ -68,7 +72,7 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque
}
// Authenticated the new user.
token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, user_auth.AuthenticateRequest{
token, err := h.AuthRepo.Authenticate(ctx, user_auth.AuthenticateRequest{
Email: req.User.Email,
Password: req.User.Password,
}, time.Hour, ctxValues.Now)
@ -77,7 +81,7 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque
}
// Add the token to the users session.
err = handleSessionToken(ctx, h.MasterDB, w, r, token)
err = handleSessionToken(ctx, w, r, token)
if err != nil {
return false, err
}
@ -107,7 +111,7 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque
data["geonameCountries"] = geonames.ValidGeonameCountries(ctx)
data["countries"], err = geonames.FindCountries(ctx, h.MasterDB, "name", "")
data["countries"], err = h.GeoRepo.FindCountries(ctx, "name", "")
if err != nil {
return err
}

View File

@ -8,17 +8,17 @@ import (
"strings"
"time"
"geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers"
"geeks-accelerator/oss/saas-starter-kit/internal/account"
"geeks-accelerator/oss/saas-starter-kit/internal/geonames"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/notify"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes"
"geeks-accelerator/oss/saas-starter-kit/internal/user"
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
"geeks-accelerator/oss/saas-starter-kit/internal/user_auth"
"github.com/gorilla/schema"
"github.com/gorilla/sessions"
"github.com/jmoiron/sqlx"
@ -26,13 +26,15 @@ import (
)
// User represents the User API method handler set.
type User struct {
MasterDB *sqlx.DB
Renderer web.Renderer
Authenticator *auth.Authenticator
ProjectRoutes project_routes.ProjectRoutes
NotifyEmail notify.Email
SecretKey string
type UserRepos struct {
UserRepo handlers.UserRepository
AuthRepo handlers.UserAuthRepository
UserAccountRepo handlers.UserAccountRepository
AccountRepo handlers.AccountRepository
GeoRepo GeoRepository
MasterDB *sqlx.DB
Renderer web.Renderer
SecretKey string
}
func urlUserVirtualLogin(userID string) string {
@ -46,7 +48,7 @@ type UserLoginRequest struct {
}
// Login handles authenticating a user into the system.
func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
func (h UserRepos) Login(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
ctxValues, err := webcontext.ContextValues(ctx)
if err != nil {
@ -75,7 +77,7 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request
}
// Authenticated the user.
token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, user_auth.AuthenticateRequest{
token, err := h.AuthRepo.Authenticate(ctx, user_auth.AuthenticateRequest{
Email: req.Email,
Password: req.Password,
}, sessionTTL, ctxValues.Now)
@ -97,7 +99,7 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request
}
// Add the token to the users session.
err = handleSessionToken(ctx, h.MasterDB, w, r, token)
err = handleSessionToken(ctx, w, r, token)
if err != nil {
return false, err
}
@ -134,7 +136,7 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request
}
// Logout handles removing authentication for the user.
func (h *User) Logout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
func (h *UserRepos) Logout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
sess := webcontext.ContextSession(ctx)
@ -150,7 +152,7 @@ func (h *User) Logout(ctx context.Context, w http.ResponseWriter, r *http.Reques
}
// ResetPassword allows a user to perform forgot password.
func (h *User) ResetPassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
func (h *UserRepos) ResetPassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
ctxValues, err := webcontext.ContextValues(ctx)
if err != nil {
@ -173,7 +175,7 @@ func (h *User) ResetPassword(ctx context.Context, w http.ResponseWriter, r *http
return err
}
_, err = user.ResetPassword(ctx, h.MasterDB, h.ProjectRoutes.UserResetPassword, h.NotifyEmail, *req, h.SecretKey, ctxValues.Now)
_, err = h.UserRepo.ResetPassword(ctx, *req, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
default:
@ -210,7 +212,7 @@ func (h *User) ResetPassword(ctx context.Context, w http.ResponseWriter, r *http
}
// ResetConfirm handles changing a users password after they have clicked on the link emailed.
func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
func (h *UserRepos) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
resetHash := params["hash"]
@ -238,7 +240,7 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.
// Append the query param value to the request.
req.ResetHash = resetHash
u, err := user.ResetConfirm(ctx, h.MasterDB, *req, h.SecretKey, ctxValues.Now)
u, err := h.UserRepo.ResetConfirm(ctx, *req, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
case user.ErrResetExpired:
@ -257,7 +259,7 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.
}
// Authenticated the user. Probably should use the default session TTL from UserLogin.
token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, user_auth.AuthenticateRequest{
token, err := h.AuthRepo.Authenticate(ctx, user_auth.AuthenticateRequest{
Email: u.Email,
Password: req.Password,
}, time.Hour, ctxValues.Now)
@ -271,7 +273,7 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.
}
// Add the token to the users session.
err = handleSessionToken(ctx, h.MasterDB, w, r, token)
err = handleSessionToken(ctx, w, r, token)
if err != nil {
return false, err
}
@ -318,7 +320,7 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.
}
// View handles displaying the current user profile.
func (h *User) View(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
func (h *UserRepos) View(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
data := make(map[string]interface{})
f := func() error {
@ -328,14 +330,14 @@ func (h *User) View(ctx context.Context, w http.ResponseWriter, r *http.Request,
return err
}
usr, err := user.ReadByID(ctx, claims, h.MasterDB, claims.Subject)
usr, err := h.UserRepo.ReadByID(ctx, claims, claims.Subject)
if err != nil {
return err
}
data["user"] = usr.Response(ctx)
usrAccs, err := user_account.FindByUserID(ctx, claims, h.MasterDB, claims.Subject, false)
usrAccs, err := h.UserAccountRepo.FindByUserID(ctx, claims, claims.Subject, false)
if err != nil {
return err
}
@ -358,7 +360,7 @@ func (h *User) View(ctx context.Context, w http.ResponseWriter, r *http.Request,
}
// Update handles allowing the current user to update their profile.
func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
func (h *UserRepos) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
ctxValues, err := webcontext.ContextValues(ctx)
if err != nil {
@ -388,7 +390,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques
}
req.ID = claims.Subject
err = user.Update(ctx, claims, h.MasterDB, *req, ctxValues.Now)
err = h.UserRepo.Update(ctx, claims, *req, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
default:
@ -409,7 +411,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques
}
pwdReq.ID = claims.Subject
err = user.UpdatePassword(ctx, claims, h.MasterDB, *pwdReq, ctxValues.Now)
err = h.UserRepo.UpdatePassword(ctx, claims, *pwdReq, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
default:
@ -441,7 +443,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques
return nil
}
usr, err := user.ReadByID(ctx, claims, h.MasterDB, claims.Subject)
usr, err := h.UserRepo.ReadByID(ctx, claims, claims.Subject)
if err != nil {
return err
}
@ -455,7 +457,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques
data["user"] = usr.Response(ctx)
data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB)
data["timezones"], err = h.GeoRepo.ListTimezones(ctx)
if err != nil {
return err
}
@ -474,7 +476,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques
}
// Account handles displaying the Account for the current user.
func (h *User) Account(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
func (h *UserRepos) Account(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
data := make(map[string]interface{})
f := func() error {
@ -484,7 +486,7 @@ func (h *User) Account(ctx context.Context, w http.ResponseWriter, r *http.Reque
return err
}
acc, err := account.ReadByID(ctx, claims, h.MasterDB, claims.Audience)
acc, err := h.AccountRepo.ReadByID(ctx, claims, claims.Audience)
if err != nil {
return err
}
@ -501,7 +503,7 @@ func (h *User) Account(ctx context.Context, w http.ResponseWriter, r *http.Reque
}
// VirtualLogin handles switching the scope of the context to another user.
func (h *User) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
func (h *UserRepos) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
ctxValues, err := webcontext.ContextValues(ctx)
if err != nil {
@ -551,7 +553,7 @@ func (h *User) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http.
}
// Perform the account switch.
tkn, err := user_auth.VirtualLogin(ctx, h.MasterDB, h.Authenticator, claims, *req, expires, ctxValues.Now)
tkn, err := h.AuthRepo.VirtualLogin(ctx, claims, *req, expires, ctxValues.Now)
if err != nil {
if verr, ok := weberror.NewValidationError(ctx, err); ok {
data["validationErrors"] = verr.(*weberror.Error)
@ -565,7 +567,7 @@ func (h *User) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http.
sess = webcontext.SessionUpdateAccessToken(sess, tkn.AccessToken)
// Read the account for a flash message.
usr, err := user.ReadByID(ctx, claims, h.MasterDB, tkn.UserID)
usr, err := h.UserRepo.ReadByID(ctx, claims, tkn.UserID)
if err != nil {
return false, err
}
@ -588,7 +590,7 @@ func (h *User) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http.
return nil
}
usrAccs, err := user_account.Find(ctx, claims, h.MasterDB, user_account.UserAccountFindRequest{
usrAccs, err := h.UserAccountRepo.Find(ctx, claims, user_account.UserAccountFindRequest{
Where: "account_id = ?",
Args: []interface{}{claims.Audience},
})
@ -612,7 +614,7 @@ func (h *User) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http.
userPhs = append(userPhs, "?")
}
users, err := user.Find(ctx, claims, h.MasterDB, user.UserFindRequest{
users, err := h.UserRepo.Find(ctx, claims, user.UserFindRequest{
Where: fmt.Sprintf("id IN (%s)",
strings.Join(userPhs, ", ")),
Args: userIDs,
@ -636,7 +638,7 @@ func (h *User) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http.
}
// VirtualLogout handles switching the scope back to the user who initiated the virtual login.
func (h *User) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
func (h *UserRepos) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
ctxValues, err := webcontext.ContextValues(ctx)
if err != nil {
@ -657,7 +659,7 @@ func (h *User) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http
expires = time.Hour
}
tkn, err := user_auth.VirtualLogout(ctx, h.MasterDB, h.Authenticator, claims, expires, ctxValues.Now)
tkn, err := h.AuthRepo.VirtualLogout(ctx, claims, expires, ctxValues.Now)
if err != nil {
return err
}
@ -667,11 +669,11 @@ func (h *User) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http
// Display a success message to verify the user has switched contexts.
if claims.Subject != tkn.UserID && claims.Audience != tkn.AccountID {
usr, err := user.ReadByID(ctx, claims, h.MasterDB, tkn.UserID)
usr, err := h.UserRepo.ReadByID(ctx, claims, tkn.UserID)
if err != nil {
return err
}
acc, err := account.ReadByID(ctx, claims, h.MasterDB, tkn.AccountID)
acc, err := h.AccountRepo.ReadByID(ctx, claims, tkn.AccountID)
if err != nil {
return err
}
@ -680,7 +682,7 @@ func (h *User) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http
fmt.Sprintf("You are now virtually logged back into account %s user %s.",
acc.Response(ctx).Name, usr.Response(ctx).Name))
} else if claims.Audience != tkn.AccountID {
acc, err := account.ReadByID(ctx, claims, h.MasterDB, tkn.AccountID)
acc, err := h.AccountRepo.ReadByID(ctx, claims, tkn.AccountID)
if err != nil {
return err
}
@ -689,7 +691,7 @@ func (h *User) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http
fmt.Sprintf("You are now virtually logged back into account %s.",
acc.Response(ctx).Name))
} else {
usr, err := user.ReadByID(ctx, claims, h.MasterDB, tkn.UserID)
usr, err := h.UserRepo.ReadByID(ctx, claims, tkn.UserID)
if err != nil {
return err
}
@ -710,7 +712,7 @@ func (h *User) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http
}
// VirtualLogin handles switching the scope of the context to another user.
func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
func (h *UserRepos) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
ctxValues, err := webcontext.ContextValues(ctx)
if err != nil {
@ -757,7 +759,7 @@ func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http
}
// Perform the account switch.
tkn, err := user_auth.SwitchAccount(ctx, h.MasterDB, h.Authenticator, claims, *req, expires, ctxValues.Now)
tkn, err := h.AuthRepo.SwitchAccount(ctx, claims, *req, expires, ctxValues.Now)
if err != nil {
if verr, ok := weberror.NewValidationError(ctx, err); ok {
data["validationErrors"] = verr.(*weberror.Error)
@ -771,7 +773,7 @@ func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http
sess = webcontext.SessionUpdateAccessToken(sess, tkn.AccessToken)
// Read the account for a flash message.
acc, err := account.ReadByID(ctx, claims, h.MasterDB, tkn.AccountID)
acc, err := h.AccountRepo.ReadByID(ctx, claims, tkn.AccountID)
if err != nil {
return false, err
}
@ -794,7 +796,7 @@ func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http
return nil
}
accounts, err := account.Find(ctx, claims, h.MasterDB, account.AccountFindRequest{
accounts, err := h.AccountRepo.Find(ctx, claims, account.AccountFindRequest{
Order: []string{"name"},
})
if err != nil {
@ -816,7 +818,7 @@ func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http
}
// handleSessionToken persists the access token to the session for request authentication.
func handleSessionToken(ctx context.Context, db *sqlx.DB, w http.ResponseWriter, r *http.Request, token user_auth.Token) error {
func handleSessionToken(ctx context.Context, w http.ResponseWriter, r *http.Request, token user_auth.Token) error {
if token.AccessToken == "" {
return errors.New("accessToken is required.")
}

View File

@ -3,37 +3,39 @@ package handlers
import (
"context"
"fmt"
"geeks-accelerator/oss/saas-starter-kit/internal/geonames"
"geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers"
"net/http"
"strings"
"time"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/datatable"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/notify"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes"
"geeks-accelerator/oss/saas-starter-kit/internal/user"
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
"geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite"
"geeks-accelerator/oss/saas-starter-kit/internal/user_auth"
"github.com/dustin/go-humanize/english"
"github.com/gorilla/schema"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
"net/http"
"strings"
"time"
)
// Users represents the Users API method handler set.
type Users struct {
MasterDB *sqlx.DB
Redis *redis.Client
Renderer web.Renderer
Authenticator *auth.Authenticator
ProjectRoutes project_routes.ProjectRoutes
NotifyEmail notify.Email
SecretKey string
UserRepo handlers.UserRepository
AccountRepo handlers.AccountRepository
UserAccountRepo handlers.UserAccountRepository
AuthRepo handlers.UserAuthRepository
InviteRepo handlers.UserInviteRepository
GeoRepo GeoRepository
MasterDB *sqlx.DB
Redis *redis.Client
Renderer web.Renderer
}
func urlUsersIndex() string {
@ -100,7 +102,7 @@ func (h *Users) Index(ctx context.Context, w http.ResponseWriter, r *http.Reques
var v datatable.ColumnValue
switch col.Field {
case "id":
v.Value = fmt.Sprintf("%d", q.ID)
v.Value = fmt.Sprintf("%s", q.ID)
case "name":
if strings.TrimSpace(q.Name) == "" {
v.Value = q.Email
@ -144,7 +146,7 @@ func (h *Users) Index(ctx context.Context, w http.ResponseWriter, r *http.Reques
}
loadFunc := func(ctx context.Context, sorting string, fields []datatable.DisplayField) (resp [][]datatable.ColumnValue, err error) {
res, err := user_account.UserFindByAccount(ctx, claims, h.MasterDB, user_account.UserFindByAccountRequest{
res, err := h.UserAccountRepo.UserFindByAccount(ctx, claims, user_account.UserFindByAccountRequest{
AccountID: claims.Audience,
Order: strings.Split(sorting, ","),
})
@ -232,7 +234,7 @@ func (h *Users) Create(ctx context.Context, w http.ResponseWriter, r *http.Reque
}
}
usr, err := user.Create(ctx, claims, h.MasterDB, req.UserCreateRequest, ctxValues.Now)
usr, err := h.UserRepo.Create(ctx, claims, req.UserCreateRequest, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
default:
@ -246,7 +248,7 @@ func (h *Users) Create(ctx context.Context, w http.ResponseWriter, r *http.Reque
}
uaStatus := user_account.UserAccountStatus_Active
_, err = user_account.Create(ctx, claims, h.MasterDB, user_account.UserAccountCreateRequest{
_, err = h.UserAccountRepo.Create(ctx, claims, user_account.UserAccountCreateRequest{
UserID: usr.ID,
AccountID: claims.Audience,
Roles: req.Roles,
@ -282,7 +284,7 @@ func (h *Users) Create(ctx context.Context, w http.ResponseWriter, r *http.Reque
return nil
}
data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB)
data["timezones"], err = h.GeoRepo.ListTimezones(ctx)
if err != nil {
return err
}
@ -327,7 +329,7 @@ func (h *Users) View(ctx context.Context, w http.ResponseWriter, r *http.Request
switch r.PostForm.Get("action") {
case "archive":
err = user.Archive(ctx, claims, h.MasterDB, user.UserArchiveRequest{
err = h.UserRepo.Archive(ctx, claims, user.UserArchiveRequest{
ID: userID,
}, ctxValues.Now)
if err != nil {
@ -352,14 +354,14 @@ func (h *Users) View(ctx context.Context, w http.ResponseWriter, r *http.Request
return nil
}
usr, err := user.ReadByID(ctx, claims, h.MasterDB, userID)
usr, err := h.UserRepo.ReadByID(ctx, claims, userID)
if err != nil {
return err
}
data["user"] = usr.Response(ctx)
usrAccs, err := user_account.FindByUserID(ctx, claims, h.MasterDB, userID, false)
usrAccs, err := h.UserAccountRepo.FindByUserID(ctx, claims, userID, false)
if err != nil {
return err
}
@ -425,7 +427,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque
}
}
err = user.Update(ctx, claims, h.MasterDB, req.UserUpdateRequest, ctxValues.Now)
err = h.UserRepo.Update(ctx, claims, req.UserUpdateRequest, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
default:
@ -439,7 +441,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque
}
if req.Roles != nil {
err = user_account.Update(ctx, claims, h.MasterDB, user_account.UserAccountUpdateRequest{
err = h.UserAccountRepo.Update(ctx, claims, user_account.UserAccountUpdateRequest{
UserID: userID,
AccountID: claims.Audience,
Roles: &req.Roles,
@ -465,7 +467,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque
}
pwdReq.ID = userID
err = user.UpdatePassword(ctx, claims, h.MasterDB, *pwdReq, ctxValues.Now)
err = h.UserRepo.UpdatePassword(ctx, claims, *pwdReq, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
default:
@ -497,12 +499,12 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque
return nil
}
usr, err := user.ReadByID(ctx, claims, h.MasterDB, userID)
usr, err := h.UserRepo.ReadByID(ctx, claims, userID)
if err != nil {
return err
}
usrAcc, err := user_account.Read(ctx, claims, h.MasterDB, user_account.UserAccountReadRequest{
usrAcc, err := h.UserAccountRepo.Read(ctx, claims, user_account.UserAccountReadRequest{
UserID: userID,
AccountID: claims.Audience,
})
@ -520,7 +522,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque
data["user"] = usr.Response(ctx)
data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB)
data["timezones"], err = h.GeoRepo.ListTimezones(ctx)
if err != nil {
return err
}
@ -577,7 +579,7 @@ func (h *Users) Invite(ctx context.Context, w http.ResponseWriter, r *http.Reque
req.UserID = claims.Subject
req.AccountID = claims.Audience
res, err := invite.SendUserInvites(ctx, claims, h.MasterDB, h.ProjectRoutes.UserInviteAccept, h.NotifyEmail, *req, h.SecretKey, ctxValues.Now)
res, err := h.InviteRepo.SendUserInvites(ctx, claims, *req, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
default:
@ -661,7 +663,7 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
// Append the query param value to the request.
req.InviteHash = inviteHash
hash, err := invite.AcceptInviteUser(ctx, h.MasterDB, *req, h.SecretKey, ctxValues.Now)
hash, err := h.InviteRepo.AcceptInviteUser(ctx, *req, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
case invite.ErrInviteExpired:
@ -699,13 +701,13 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
}
// Load the user without any claims applied.
usr, err := user.ReadByID(ctx, auth.Claims{}, h.MasterDB, hash.UserID)
usr, err := h.UserRepo.ReadByID(ctx, auth.Claims{}, hash.UserID)
if err != nil {
return false, err
}
// Authenticated the user. Probably should use the default session TTL from UserLogin.
token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, user_auth.AuthenticateRequest{
token, err := h.AuthRepo.Authenticate(ctx, user_auth.AuthenticateRequest{
Email: usr.Email,
Password: req.Password,
AccountID: hash.AccountID,
@ -720,7 +722,7 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
}
// Add the token to the users session.
err = handleSessionToken(ctx, h.MasterDB, w, r, token)
err = handleSessionToken(ctx, w, r, token)
if err != nil {
return false, err
}
@ -729,9 +731,9 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
return true, web.Redirect(ctx, w, r, "/", http.StatusFound)
}
usrAcc, err := invite.AcceptInvite(ctx, h.MasterDB, invite.AcceptInviteRequest{
usrAcc, err := h.InviteRepo.AcceptInvite(ctx, invite.AcceptInviteRequest{
InviteHash: inviteHash,
}, h.SecretKey, ctxValues.Now)
}, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
@ -776,7 +778,7 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
}
// Read user by ID with no claims.
usr, err := user.ReadByID(ctx, auth.Claims{}, h.MasterDB, usrAcc.UserID)
usr, err := h.UserRepo.ReadByID(ctx, auth.Claims{}, usrAcc.UserID)
if err != nil {
return false, err
}
@ -799,7 +801,7 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
return nil
}
data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB)
data["timezones"], err = h.GeoRepo.ListTimezones(ctx)
if err != nil {
return err
}

View File

@ -6,6 +6,13 @@ import (
"encoding/json"
"expvar"
"fmt"
"geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference"
"geeks-accelerator/oss/saas-starter-kit/internal/geonames"
"geeks-accelerator/oss/saas-starter-kit/internal/project"
"geeks-accelerator/oss/saas-starter-kit/internal/signup"
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
"geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite"
"geeks-accelerator/oss/saas-starter-kit/internal/user_auth"
"html/template"
"log"
"net"
@ -32,8 +39,9 @@ import (
template_renderer "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/template-renderer"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes"
"geeks-accelerator/oss/saas-starter-kit/internal/project_route"
"geeks-accelerator/oss/saas-starter-kit/internal/user"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/ec2metadata"
@ -51,7 +59,6 @@ import (
redistrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
sqlxtrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/jmoiron/sqlx"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
"gopkg.in/gomail.v2"
)
// build is the git version of this program. It is set using build flags in the makefile.
@ -66,10 +73,9 @@ func main() {
// =========================================================================
// Logging
log.SetFlags(log.LstdFlags|log.Lmicroseconds|log.Lshortfile)
log.SetPrefix(service+" : ")
log := log.New(os.Stdout, log.Prefix() , log.Flags())
log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Lshortfile)
log.SetPrefix(service + " : ")
log := log.New(os.Stdout, log.Prefix(), log.Flags())
// =========================================================================
// Configuration
@ -87,27 +93,29 @@ func main() {
DisableHTTP2 bool `default:"false" envconfig:"DISABLE_HTTP2"`
}
Service struct {
Name string `default:"web-app" envconfig:"NAME"`
Project string `default:"" envconfig:"PROJECT"`
BaseUrl string `default:"" envconfig:"BASE_URL" example:"http://example.saasstartupkit.com"`
HostNames []string `envconfig:"HOST_NAMES" example:"www.example.saasstartupkit.com"`
EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"`
TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"`
SharedTemplateDir string `default:"../../resources/templates/shared" envconfig:"SHARED_TEMPLATE_DIR"`
StaticFiles struct {
Name string `default:"web-app" envconfig:"SERVICE_NAME"`
BaseUrl string `default:"" envconfig:"BASE_URL" example:"http://example.saasstartupkit.com"`
HostNames []string `envconfig:"HOST_NAMES" example:"www.example.saasstartupkit.com"`
EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"`
TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"`
StaticFiles struct {
Dir string `default:"./static" envconfig:"STATIC_DIR"`
S3Enabled bool `envconfig:"S3_ENABLED"`
S3Prefix string `default:"public/web_app/static" envconfig:"S3_PREFIX"`
CloudFrontEnabled bool `envconfig:"CLOUDFRONT_ENABLED"`
ImgResizeEnabled bool `envconfig:"IMG_RESIZE_ENABLED"`
}
WebApiBaseUrl string `default:"http://127.0.0.1:3001" envconfig:"WEB_API_BASE_URL" example:"http://api.example.saasstartupkit.com"`
SessionKey string `default:"" envconfig:"SESSION_KEY"`
SessionName string `default:"" envconfig:"SESSION_NAME"`
EmailSender string `default:"test@example.saasstartupkit.com" envconfig:"EMAIL_SENDER"`
DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"`
ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"`
}
Project struct {
Name string `default:"" envconfig:"PROJECT_NAME"`
SharedTemplateDir string `default:"../../resources/templates/shared" envconfig:"SHARED_TEMPLATE_DIR"`
SharedSecretKey string `default:"" envconfig:"SHARED_SECRET_KEY"`
EmailSender string `default:"test@example.saasstartupkit.com" envconfig:"EMAIL_SENDER"`
WebApiBaseUrl string `default:"http://127.0.0.1:3001" envconfig:"WEB_API_BASE_URL" example:"http://api.example.saasstartupkit.com"`
}
Redis struct {
Host string `default:":6379" envconfig:"HOST"`
DB int `default:"1" envconfig:"DB"`
@ -145,12 +153,6 @@ func main() {
UseAwsSecretManager bool `default:"false" envconfig:"USE_AWS_SECRET_MANAGER"`
KeyExpiration time.Duration `default:"3600s" envconfig:"KEY_EXPIRATION"`
}
STMP struct {
Host string `default:"localhost" envconfig:"HOST"`
Port int `default:"25" envconfig:"PORT"`
User string `default:"" envconfig:"USER"`
Pass string `default:"" envconfig:"PASS" json:"-"` // don't print
}
BuildInfo struct {
CiCommitRefName string `envconfig:"CI_COMMIT_REF_NAME"`
CiCommitShortSha string `envconfig:"CI_COMMIT_SHORT_SHA"`
@ -202,10 +204,10 @@ func main() {
// deployments and distributed to each instance of the service running.
if cfg.Aws.SecretsManagerConfigPrefix == "" {
var pts []string
if cfg.Service.Project != "" {
pts = append(pts, cfg.Service.Project)
if cfg.Project.Name != "" {
pts = append(pts, cfg.Project.Name)
}
pts = append(pts, cfg.Env, cfg.Service.Name)
pts = append(pts, cfg.Env)
cfg.Aws.SecretsManagerConfigPrefix = filepath.Join(pts...)
}
@ -293,6 +295,37 @@ func main() {
awsSession = awstrace.WrapSession(awsSession)
}
// =========================================================================
// Shared Secret Key used for encrypting sessions and links.
// Set the secret key if not provided in the config.
if cfg.Project.SharedSecretKey == "" {
// AWS secrets manager ID for storing the session key. This is optional and only will be used
// if a valid AWS session is provided.
secretID := filepath.Join(cfg.Aws.SecretsManagerConfigPrefix, "SharedSecretKey")
// If AWS is enabled, check the Secrets Manager for the session key.
if awsSession != nil {
cfg.Project.SharedSecretKey, err = devops.SecretManagerGetString(awsSession, secretID)
if err != nil && errors.Cause(err) != devops.ErrSecreteNotFound {
log.Fatalf("main : Session : %+v", err)
}
}
// If the session key is still empty, generate a new key.
if cfg.Project.SharedSecretKey == "" {
cfg.Project.SharedSecretKey = string(securecookie.GenerateRandomKey(32))
if awsSession != nil {
err = devops.SecretManagerPutString(awsSession, secretID, cfg.Project.SharedSecretKey)
if err != nil {
log.Fatalf("main : Session : %+v", err)
}
}
}
}
// =========================================================================
// Start Redis
// Ensure the eviction policy on the redis cluster is set correctly.
@ -367,7 +400,8 @@ func main() {
// Notify Email
var notifyEmail notify.Email
if awsSession != nil {
notifyEmail, err = notify.NewEmailAws(awsSession, cfg.Service.SharedTemplateDir, cfg.Service.EmailSender)
// Send emails with AWS SES. Alternative to use SMTP with notify.NewEmailSmtp.
notifyEmail, err = notify.NewEmailAws(awsSession, cfg.Project.SharedTemplateDir, cfg.Project.EmailSender)
if err != nil {
log.Fatalf("main : Notify Email : %+v", err)
}
@ -384,15 +418,7 @@ func main() {
}
}
} else {
d := gomail.Dialer{
Host: cfg.STMP.Host,
Port: cfg.STMP.Port,
Username: cfg.STMP.User,
Password: cfg.STMP.Pass}
notifyEmail, err = notify.NewEmailSmtp(d, cfg.Service.SharedTemplateDir, cfg.Service.EmailSender)
if err != nil {
log.Fatalf("main : Notify Email : %+v", err)
}
notifyEmail = notify.NewEmailDisabled()
}
// =========================================================================
@ -409,12 +435,46 @@ func main() {
}
// =========================================================================
// Load middlewares that need to be configured specific for the service.
// Init repositories and AppContext
var serviceMiddlewares = []web.Middleware{
mid.Translator(webcontext.UniversalTranslator()),
projectRoute, err := project_route.New(cfg.Project.WebApiBaseUrl, cfg.Service.BaseUrl)
if err != nil {
log.Fatalf("main : project routes : %+v", cfg.Service.BaseUrl, err)
}
usrRepo := user.NewRepository(masterDb, projectRoute.UserResetPassword, notifyEmail, cfg.Project.SharedSecretKey)
usrAccRepo := user_account.NewRepository(masterDb)
accRepo := account.NewRepository(masterDb)
geoRepo := geonames.NewRepository(masterDb)
accPrefRepo := account_preference.NewRepository(masterDb)
authRepo := user_auth.NewRepository(masterDb, authenticator, usrRepo, usrAccRepo, accPrefRepo)
signupRepo := signup.NewRepository(masterDb, usrRepo, usrAccRepo, accRepo)
inviteRepo := invite.NewRepository(masterDb, usrRepo, usrAccRepo, accRepo, projectRoute.UserInviteAccept, notifyEmail, cfg.Project.SharedSecretKey)
prjRepo := project.NewRepository(masterDb)
appCtx := &handlers.AppContext{
Log: log,
Env: cfg.Env,
//MasterDB: masterDb,
Redis: redisClient,
TemplateDir: cfg.Service.TemplateDir,
StaticDir: cfg.Service.StaticFiles.Dir,
ProjectRoute: projectRoute,
UserRepo: usrRepo,
UserAccountRepo: usrAccRepo,
AccountRepo: accRepo,
AccountPrefRepo: accPrefRepo,
AuthRepo: authRepo,
GeoRepo: geoRepo,
SignupRepo: signupRepo,
InviteRepo: inviteRepo,
ProjectRepo: prjRepo,
Authenticator: authenticator,
}
// =========================================================================
// Load middlewares that need to be configured specific for the service.
// Init redirect middleware to ensure all requests go to the primary domain contained in the base URL.
if primaryServiceHost != "127.0.0.1" && primaryServiceHost != "localhost" {
redirect := mid.DomainNameRedirect(mid.DomainNameRedirectConfig{
@ -430,52 +490,23 @@ func main() {
DomainName: primaryServiceHost,
HTTPSEnabled: cfg.Service.EnableHTTPS,
})
serviceMiddlewares = append(serviceMiddlewares, redirect)
appCtx.PostAppMiddleware = append(appCtx.PostAppMiddleware, redirect)
}
// Add the translator middleware for localization.
appCtx.PostAppMiddleware = append(appCtx.PostAppMiddleware, mid.Translator(webcontext.UniversalTranslator()))
// Generate the new session store and append it to the global list of middlewares.
// Init session store
if cfg.Service.SessionName == "" {
cfg.Service.SessionName = fmt.Sprintf("%s-session", cfg.Service.Name)
}
// Set the session key if not provided in the config.
if cfg.Service.SessionKey == "" {
// AWS secrets manager ID for storing the session key. This is optional and only will be used
// if a valid AWS session is provided.
secretID := filepath.Join(cfg.Aws.SecretsManagerConfigPrefix, "session")
// If AWS is enabled, check the Secrets Manager for the session key.
if awsSession != nil {
cfg.Service.SessionKey, err = devops.SecretManagerGetString(awsSession, secretID)
if err != nil && errors.Cause(err) != devops.ErrSecreteNotFound {
log.Fatalf("main : Session : %+v", err)
}
}
// If the session key is still empty, generate a new key.
if cfg.Service.SessionKey == "" {
cfg.Service.SessionKey = string(securecookie.GenerateRandomKey(32))
if awsSession != nil {
err = devops.SecretManagerPutString(awsSession, secretID, cfg.Service.SessionKey)
if err != nil {
log.Fatalf("main : Session : %+v", err)
}
}
}
}
// Generate the new session store and append it to the global list of middlewares.
sessionStore := sessions.NewCookieStore([]byte(cfg.Service.SessionKey))
serviceMiddlewares = append(serviceMiddlewares, mid.Session(sessionStore, cfg.Service.SessionName))
sessionStore := sessions.NewCookieStore([]byte(cfg.Project.SharedSecretKey))
appCtx.PostAppMiddleware = append(appCtx.PostAppMiddleware, mid.Session(sessionStore, cfg.Service.SessionName))
// =========================================================================
// URL Formatter
projectRoutes, err := project_routes.New(cfg.Service.WebApiBaseUrl, cfg.Service.BaseUrl)
if err != nil {
log.Fatalf("main : project routes : %+v", cfg.Service.BaseUrl, err)
}
// s3UrlFormatter is a help function used by to convert an s3 key to
// a publicly available image URL.
@ -495,7 +526,7 @@ func main() {
return s3UrlFormatter(p)
}
} else {
staticS3UrlFormatter = projectRoutes.WebAppUrl
staticS3UrlFormatter = projectRoute.WebAppUrl
}
// staticUrlFormatter is a help function used by template functions defined below.
@ -698,7 +729,7 @@ func main() {
return nil
}
usr, err := user.ReadByID(ctx, auth.Claims{}, masterDb, claims.Subject)
usr, err := usrRepo.ReadByID(ctx, auth.Claims{}, claims.Subject)
if err != nil {
return nil
}
@ -733,7 +764,7 @@ func main() {
return nil
}
acc, err := account.ReadByID(ctx, auth.Claims{}, masterDb, claims.Audience)
acc, err := accRepo.ReadByID(ctx, auth.Claims{}, claims.Audience)
if err != nil {
return nil
}
@ -874,7 +905,7 @@ func main() {
enableHotReload := cfg.Env == "dev"
// Template Renderer used to generate HTML response for web experience.
renderer, err := template_renderer.NewTemplateRenderer(cfg.Service.TemplateDir, enableHotReload, gvd, t, eh)
appCtx.Renderer, err = template_renderer.NewTemplateRenderer(cfg.Service.TemplateDir, enableHotReload, gvd, t, eh)
if err != nil {
log.Fatalf("main : Marshalling Config to JSON : %+v", err)
}
@ -926,7 +957,7 @@ func main() {
if cfg.HTTP.Host != "" {
api := http.Server{
Addr: cfg.HTTP.Host,
Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, cfg.Service.SessionKey, notifyEmail, renderer, serviceMiddlewares...),
Handler: handlers.APP(shutdown, appCtx),
ReadTimeout: cfg.HTTP.ReadTimeout,
WriteTimeout: cfg.HTTP.WriteTimeout,
MaxHeaderBytes: 1 << 20,
@ -943,7 +974,7 @@ func main() {
if cfg.HTTPS.Host != "" {
api := http.Server{
Addr: cfg.HTTPS.Host,
Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, cfg.Service.SessionKey, notifyEmail, renderer, serviceMiddlewares...),
Handler: handlers.APP(shutdown, appCtx),
ReadTimeout: cfg.HTTPS.ReadTimeout,
WriteTimeout: cfg.HTTPS.WriteTimeout,
MaxHeaderBytes: 1 << 20,