From ca5691b043ba3dbb4e9da6eb8d76a31fef0f8cbd Mon Sep 17 00:00:00 2001 From: Szabolcs Horvath Date: Thu, 2 Jan 2025 12:51:29 +0100 Subject: [PATCH] Auth0 integration - Add auth endpoints - Sessions - Add ability to remote debug with `make debug` - README cleanup - Remove old auth code - `go mod tidy` - Central `init()` function in the main.go file --- .github/.env | 3 +- .gitignore | 3 - Makefile | 17 +++- README.md | 31 +++++-- go.mod | 9 +- go.sum | 22 +++-- http_server/middleware/middleware.go | 38 ++++----- http_server/routes/auth/routes.go | 119 +++++++++++++++++++++++++++ http_server/routes/root.go | 9 ++ main.go | 47 ++++++++--- util/auth.go | 57 +++++++++++++ web/templates/base.gohtml | 3 + 12 files changed, 304 insertions(+), 54 deletions(-) create mode 100644 http_server/routes/auth/routes.go create mode 100644 http_server/routes/root.go create mode 100644 util/auth.go diff --git a/.github/.env b/.github/.env index 4002c97..6d8a2ad 100644 --- a/.github/.env +++ b/.github/.env @@ -1,2 +1,3 @@ PORT="6969" -DB_FILE="it/nutrition-tracker-test.db" \ No newline at end of file +DB_FILE="it/nutrition-tracker-test.db" +AUTH0_DISABLED="true" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 80ad381..076cf49 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,6 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out -# Dependency directories (remove the comment below to include it) -# vendor/ - # Go workspace file go.work go.work.sum diff --git a/Makefile b/Makefile index 083539a..f7c985e 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,16 @@ SQLITE_DB_FILE ?= sqlite/nutrition-tracker.db IT_SQLITE_DB_FILE ?= it/nutrition-tracker-test.db SQLITE_MIGRATIONS_DIR ?= sqlite/migrations +GOCOVERDIR ?= coverage +CGO_ENABLED=1 # Required for sqlite3 driver + SQLC_VERSION ?= v1.27.0 GOLANG_MIGRATE_VERSION ?= v4.18.1 STRINGER_VERSION ?= v0.28.0 +DELVE_VERSION ?= latest HTMX_VERSION ?= 2.0.3 BOOTSTRAP_VERSION ?= 5.3.3 BOOTSTRAP_ICONS_VERSION ?= 1.11.3 -GOCOVERDIR ?= coverage -CGO_ENABLED=1 # Required for sqlite3 driver install-go-deps: go install -v github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) @@ -21,6 +23,11 @@ init-db: migrate-up build: sqlc generate go build -o out/nutrition-tracker -mod=readonly +debug: sqlc generate + go install -v github.com/go-delve/delve/cmd/dlv@$(DELVE_VERSION) + go build -o out/nutrition-tracker-debug -mod=readonly -gcflags="all=-N -l" + dlv --listen=:443 --headless=true --api-version=2 --accept-multiclient exec ./out/nutrition-tracker-debug + sqlc: sqlc generate @@ -30,6 +37,8 @@ generate: clean: rm -rf generated out + + ut: sqlc rm -f $(GOCOVERDIR)/ut-coverage.out mkdir -p $(GOCOVERDIR) @@ -57,6 +66,8 @@ coverage-ut: ut coverage-it: it go tool cover -html=$(GOCOVERDIR)/it-coverage.out + + create-migration: ifneq ($(MIGRATION_NAME),) migrate create -dir sqlite/migrations -ext .sql $(MIGRATION_NAME) @@ -99,6 +110,7 @@ else endif + download-htmx: cd ./web/static/vendor/htmx; \ curl -O https://unpkg.com/htmx.org@$(HTMX_VERSION)/dist/htmx.min.js; \ @@ -119,4 +131,3 @@ download-bootstrap-icons: rm -rf bootstrap-icons-$(BOOTSTRAP_ICONS_VERSION); \ rm bootstrap-icons-$(BOOTSTRAP_ICONS_VERSION).zip; \ cd -; - diff --git a/README.md b/README.md index 63e349e..574ac8f 100644 --- a/README.md +++ b/README.md @@ -20,19 +20,29 @@ This project's purpose is to help Kinga track and plan her diet. ``` ### Set up -1. #### Initialize the database +1. #### Create a `.env` file with the necessary environment variables + ```shell + PORT="80" + DB_FILE="sqlite/nutrition-tracker.db" + AUTH0_DOMAIN="" + AUTH0_CLIENT_ID="" + AUTH0_CLIENT_SECRET="" + AUTH0_CALLBACK_URL="https://nutrition-tracking.com/auth/callback" # Should be the same as the one set in the Auth0 dashboard + SESSION_KEY="" # Should ideally be at least 64 bytes of random data + ``` +2. #### Initialize the database ```shell make init-db ``` - -2. #### Build the project +3. #### Build the project ```shell make build ``` ## Running +###### (The .env file only needs to be specified if it is not in the project root) ```shell -out/nutrition-tracker +out/nutrition-tracker ``` ## Checking test coverage @@ -49,7 +59,16 @@ make unit-coverage make integration-coverage ``` -## Adding a new migration +## Migrations +###### Create a new migration +```shell +make create-migration MIGRATION_NAME= +``` +###### Apply all migrations +```shell +make migrate-up +``` +###### Rollback the last migration ```shell -make create-migration MIGRATION_NAME= +make migrate-down-1 ``` \ No newline at end of file diff --git a/go.mod b/go.mod index 6c6c134..c45c8e1 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,18 @@ module github.com/szabolcs-horvath/nutrition-tracker go 1.23.1 require ( - github.com/auth0/go-jwt-middleware/v2 v2.2.2 + github.com/coreos/go-oidc/v3 v3.11.0 github.com/deckarep/golang-set/v2 v2.7.0 github.com/google/uuid v1.6.0 + github.com/gorilla/sessions v1.4.0 github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.24 + golang.org/x/oauth2 v0.24.0 ) require ( + github.com/go-jose/go-jose/v4 v4.0.4 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/stretchr/testify v1.10.0 // indirect golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sync v0.10.0 // indirect - gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect ) diff --git a/go.sum b/go.sum index c53de0e..3ca8c58 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,32 @@ -github.com/auth0/go-jwt-middleware/v2 v2.2.2 h1:vrvkFZf72r3Qbt45KLjBG3/6Xq2r3NTixWKu2e8de9I= -github.com/auth0/go-jwt-middleware/v2 v2.2.2/go.mod h1:4vwxpVtu/Kl4c4HskT+gFLjq0dra8F1joxzamrje6J0= +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set/v2 v2.7.0 h1:gIloKvD7yH2oip4VLhsv3JyLLFnC0Y2mlusgcvJYW5k= github.com/deckarep/golang-set/v2 v2.7.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= -gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http_server/middleware/middleware.go b/http_server/middleware/middleware.go index 7f6387a..2ae800a 100644 --- a/http_server/middleware/middleware.go +++ b/http_server/middleware/middleware.go @@ -3,14 +3,11 @@ package middleware import ( "bytes" "context" - "github.com/auth0/go-jwt-middleware/v2" - "github.com/auth0/go-jwt-middleware/v2/jwks" - "github.com/auth0/go-jwt-middleware/v2/validator" "github.com/google/uuid" + "github.com/szabolcs-horvath/nutrition-tracker/util" "io" "log/slog" "net/http" - "net/url" "slices" "time" ) @@ -78,21 +75,24 @@ func LogIncomingRequest(next http.Handler) http.Handler { }) } -func Authenticate(audience string, domain string) Middleware { - return func(next http.Handler) http.Handler { - issuerURL, _ := url.Parse("https://" + domain + "/") - provider := jwks.NewCachingProvider(issuerURL, 5*time.Minute) - - jwtValidator, _ := validator.New( - provider.KeyFunc, - validator.RS256, - issuerURL.String(), - []string{audience}, - ) - - middleware := jwtmiddleware.New(jwtValidator.ValidateToken) - return middleware.CheckJWT(next) - } +func IsAuthenticated(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, err := util.CookieStoreInstance.Get(r, "auth-session") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if session.Values["profile"] == nil { + if r.URL.Path == "/auth/login" || r.URL.Path == "/auth/callback" { + slog.Warn("[IsAuthenticated] session.Values[\"profile\"] is nil", "PATH", r.URL.Path) + } else { + slog.Info("[IsAuthenticated] redirecting to the login path...", "PATH", r.URL.Path) + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) + return + } + } + next.ServeHTTP(w, r) + }) } func LogCompletedRequest(next http.Handler) http.Handler { diff --git a/http_server/routes/auth/routes.go b/http_server/routes/auth/routes.go new file mode 100644 index 0000000..17bc650 --- /dev/null +++ b/http_server/routes/auth/routes.go @@ -0,0 +1,119 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" + "github.com/szabolcs-horvath/nutrition-tracker/util" + "net/http" + "net/url" +) + +const Prefix = "/auth" + +func Routes() map[string]http.HandlerFunc { + return map[string]http.HandlerFunc{ + "GET /login": loginHandler, + "GET /callback": callbackHandler, + "GET /logout": logoutHandler, + } +} + +func loginHandler(w http.ResponseWriter, r *http.Request) { + state, err := generateRandomState() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + session, err := util.CookieStoreInstance.New(r, "auth-session") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + session.Values["state"] = state + if err = session.Save(r, w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, util.AuthenticatorInstance.AuthCodeURL(state), http.StatusTemporaryRedirect) +} + +func callbackHandler(w http.ResponseWriter, r *http.Request) { + token, err := util.AuthenticatorInstance.Exchange(r.Context(), r.FormValue("code")) + if err != nil { + http.Error(w, "Failed to exchange an authorization code for a token.", http.StatusUnauthorized) + return + } + idToken, err := util.AuthenticatorInstance.VerifyIDToken(r.Context(), token) + if err != nil { + http.Error(w, "Failed to verify ID Token.", http.StatusInternalServerError) + return + } + var profile map[string]interface{} + if err = idToken.Claims(&profile); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + session, err := util.CookieStoreInstance.Get(r, "auth-session") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + session.Values["access_token"] = token.AccessToken + session.Values["profile"] = profile + if err = session.Save(r, w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/htmx", http.StatusTemporaryRedirect) +} + +func logoutHandler(w http.ResponseWriter, r *http.Request) { + logoutUrl, err := url.Parse("https://" + util.SafeGetEnv("AUTH0_DOMAIN") + "/v2/logout") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + returnTo, err := url.Parse(scheme + "://" + r.Host) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + parameters := url.Values{} + parameters.Add("returnTo", returnTo.String()) + parameters.Add("client_id", util.SafeGetEnv("AUTH0_CLIENT_ID")) + logoutUrl.RawQuery = parameters.Encode() + + session, err := util.CookieStoreInstance.Get(r, "auth-session") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + clear(session.Values) + if err = session.Save(r, w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, logoutUrl.String(), http.StatusTemporaryRedirect) +} + +func generateRandomState() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + state := base64.StdEncoding.EncodeToString(b) + + return state, nil +} diff --git a/http_server/routes/root.go b/http_server/routes/root.go new file mode 100644 index 0000000..0d0020a --- /dev/null +++ b/http_server/routes/root.go @@ -0,0 +1,9 @@ +package routes + +import ( + "net/http" +) + +func RootHandler(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/htmx", http.StatusTemporaryRedirect) +} diff --git a/main.go b/main.go index 526b898..7698001 100644 --- a/main.go +++ b/main.go @@ -2,10 +2,14 @@ package main import ( "context" + "encoding/gob" + "fmt" + "github.com/gorilla/sessions" "github.com/joho/godotenv" "github.com/szabolcs-horvath/nutrition-tracker/http_server/middleware" "github.com/szabolcs-horvath/nutrition-tracker/http_server/routes" "github.com/szabolcs-horvath/nutrition-tracker/http_server/routes/api" + "github.com/szabolcs-horvath/nutrition-tracker/http_server/routes/auth" "github.com/szabolcs-horvath/nutrition-tracker/http_server/routes/htmx" "github.com/szabolcs-horvath/nutrition-tracker/util" "log/slog" @@ -16,7 +20,7 @@ import ( "time" ) -func getEnvFile() string { +func init() { envFile := ".env" if len(os.Args[1:]) > 0 { if os.Args[1] != "" { @@ -24,27 +28,48 @@ func getEnvFile() string { } } slog.Info("[getEnvFile] Using .env file: " + envFile) - return envFile -} -func main() { - envFile := getEnvFile() if err := godotenv.Load(envFile); err != nil { slog.Error("[main] Failed to load .env file!", "FILE", envFile, "ERROR", err) panic(1) } + if util.SafeGetEnv("AUTH0_DISABLED") != "true" { + authenticator, err := util.NewAuthenticator() + if err != nil { + panic(fmt.Errorf("couldn't initialize the Authenticator instance: %v", err.Error())) + } + util.AuthenticatorInstance = authenticator + + gob.Register(map[string]interface{}{}) + util.CookieStoreInstance = sessions.NewCookieStore([]byte(util.SafeGetEnv("SESSION_KEY"))) + util.CookieStoreInstance.Options = &sessions.Options{ + Path: "/", + Secure: false, + HttpOnly: false, + SameSite: http.SameSiteLaxMode, + } + } +} + +func main() { router := http.NewServeMux() + + router.HandleFunc("/{$}", routes.RootHandler) routes.ServeRoute(router, api.Prefix, api.Routes()) + routes.ServeRouteHandlers(router, auth.Prefix, auth.Routes()) routes.ServeRouteHandlers(router, htmx.Prefix, htmx.Routes()) routes.ServeFS(router, "/static", "web/static/vendor") - middlewareStack := middleware.CreateStack( - middleware.AddRequestId, - middleware.LogIncomingRequest, - //middleware.Authenticate(util.SafeGetEnv("AUTH0_AUDIENCE"), util.SafeGetEnv("AUTH0_DOMAIN")), - middleware.LogCompletedRequest, - ) + middlewares := make([]middleware.Middleware, 0) + middlewares = append(middlewares, middleware.AddRequestId) + middlewares = append(middlewares, middleware.LogIncomingRequest) + if util.SafeGetEnv("AUTH0_DISABLED") != "true" { + middlewares = append(middlewares, middleware.IsAuthenticated) + } + middlewares = append(middlewares, middleware.LogCompletedRequest) + middlewareStack := middleware.CreateStack(middlewares...) + server := http.Server{ Addr: ":" + util.SafeGetEnv("PORT"), Handler: middlewareStack(router), diff --git a/util/auth.go b/util/auth.go new file mode 100644 index 0000000..575779f --- /dev/null +++ b/util/auth.go @@ -0,0 +1,57 @@ +package util + +import ( + "context" + "errors" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/sessions" + "golang.org/x/oauth2" +) + +var AuthenticatorInstance *Authenticator + +var CookieStoreInstance *sessions.CookieStore + +// Authenticator is used to authenticate our users. +type Authenticator struct { + *oidc.Provider + oauth2.Config +} + +// NewAuthenticator instantiates the *Authenticator. +func NewAuthenticator() (*Authenticator, error) { + provider, err := oidc.NewProvider( + context.Background(), + "https://"+SafeGetEnv("AUTH0_DOMAIN")+"/", + ) + if err != nil { + return nil, err + } + + conf := oauth2.Config{ + ClientID: SafeGetEnv("AUTH0_CLIENT_ID"), + ClientSecret: SafeGetEnv("AUTH0_CLIENT_SECRET"), + RedirectURL: SafeGetEnv("AUTH0_CALLBACK_URL"), + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile"}, + } + + return &Authenticator{ + Provider: provider, + Config: conf, + }, nil +} + +// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken. +func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) { + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, errors.New("no id_token field in oauth2 token") + } + + oidcConfig := &oidc.Config{ + ClientID: a.ClientID, + } + + return a.Verifier(oidcConfig).Verify(ctx, rawIDToken) +} diff --git a/web/templates/base.gohtml b/web/templates/base.gohtml index c6f6e40..f19cb5c 100644 --- a/web/templates/base.gohtml +++ b/web/templates/base.gohtml @@ -9,6 +9,9 @@ +
+ +
{{ if eq .TabName "today_tab" }}