2019-05-18 18:06:10 -04:00
|
|
|
package handlers
|
|
|
|
|
|
|
|
import (
|
2019-07-31 13:47:30 -08:00
|
|
|
"context"
|
|
|
|
"fmt"
|
2019-05-18 18:06:10 -04:00
|
|
|
"log"
|
|
|
|
"net/http"
|
|
|
|
"os"
|
2019-08-06 17:04:37 -08:00
|
|
|
"path/filepath"
|
|
|
|
"time"
|
2019-05-18 18:06:10 -04:00
|
|
|
|
2019-08-21 18:34:14 -08:00
|
|
|
"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"
|
2019-07-13 12:16:28 -08:00
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/mid"
|
2019-07-31 13:47:30 -08:00
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
|
2019-07-13 12:16:28 -08:00
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
|
2019-07-31 13:47:30 -08:00
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
|
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
|
2019-08-21 19:28:23 -08:00
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/project"
|
2019-08-14 17:59:47 -08:00
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/project_route"
|
2019-08-21 19:28:23 -08:00
|
|
|
"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"
|
2019-08-17 11:03:48 +07:00
|
|
|
|
2019-08-06 17:04:37 -08:00
|
|
|
"github.com/ikeikeikeike/go-sitemap-generator/v2/stm"
|
2019-05-23 14:32:24 -05:00
|
|
|
"github.com/jmoiron/sqlx"
|
2019-07-13 16:32:29 -08:00
|
|
|
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
|
2019-05-18 18:06:10 -04:00
|
|
|
)
|
|
|
|
|
2019-07-31 13:47:30 -08:00
|
|
|
const (
|
2019-08-04 14:48:43 -08:00
|
|
|
TmplLayoutBase = "base.gohtml"
|
2019-08-03 15:02:00 -08:00
|
|
|
tmplLayoutSite = "site.gohtml"
|
2019-08-04 14:48:43 -08:00
|
|
|
TmplContentErrorGeneric = "error-generic.gohtml"
|
2019-07-31 13:47:30 -08:00
|
|
|
)
|
2019-05-18 18:06:10 -04:00
|
|
|
|
2019-08-14 17:59:47 -08:00
|
|
|
type AppContext struct {
|
|
|
|
Log *log.Logger
|
|
|
|
Env webcontext.Env
|
|
|
|
MasterDB *sqlx.DB
|
|
|
|
Redis *redis.Client
|
2019-08-21 18:34:14 -08:00
|
|
|
UserRepo *user.Repository
|
|
|
|
UserAccountRepo *user_account.Repository
|
|
|
|
AccountRepo *account.Repository
|
|
|
|
AccountPrefRepo *account_preference.Repository
|
|
|
|
AuthRepo *user_auth.Repository
|
|
|
|
SignupRepo *signup.Repository
|
|
|
|
InviteRepo *invite.Repository
|
|
|
|
ProjectRepo *project.Repository
|
|
|
|
GeoRepo *geonames.Repository
|
2019-08-14 17:59:47 -08:00
|
|
|
Authenticator *auth.Authenticator
|
|
|
|
StaticDir string
|
|
|
|
TemplateDir string
|
|
|
|
Renderer web.Renderer
|
|
|
|
ProjectRoute project_route.ProjectRoute
|
|
|
|
PreAppMiddleware []web.Middleware
|
|
|
|
PostAppMiddleware []web.Middleware
|
|
|
|
}
|
|
|
|
|
2019-05-18 18:06:10 -04:00
|
|
|
// API returns a handler for a set of routes.
|
2019-08-14 17:59:47 -08:00
|
|
|
func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler {
|
2019-07-12 11:41:41 -08:00
|
|
|
|
2019-08-14 17:59:47 -08:00
|
|
|
// Include the pre middlewares first.
|
|
|
|
middlewares := appCtx.PreAppMiddleware
|
2019-07-12 11:41:41 -08:00
|
|
|
|
2019-08-14 17:59:47 -08:00
|
|
|
// 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...)
|
2019-07-12 11:41:41 -08:00
|
|
|
}
|
2019-05-18 18:06:10 -04:00
|
|
|
|
|
|
|
// Construct the web.App which holds all routes as well as common Middleware.
|
2019-08-14 17:59:47 -08:00
|
|
|
app := web.NewApp(shutdown, appCtx.Log, appCtx.Env, middlewares...)
|
2019-05-18 18:06:10 -04:00
|
|
|
|
2019-08-06 17:04:37 -08:00
|
|
|
// Build a sitemap.
|
|
|
|
sm := stm.NewSitemap(1)
|
2019-08-13 20:26:39 -08:00
|
|
|
sm.SetVerbose(false)
|
2019-08-14 17:59:47 -08:00
|
|
|
sm.SetDefaultHost(appCtx.ProjectRoute.WebAppUrl(""))
|
2019-08-06 17:04:37 -08:00
|
|
|
sm.Create()
|
|
|
|
|
|
|
|
smLocAddModified := func(loc stm.URL, filename string) {
|
2019-08-14 17:59:47 -08:00
|
|
|
contentPath := filepath.Join(appCtx.TemplateDir, "content", filename)
|
2019-08-06 17:04:37 -08:00
|
|
|
|
|
|
|
file, err := os.Stat(contentPath)
|
|
|
|
if err != nil {
|
|
|
|
log.Fatalf("main : Add sitemap file modified for %s: %+v", filename, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
lm := []interface{}{"lastmod", file.ModTime().Format(time.RFC3339)}
|
|
|
|
loc = append(loc, lm)
|
|
|
|
sm.Add(loc)
|
|
|
|
}
|
|
|
|
|
2019-07-31 13:47:30 -08:00
|
|
|
// Register project management pages.
|
|
|
|
p := Projects{
|
2019-08-14 17:59:47 -08:00
|
|
|
ProjectRepo: appCtx.ProjectRepo,
|
|
|
|
Redis: appCtx.Redis,
|
|
|
|
Renderer: appCtx.Renderer,
|
2019-05-18 18:06:10 -04:00
|
|
|
}
|
2019-08-14 17:59:47 -08:00
|
|
|
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())
|
2019-05-18 18:06:10 -04:00
|
|
|
|
2019-08-04 23:24:30 -08:00
|
|
|
// Register user management pages.
|
|
|
|
us := Users{
|
2019-08-14 17:59:47 -08:00
|
|
|
UserRepo: appCtx.UserRepo,
|
|
|
|
UserAccountRepo: appCtx.UserAccountRepo,
|
|
|
|
AuthRepo: appCtx.AuthRepo,
|
|
|
|
InviteRepo: appCtx.InviteRepo,
|
2019-08-17 11:03:48 +07:00
|
|
|
GeoRepo: appCtx.GeoRepo,
|
2019-08-14 17:59:47 -08:00
|
|
|
Redis: appCtx.Redis,
|
|
|
|
Renderer: appCtx.Renderer,
|
2019-08-04 23:24:30 -08:00
|
|
|
}
|
2019-08-14 17:59:47 -08:00
|
|
|
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())
|
2019-08-05 13:27:23 -08:00
|
|
|
app.Handle("POST", "/users/invite/:hash", us.InviteAccept)
|
|
|
|
app.Handle("GET", "/users/invite/:hash", us.InviteAccept)
|
2019-08-14 17:59:47 -08:00
|
|
|
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())
|
2019-08-04 23:27:02 -08:00
|
|
|
|
2019-05-18 18:06:10 -04:00
|
|
|
// Register user management and authentication endpoints.
|
2019-08-17 11:03:48 +07:00
|
|
|
u := UserRepos{
|
2019-08-14 17:59:47 -08:00
|
|
|
UserRepo: appCtx.UserRepo,
|
|
|
|
UserAccountRepo: appCtx.UserAccountRepo,
|
|
|
|
AccountRepo: appCtx.AccountRepo,
|
|
|
|
AuthRepo: appCtx.AuthRepo,
|
2019-08-17 11:03:48 +07:00
|
|
|
GeoRepo: appCtx.GeoRepo,
|
2019-08-14 17:59:47 -08:00
|
|
|
Renderer: appCtx.Renderer,
|
2019-05-18 18:06:10 -04:00
|
|
|
}
|
2019-07-31 13:47:30 -08:00
|
|
|
app.Handle("POST", "/user/login", u.Login)
|
|
|
|
app.Handle("GET", "/user/login", u.Login)
|
|
|
|
app.Handle("GET", "/user/logout", u.Logout)
|
2019-08-02 15:03:32 -08:00
|
|
|
app.Handle("POST", "/user/reset-password/:hash", u.ResetConfirm)
|
|
|
|
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)
|
2019-08-14 17:59:47 -08:00
|
|
|
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())
|
2019-08-04 14:48:43 -08:00
|
|
|
|
|
|
|
// Register account management endpoints.
|
|
|
|
acc := Account{
|
2019-08-14 17:59:47 -08:00
|
|
|
AccountRepo: appCtx.AccountRepo,
|
|
|
|
AccountPrefRepo: appCtx.AccountPrefRepo,
|
|
|
|
AuthRepo: appCtx.AuthRepo,
|
|
|
|
Authenticator: appCtx.Authenticator,
|
2019-08-17 11:03:48 +07:00
|
|
|
GeoRepo: appCtx.GeoRepo,
|
2019-08-14 17:59:47 -08:00
|
|
|
Renderer: appCtx.Renderer,
|
2019-08-04 14:48:43 -08:00
|
|
|
}
|
2019-08-14 17:59:47 -08:00
|
|
|
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))
|
2019-05-18 18:06:10 -04:00
|
|
|
|
2019-08-14 17:59:47 -08:00
|
|
|
// Register signup endpoints.
|
2019-07-31 13:47:30 -08:00
|
|
|
s := Signup{
|
2019-08-14 17:59:47 -08:00
|
|
|
SignupRepo: appCtx.SignupRepo,
|
|
|
|
AuthRepo: appCtx.AuthRepo,
|
2019-08-17 11:03:48 +07:00
|
|
|
GeoRepo: appCtx.GeoRepo,
|
2019-08-14 17:59:47 -08:00
|
|
|
Renderer: appCtx.Renderer,
|
2019-07-31 13:47:30 -08:00
|
|
|
}
|
2019-05-18 18:06:10 -04:00
|
|
|
// This route is not authenticated
|
2019-07-31 13:47:30 -08:00
|
|
|
app.Handle("POST", "/signup", s.Step1)
|
|
|
|
app.Handle("GET", "/signup", s.Step1)
|
2019-05-18 18:06:10 -04:00
|
|
|
|
2019-08-03 15:01:17 -08:00
|
|
|
// Register example endpoints.
|
|
|
|
ex := Examples{
|
2019-08-14 17:59:47 -08:00
|
|
|
Renderer: appCtx.Renderer,
|
2019-08-03 15:01:17 -08:00
|
|
|
}
|
2019-08-14 17:59:47 -08:00
|
|
|
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))
|
2019-08-03 15:01:17 -08:00
|
|
|
|
2019-08-01 11:34:03 -08:00
|
|
|
// Register geo
|
|
|
|
g := Geo{
|
2019-08-17 11:03:48 +07:00
|
|
|
GeoRepo: appCtx.GeoRepo,
|
|
|
|
Redis: appCtx.Redis,
|
2019-08-01 11:34:03 -08:00
|
|
|
}
|
|
|
|
app.Handle("GET", "/geo/regions/autocomplete", g.RegionsAutocomplete)
|
2019-08-01 13:45:38 -08:00
|
|
|
app.Handle("GET", "/geo/postal_codes/autocomplete", g.PostalCodesAutocomplete)
|
|
|
|
app.Handle("GET", "/geo/geonames/postal_code/:postalCode", g.GeonameByPostalCode)
|
|
|
|
app.Handle("GET", "/geo/country/:countryCode/timezones", g.CountryTimezones)
|
2019-08-01 11:34:03 -08:00
|
|
|
|
2019-05-18 18:06:10 -04:00
|
|
|
// Register root
|
|
|
|
r := Root{
|
2019-08-14 17:59:47 -08:00
|
|
|
Renderer: appCtx.Renderer,
|
|
|
|
ProjectRoute: appCtx.ProjectRoute,
|
|
|
|
Sitemap: sm,
|
2019-05-18 18:06:10 -04:00
|
|
|
}
|
2019-08-03 15:02:00 -08:00
|
|
|
app.Handle("GET", "/api", r.SitePage)
|
2019-08-03 17:36:17 -08:00
|
|
|
app.Handle("GET", "/pricing", r.SitePage)
|
2019-08-03 15:02:00 -08:00
|
|
|
app.Handle("GET", "/support", r.SitePage)
|
|
|
|
app.Handle("GET", "/legal/privacy", r.SitePage)
|
|
|
|
app.Handle("GET", "/legal/terms", r.SitePage)
|
2019-08-14 17:59:47 -08:00
|
|
|
app.Handle("GET", "/", r.Index, mid.AuthenticateSessionOptional(appCtx.Authenticator))
|
2019-07-31 18:34:27 -08:00
|
|
|
app.Handle("GET", "/index.html", r.IndexHtml)
|
|
|
|
app.Handle("GET", "/robots.txt", r.RobotTxt)
|
2019-08-06 17:04:37 -08:00
|
|
|
app.Handle("GET", "/sitemap.xml", r.SitemapXml)
|
|
|
|
|
|
|
|
// Add sitemap entries for Root.
|
|
|
|
smLocAddModified(stm.URL{{"loc", "/"}, {"changefreq", "weekly"}, {"mobile", true}, {"priority", 0.9}}, "site-index.gohtml")
|
|
|
|
smLocAddModified(stm.URL{{"loc", "/pricing"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.8}}, "site-pricing.gohtml")
|
|
|
|
smLocAddModified(stm.URL{{"loc", "/support"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.8}}, "site-support.gohtml")
|
|
|
|
smLocAddModified(stm.URL{{"loc", "/api"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.7}}, "site-api.gohtml")
|
|
|
|
smLocAddModified(stm.URL{{"loc", "/legal/privacy"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.5}}, "legal-privacy.gohtml")
|
|
|
|
smLocAddModified(stm.URL{{"loc", "/legal/terms"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.5}}, "legal-terms.gohtml")
|
2019-05-18 18:06:10 -04:00
|
|
|
|
2019-07-31 13:47:30 -08:00
|
|
|
// Register health check endpoint. This route is not authenticated.
|
|
|
|
check := Check{
|
2019-08-14 17:59:47 -08:00
|
|
|
MasterDB: appCtx.MasterDB,
|
|
|
|
Redis: appCtx.Redis,
|
2019-07-31 13:47:30 -08:00
|
|
|
}
|
|
|
|
app.Handle("GET", "/v1/health", check.Health)
|
|
|
|
|
2019-07-31 18:34:27 -08:00
|
|
|
// Handle static files/pages. Render a custom 404 page when file not found.
|
2019-07-31 13:47:30 -08:00
|
|
|
static := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
|
2019-08-14 17:59:47 -08:00
|
|
|
err := web.StaticHandler(ctx, w, r, params, appCtx.StaticDir, "")
|
2019-07-31 13:47:30 -08:00
|
|
|
if err != nil {
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
rmsg := fmt.Sprintf("%s %s not found", r.Method, r.RequestURI)
|
|
|
|
err = weberror.NewErrorMessage(ctx, err, http.StatusNotFound, rmsg)
|
|
|
|
} else {
|
|
|
|
err = weberror.NewError(ctx, err, http.StatusInternalServerError)
|
|
|
|
}
|
|
|
|
|
2019-08-14 17:59:47 -08:00
|
|
|
return web.RenderError(ctx, w, r, err, appCtx.Renderer, TmplLayoutBase, TmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
|
2019-07-31 13:47:30 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2019-05-18 18:06:10 -04:00
|
|
|
// Static file server
|
2019-07-31 13:47:30 -08:00
|
|
|
app.Handle("GET", "/*", static)
|
2019-05-18 18:06:10 -04:00
|
|
|
|
|
|
|
return app
|
|
|
|
}
|