Compare commits

..

47 Commits

Author SHA1 Message Date
32f10ce40c Add password reset service 2024-09-12 07:33:10 -04:00
56ce9fa2f8 Add .env file support 2024-09-01 20:23:31 -04:00
14b3863f8e Add email service for sending emails 2024-09-01 11:56:51 -04:00
c4e5d3575e Add currentUser template func 2024-08-29 23:22:04 -04:00
530672437c Add RequireUser middleware 2024-08-29 07:03:13 -04:00
e32aa9ca6c Use middleware for user session 2024-08-28 22:54:58 -04:00
2bb770cd7f Set up database migrations 2024-08-27 22:00:06 -04:00
40ed60bca6 Improve SQL 2024-08-27 15:46:54 -04:00
69ecae6c26 Fix signin and signup redirect 2024-08-27 15:44:54 -04:00
0fa9037164 Add sign out button 2024-08-22 21:02:09 -04:00
dfde1b8381 Add user sessions 2024-08-21 23:34:10 -04:00
87cae430a3 Buffer template output 2024-08-14 18:54:19 -04:00
a2d09d8e68 Improved error message for improperly set csrf key 2024-08-14 12:49:13 -04:00
a52e76c0da Use template fn errors to fail when the correct fn has not been assigned yet 2024-08-14 12:48:36 -04:00
56f98a9c14 Set cookie to http only 2024-08-13 11:39:19 -04:00
4cf50a7d81 Use a custom template function for csrf protection 2024-08-13 06:58:02 -04:00
8bc58eedbe Add request data to template Execute function 2024-08-12 18:55:57 -04:00
da5eeb3f0f Refactor FromFile constructor 2024-08-12 18:04:22 -04:00
de681c1ac3 Add csrf protection 2024-08-11 20:23:43 -04:00
faf9139d79 Add cookie 2024-08-08 15:44:19 -04:00
951c081680 Add sign in handler 2024-08-08 12:22:56 -04:00
c4b5dcedf9 Add user authentication function 2024-08-07 19:33:41 -04:00
2d53824194 Add Sign in page 2024-08-07 19:09:34 -04:00
7d234c5aad Connect users controller to db 2024-08-07 12:19:32 -04:00
fef066b449 Add user model 2024-08-07 00:15:41 -04:00
1fcbac610f Add query param autofill to form 2024-08-04 08:49:58 -04:00
1156cabe05 Add user signup controller 2024-08-03 07:34:27 -04:00
82b954af2e Add css to signup page 2024-08-01 22:40:41 -04:00
8eb491fa97 Add signup page 2024-08-01 21:31:39 -04:00
5b8e8017ca Add nav bar 2024-08-01 20:50:33 -04:00
45ff8dd334 Add tailwind v2 CSS framework 2024-08-01 19:01:51 -04:00
815c6a689d Add tailwind v2 CSS framework 2024-08-01 18:25:25 -04:00
920e7972af Dynamic FAQ page template 2024-08-01 10:11:56 -04:00
13bf91ba7d Move static template helper func to controllers package 2024-08-01 09:56:00 -04:00
140230d89b Refactor static template helper func 2024-08-01 09:37:59 -04:00
9a59acfd2d Add Lorem ipsum to home template 2024-08-01 01:29:23 -04:00
3b42227b81 Add variadic parameters for template FromFS function 2024-08-01 01:14:34 -04:00
1d6e8a9811 Embed template files in server binary 2024-08-01 00:42:35 -04:00
5adfeefa33 Check for invalid templates at server startup 2024-08-01 00:10:44 -04:00
54313fe21b Add logger middleware 2024-07-31 22:02:36 -04:00
1e61052ec1 Microsoft Windows™ :< 2024-07-31 21:36:48 -04:00
b3dbd23a8e Use template for faq page 2024-07-31 21:18:51 -04:00
8051f053c3 Actually fix contact page template 2024-07-31 21:18:18 -04:00
aac5e45b3f Add executeTemplate function 2024-07-31 21:16:06 -04:00
4e2ad80fdf Fix contact page template 2024-07-31 21:10:56 -04:00
eb6d144e92 Add contact page template 2024-07-31 20:59:17 -04:00
393cc1f3c0 Better error handling 2024-07-31 20:48:49 -04:00
27 changed files with 1710 additions and 33 deletions

13
.env.template Normal file
View File

@@ -0,0 +1,13 @@
# Cryptographic key for generating secure CSRF protection tokens
LENSLOCKED_CSRF_KEY=
# Postgresql DB c nnection settings
# Make sure to enable ssl when deploying to production
LENSLOCKED_DB_STRING="host=localhost port=5432 user=changeme dbname=lenslocked sslmode=disable"
# SMTP Email settings
LENSLOCKED_EMAIL_HOST=
LENSLOCKED_EMAIL_PORT=
LENSLOCKED_EMAIL_USERNAME=
LENSLOCKED_EMAIL_PASSWORD=
LENSLOCKED_EMAIL_FROM=

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
tmp tmp
*.env

71
context/users.go Normal file
View File

@@ -0,0 +1,71 @@
package userctx
import (
"context"
"net/http"
"git.kealoha.me/lks/lenslocked/models"
)
type key string
const userKey key = "User"
func WithUser(ctx context.Context, user *models.User) context.Context {
return context.WithValue(ctx, userKey, user)
}
func User(ctx context.Context) *models.User {
val := ctx.Value(userKey)
user, ok := val.(*models.User)
if !ok {
return nil
}
return user
}
type UserMiddleware struct {
SS *models.SessionService
}
func (umw UserMiddleware) SetUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seshCookie, err := r.Cookie("session")
if err != nil {
next.ServeHTTP(w, r)
return
}
user, err := umw.SS.User(seshCookie.Value)
if err != nil {
next.ServeHTTP(w, r)
return
}
ctx := r.Context()
ctx = WithUser(ctx, user)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
func (umw UserMiddleware) RequireUserfn(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := User(r.Context())
if user == nil {
http.Redirect(w, r, "/signin", http.StatusFound)
return
}
next(w, r)
})
}
func (umw UserMiddleware) RequireUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := User(r.Context())
if user == nil {
http.Redirect(w, r, "/signin", http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}

49
controllers/static.go Normal file
View File

@@ -0,0 +1,49 @@
package controllers
import (
"git.kealoha.me/lks/lenslocked/templates"
"git.kealoha.me/lks/lenslocked/views"
"net/http"
)
type Template interface {
Execute(w http.ResponseWriter, r *http.Request, data interface{})
}
func StaticController(templatePath ...string) http.HandlerFunc {
tpl := views.Must(views.FromFS(templates.FS, templatePath...))
err := tpl.TestTemplate(nil)
if err != nil {
panic(err)
}
return func(w http.ResponseWriter, r *http.Request) { tpl.Execute(w, r, nil) }
}
func FAQ(templatePath ...string) http.HandlerFunc {
questions := []struct {
Question string
Answer string
}{
{
Question: "Is this a real website?",
Answer: "No.",
},
{
Question: "I Can Has Cheezburger?",
Answer: "No.",
},
}
tpl := views.Must(views.FromFS(templates.FS, templatePath...))
err := tpl.TestTemplate(nil)
if err != nil {
panic(err)
}
return func(w http.ResponseWriter, r *http.Request) {
tpl.Execute(w, r, questions)
}
}

259
controllers/users.go Normal file
View File

@@ -0,0 +1,259 @@
package controllers
import (
"fmt"
"net/http"
"net/url"
"time"
userctx "git.kealoha.me/lks/lenslocked/context"
"git.kealoha.me/lks/lenslocked/models"
"git.kealoha.me/lks/lenslocked/templates"
"git.kealoha.me/lks/lenslocked/views"
)
type Users struct {
Templates struct {
Signup Template
Signin Template
ForgotPass Template
ResetUrlSent Template
ResetPass Template
}
UserService *models.UserService
SessionService *models.SessionService
PassResetService *models.PasswordResetService
EmailService *models.EmailService
}
func (u Users) GetSignup(w http.ResponseWriter, r *http.Request) {
var data struct {
Email string
}
data.Email = r.FormValue("email")
u.Templates.Signup.Execute(w, r, data)
}
func (u Users) PostSignup(w http.ResponseWriter, r *http.Request) {
email := r.FormValue("email")
password := r.FormValue("password")
user, err := u.UserService.Create(email, password)
if err != nil {
fmt.Println(err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
session, err := u.SessionService.Create(user.ID)
if err != nil {
fmt.Println(err)
http.Redirect(w, r, "/signin", http.StatusFound)
return
}
cookie := http.Cookie{
Name: "session",
Value: session.Token,
Path: "/",
HttpOnly: true,
}
http.SetCookie(w, &cookie)
http.Redirect(w, r, "/user", http.StatusFound)
}
func (u Users) GetSignin(w http.ResponseWriter, r *http.Request) {
var data struct {
Email string
}
data.Email = r.FormValue("email")
u.Templates.Signin.Execute(w, r, data)
}
func (u Users) PostSignin(w http.ResponseWriter, r *http.Request) {
var data struct {
Email string
Password string
}
data.Email = r.FormValue("email")
data.Password = r.FormValue("password")
user, err := u.UserService.Authenticate(data.Email, data.Password)
if err != nil {
fmt.Println(err)
http.Error(w, "Something went wrong.", http.StatusInternalServerError)
return
}
session, err := u.SessionService.Create(user.ID)
if err != nil {
fmt.Println(err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
cookie := http.Cookie{
Name: "session",
Value: session.Token,
Path: "/",
HttpOnly: true,
}
http.SetCookie(w, &cookie)
fmt.Fprintf(w, "Current user: %s\n", user.Email)
//http.Redirect(w, r, "/user", http.StatusFound)
}
func (u Users) GetSignout(w http.ResponseWriter, r *http.Request) {
sessionCookie, err := r.Cookie("session")
if err != nil {
http.Redirect(w, r, "/signin", http.StatusFound)
return
}
err = u.SessionService.Delete(sessionCookie.Value)
if err != nil {
fmt.Println(err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
c := http.Cookie{
Name: "session",
MaxAge: -1,
}
http.SetCookie(w, &c)
http.Redirect(w, r, "/signin", http.StatusFound)
}
func (u Users) GetForgotPassword(w http.ResponseWriter, r *http.Request) {
var data struct {
Email string
}
data.Email = r.FormValue("email")
u.Templates.ForgotPass.Execute(w, r, data)
}
func (u Users) PostForgotPassword(w http.ResponseWriter, r *http.Request) {
var data struct {
Email string
}
data.Email = r.FormValue("email")
pwReset, err := u.PassResetService.Create(data.Email)
if err != nil {
fmt.Println(err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
vals := url.Values{
"token": {pwReset.Token},
}
// TODO: Make the URL here configurable and use https
resetURL := "http://" + r.Host + "/reset-pw?" + vals.Encode()
fmt.Println(resetURL)
err = u.EmailService.SendPasswordReset(data.Email, resetURL)
if err != nil {
fmt.Println(err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
u.Templates.ResetUrlSent.Execute(w, r, data)
}
func (u Users) GetResetPass(w http.ResponseWriter, r *http.Request) {
var data struct {
Token string
}
data.Token = r.FormValue("token")
u.Templates.ResetPass.Execute(w, r, data)
}
func (u Users) PostResetPass(w http.ResponseWriter, r *http.Request) {
var data struct {
Token, Password string
}
data.Token = r.FormValue("token")
data.Password = r.FormValue("password")
user, err := u.PassResetService.Consume(data.Token)
if err != nil {
fmt.Println(err)
// TODO: Distinguish between server errors and invalid token errors.
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
err = u.UserService.UpdatePassword(user.ID, data.Password)
if err != nil {
fmt.Println(err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Sign the user in now that they have reset their password.
// Any errors from this point onward should redirect to the sign in page.
session, err := u.SessionService.Create(user.ID)
if err != nil {
fmt.Println(err)
http.Redirect(w, r, "/signin", http.StatusFound)
return
}
//setCookie(w, CookieSession, session.Token)
cookie := http.Cookie{
Name: "session",
Value: session.Token,
Path: "/",
HttpOnly: true,
}
http.SetCookie(w, &cookie)
http.Redirect(w, r, "/users/me", http.StatusFound)
}
func (u Users) CurrentUser(w http.ResponseWriter, r *http.Request) {
user := userctx.User(r.Context())
if user == nil {
http.Redirect(w, r, "/signin", http.StatusFound)
return
}
fmt.Fprintf(w, "Current user: %s\n", user.Email)
}
func WithTemplates(user_service *models.UserService, session_service *models.SessionService, email_service *models.EmailService, signup, signin, forgotPass, resetUrlSent, resetPass Template) Users {
u := Users{}
u.Templates.Signup = signup
u.Templates.Signin = signin
u.Templates.ForgotPass = forgotPass
u.Templates.ResetUrlSent = resetUrlSent
u.Templates.ResetPass = resetPass
u.UserService = user_service
u.SessionService = session_service
u.EmailService = email_service
u.PassResetService = &models.PasswordResetService{
DB: u.UserService.DB,
Duration: time.Hour / 2,
}
return u
}
func Default(user_service *models.UserService, session_service *models.SessionService, email_service *models.EmailService) Users {
signup_tpl := views.Must(views.FromFS(templates.FS, "signup.gohtml", "tailwind.gohtml"))
signin_tpl := views.Must(views.FromFS(templates.FS, "signin.gohtml", "tailwind.gohtml"))
pwReset_tpl := views.Must(views.FromFS(templates.FS, "pwReset.gohtml", "tailwind.gohtml"))
pwResetSent_tpl := views.Must(views.FromFS(templates.FS, "pwResetSent.gohtml", "tailwind.gohtml"))
resetPass_tpl := views.Must(views.FromFS(templates.FS, "pwChange.gohtml", "tailwind.gohtml"))
err := signup_tpl.TestTemplate(nil)
if err != nil {
panic(err)
}
err = signin_tpl.TestTemplate(nil)
if err != nil {
panic(err)
}
err = pwReset_tpl.TestTemplate(nil)
if err != nil {
panic(err)
}
err = pwResetSent_tpl.TestTemplate(nil)
if err != nil {
panic(err)
}
return WithTemplates(user_service, session_service, email_service, signup_tpl, signin_tpl, pwReset_tpl, pwResetSent_tpl, resetPass_tpl)
}

27
go.mod
View File

@@ -2,4 +2,29 @@ module git.kealoha.me/lks/lenslocked
go 1.22.5 go 1.22.5
require github.com/go-chi/chi/v5 v5.1.0 // indirect require (
github.com/go-chi/chi/v5 v5.1.0
github.com/gorilla/csrf v1.7.2
github.com/jackc/pgx/v4 v4.18.3
github.com/pressly/goose/v3 v3.21.1
golang.org/x/crypto v0.26.0
)
require (
github.com/go-mail/mail/v2 v2.3.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.14.3 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgtype v1.14.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/sethvargo/go-retry v0.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/text v0.17.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
)

232
go.sum
View File

@@ -1,2 +1,234 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-mail/mail/v2 v2.3.0 h1:wha99yf2v3cpUzD1V9ujP404Jbw2uEvs+rBJybkdYcw=
github.com/go-mail/mail/v2 v2.3.0/go.mod h1:oE2UK8qebZAjjV1ZYUpY7FPnbi/kIU53l1dmqPRb4go=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
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/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
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/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw=
github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA=
github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/pressly/goose/v3 v3.21.1 h1:5SSAKKWej8LVVzNLuT6KIvP1eFDuPvxa+B6H0w78buQ=
github.com/pressly/goose/v3 v3.21.1/go.mod h1:sqthmzV8PitchEkjecFJII//l43dLOCzfWh8pHEe+vE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec=
github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4=
modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

161
main.go
View File

@@ -1,38 +1,80 @@
package main package main
import ( import (
"database/sql"
"fmt" "fmt"
"html/template" "io/fs"
"net/http" "net/http"
"os"
"strconv"
userctx "git.kealoha.me/lks/lenslocked/context"
ctrlrs "git.kealoha.me/lks/lenslocked/controllers"
"git.kealoha.me/lks/lenslocked/migrations"
"git.kealoha.me/lks/lenslocked/models"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/gorilla/csrf"
"github.com/joho/godotenv"
"github.com/pressly/goose/v3"
"github.com/go-chi/chi/v5/middleware"
_ "github.com/jackc/pgx/v4/stdlib"
) )
func homeHandler(w http.ResponseWriter, r *http.Request) { const DEBUG bool = true
w.Header().Set("Content-Type", "text/html; charset=utf8")
tpl, err := template.ParseFiles("templates/home.gohtml") type config struct {
Postgres string
Email struct {
Host string
Port int
Username, Pass, Sender string
}
Csrf struct {
Key []byte
Secure bool
}
Server struct {
Address string
}
}
func loadConfig() (config, error) {
var cfg config
cfg.Csrf.Secure = !DEBUG
err := godotenv.Load()
if err != nil { if err != nil {
panic(err) fmt.Println("Warning: Could not load a .env file")
} }
err = tpl.Execute(w, nil)
cfg.Csrf.Key = []byte(os.Getenv("LENSLOCKED_CSRF_KEY"))
if len(cfg.Csrf.Key) < 32 {
return cfg, fmt.Errorf("Error: no or bad csrf protection key\nPlease set the LENSLOCKED_CSRF_KEY env var to a key at least 32 characters long.")
}
cfg.Postgres = os.Getenv("LENSLOCKED_DB_STRING")
cfg.Email.Host = os.Getenv("LENSLOCKED_EMAIL_HOST")
cfg.Email.Username = os.Getenv("LENSLOCKED_EMAIL_USERNAME")
cfg.Email.Pass = os.Getenv("LENSLOCKED_EMAIL_PASSWORD")
cfg.Email.Sender = os.Getenv("LENSLOCKED_EMAIL_FROM")
cfg.Email.Port, err = strconv.Atoi(os.Getenv("LENSLOCKED_EMAIL_PORT"))
if err != nil { if err != nil {
panic(err) fmt.Println("Warning: Invalid STMP port set in LENSLOCKED_EMAIL_PORT. Using port 587")
cfg.Email.Port = 587
}
cfg.Server.Address = os.Getenv("LENSLOCKED_ADDRESS")
if cfg.Server.Address == "" {
if DEBUG {
cfg.Server.Address = ":3000"
} else {
return cfg, fmt.Errorf("No server address set\nPlease set the LENSLOCKED_ADDRESS env var to the servers address")
} }
} }
func contactHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf8") return cfg, nil
fmt.Fprint(w, "<h1>Contact Page</h1><p>To get in touch, email me at <a href=\"mailto:example@example.com\">example@example.com</a></p>")
}
func faqHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf8")
fmt.Fprint(w, `
<h1>FAQ</h1>
<hr>
<h3>Is this a real website?</h3>
<p>No.</p>
<h3>I Can Has Cheezburger?</h3>
<p>No.</p>
`)
} }
func notFoundHandler(w http.ResponseWriter, r *http.Request) { func notFoundHandler(w http.ResponseWriter, r *http.Request) {
@@ -41,12 +83,73 @@ func notFoundHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "404 page not found") fmt.Fprint(w, "404 page not found")
} }
func main() { func ConnectDB(dbstr string) *sql.DB {
r := chi.NewRouter() db, err := sql.Open("pgx", dbstr)
r.Get("/", homeHandler) if err != nil {
r.Get("/contact", contactHandler) panic(fmt.Sprint("Error connecting to database: %w", err))
r.Get("/faq", faqHandler) }
r.NotFound(notFoundHandler) err = db.Ping()
fmt.Println("Starting the server on :3000...") if err != nil {
http.ListenAndServe(":3000", r) panic(fmt.Sprint("Error connecting to database: %w", err))
}
return db
}
func MigrateDB(db *sql.DB, subfs fs.FS) error {
goose.SetBaseFS(subfs)
defer func() { goose.SetBaseFS(nil) }()
err := goose.SetDialect("postgres")
if err != nil {
return fmt.Errorf("Migrate: %w", err)
}
err = goose.Up(db, ".")
if err != nil {
return fmt.Errorf("Migrate: %w", err)
}
return nil
}
func main() {
cfg, err := loadConfig()
if err != nil {
panic(err)
}
db := ConnectDB(cfg.Postgres)
defer db.Close()
err = MigrateDB(db, migrations.FS)
if err != nil {
panic(err)
}
userService := models.UserService{DB: db}
sessionService := models.SessionService{DB: db}
emailService := models.NewEmailService(cfg.Email.Host, cfg.Email.Port, cfg.Email.Username, cfg.Email.Pass, cfg.Email.Sender)
var usersCtrlr ctrlrs.Users = ctrlrs.Default(&userService, &sessionService, emailService)
umw := userctx.UserMiddleware{SS: &sessionService}
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(csrf.Protect(cfg.Csrf.Key, csrf.Secure(cfg.Csrf.Secure)))
r.Use(umw.SetUser)
r.Get("/", ctrlrs.StaticController("home.gohtml", "tailwind.gohtml"))
r.Get("/contact", ctrlrs.StaticController("contact.gohtml", "tailwind.gohtml"))
r.Get("/faq", ctrlrs.FAQ("faq.gohtml", "tailwind.gohtml"))
r.Get("/signup", usersCtrlr.GetSignup)
r.Post("/signup", usersCtrlr.PostSignup)
r.Get("/signin", usersCtrlr.GetSignin)
r.Post("/signin", usersCtrlr.PostSignin)
r.Post("/signout", usersCtrlr.GetSignout)
r.Get("/forgot-pw", usersCtrlr.GetForgotPassword)
r.Post("/forgot-pw", usersCtrlr.PostForgotPassword)
r.Get("/reset-pw", usersCtrlr.GetResetPass)
r.Post("/reset-pw", usersCtrlr.PostResetPass)
r.Get("/user", umw.RequireUserfn(usersCtrlr.CurrentUser))
r.NotFound(notFoundHandler)
fmt.Printf("Starting the server on %s...\n", cfg.Server.Address)
http.ListenAndServe(cfg.Server.Address, r)
} }

View File

@@ -0,0 +1,13 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE users;
-- +goose StatementEnd

View File

@@ -0,0 +1,13 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE sessions (
id SERIAL PRIMARY KEY,
user_id INT UNIQUE REFERENCES users (id) ON DELETE CASCADE,
token_hash TEXT UNIQUE NOT NULL
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE sessions;
-- +goose StatementEnd

View File

@@ -0,0 +1,14 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE password_resets (
id SERIAL PRIMARY KEY,
user_id INT UNIQUE REFERENCES users (id) ON DELETE CASCADE,
token_hash TEXT UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE password_resets;
-- +goose StatementEnd

6
migrations/fs.go Normal file
View File

@@ -0,0 +1,6 @@
package migrations
import "embed"
//go:embed *.sql
var FS embed.FS

65
models/email.go Normal file
View File

@@ -0,0 +1,65 @@
package models
import (
"fmt"
"github.com/go-mail/mail/v2"
)
type Email struct {
To string
Subject string
Text string
Html string
}
type EmailService struct {
DefaultSender string
dialer *mail.Dialer
}
func NewEmailService(host string, port int, username, pass, sender string) *EmailService {
es := EmailService{
dialer: mail.NewDialer(host, port, username, pass),
DefaultSender: sender,
}
return &es
}
func (es *EmailService) Send(email Email) error {
msg := mail.NewMessage()
msg.SetHeader("To", email.To)
msg.SetHeader("From", es.DefaultSender)
msg.SetHeader("Subject", email.Subject)
if email.Html != "" && email.Text != "" {
msg.SetBody("text/plain", email.Text)
msg.AddAlternative("text/html", email.Html)
} else if email.Text != "" {
msg.SetBody("text/plain", email.Text)
} else if email.Html != "" {
msg.SetBody("text/html", email.Html)
}
err := es.dialer.DialAndSend(msg)
if err != nil {
return fmt.Errorf("send: %w", err)
}
return nil
}
func (es *EmailService) SendPasswordReset(to, resetURL string) error {
email := Email{
Subject: "Reset your password",
To: to,
Text: "To reset your password, please visit the following link: " + resetURL,
Html: `<p>To reset your password, please visit the following link: <a href="` + resetURL + `">` + resetURL + `</a></p>`,
}
err := es.Send(email)
if err != nil {
return fmt.Errorf("forgot password email: %w", err)
}
return nil
}

118
models/password_reset.go Normal file
View File

@@ -0,0 +1,118 @@
package models
import (
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"strings"
"time"
)
const (
resetTokenBytes int = 32
)
func resetToken() (string, error) {
return RandString(resetTokenBytes)
}
func hashToken(token string) string {
tokenHash := sha256.Sum256([]byte(token))
return base64.URLEncoding.EncodeToString(tokenHash[:])
}
type PasswordReset struct {
ID int
UserID int
// Token is only set when a PasswordReset is being created.
Token string
TokenHash string
ExpiresAt time.Time
}
type PasswordResetService struct {
DB *sql.DB
BytesPerToken int
Duration time.Duration
}
func (service *PasswordResetService) delete(id int) error {
_, err := service.DB.Exec(`
DELETE FROM password_resets
WHERE id = $1;`, id)
if err != nil {
return fmt.Errorf("delete: %w", err)
}
return nil
}
func (service *PasswordResetService) Create(email string) (*PasswordReset, error) {
// Verify we have a valid email address for user
email = strings.ToLower(email)
var UserID int
row := service.DB.QueryRow(`
SELECT id FROM users WHERE email = $1;
`, email)
err := row.Scan(&UserID)
if err != nil {
return nil, fmt.Errorf("Create: %w", err)
}
token, err := resetToken()
if err != nil {
return nil, fmt.Errorf("Create: %w", err)
}
duration := service.Duration
if duration == 0 {
duration = time.Hour
}
pwReset := PasswordReset{
UserID: UserID,
Token: token,
TokenHash: hashToken(token),
ExpiresAt: time.Now().Add(duration),
}
row = service.DB.QueryRow(`
INSERT INTO password_resets (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE
SET token_hash = $2, expires_at = $3
RETURNING id;
`, pwReset.UserID, pwReset.TokenHash, pwReset.ExpiresAt)
err = row.Scan(&pwReset.ID)
if err != nil {
return nil, fmt.Errorf("Create: %w", err)
}
return &pwReset, nil
}
// We are going to consume a token and return the user associated with it, or return an error if the token wasn't valid for any reason.
func (service *PasswordResetService) Consume(token string) (*User, error) {
var pwReset PasswordReset
var user User
pwReset.TokenHash = hashToken(token)
row := service.DB.QueryRow(`
SELECT password_resets.id, password_resets.expires_at, users.id, users.email, users.password_hash
FROM password_resets JOIN users ON users.id = password_resets.user_id
WHERE password_resets.token_hash = $1;
`, pwReset.TokenHash)
err := row.Scan(&pwReset.ID, &pwReset.ExpiresAt, &user.ID, &user.Email, &user.PasswordHash)
if err != nil {
return nil, fmt.Errorf("Consume: %w", err)
}
if time.Now().After(pwReset.ExpiresAt) {
return nil, fmt.Errorf("Invalid token")
}
err = service.delete(pwReset.ID)
if err != nil {
return nil, fmt.Errorf("consume: %w", err)
}
return &user, nil
}

105
models/sessions.go Normal file
View File

@@ -0,0 +1,105 @@
package models
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
)
func RandBytes(n int) ([]byte, error) {
b := make([]byte, n)
nRead, err := rand.Read(b)
if err != nil {
return nil, fmt.Errorf("bytes: %w", err)
}
if nRead < n {
return nil, fmt.Errorf("bytes: didn't read enough random bytes")
}
return b, nil
}
func RandString(n int) (string, error) {
b, err := RandBytes(n)
if err != nil {
return "", fmt.Errorf("string: %w", err)
}
return base64.URLEncoding.EncodeToString(b), nil
}
const SessionTokenBytes = 32
func SessionToken() (string, error) {
return RandString(SessionTokenBytes)
}
func hash(token string) string {
tokenHash := sha256.Sum256([]byte(token))
return base64.StdEncoding.EncodeToString(tokenHash[:])
}
type Session struct {
ID int
UserID int
TokenHash string
// Token is only set when creating a new session. When looking up a session
// this will be left empty, as we only store the hash of a session token
// in our database and we cannot reverse it into a raw token.
Token string
}
type SessionService struct {
DB *sql.DB
}
func (ss *SessionService) Create(userID int) (*Session, error) {
token, err := SessionToken()
if err != nil {
return nil, fmt.Errorf("create: %w", err)
}
session := Session{
UserID: userID,
Token: token,
TokenHash: hash(token),
}
row := ss.DB.QueryRow(`
INSERT INTO sessions (user_id, token_hash)
VALUES ($1, $2) ON CONFLICT (user_id) DO
UPDATE
SET token_hash = $2
RETURNING id;
`, session.UserID, session.TokenHash)
err = row.Scan(&session.ID)
if err != nil {
return nil, fmt.Errorf("create: %w", err)
}
return &session, nil
}
func (ss *SessionService) Delete(token string) error {
tokenHash := hash(token)
_, err := ss.DB.Exec(`DELETE FROM sessions WHERE token_hash = $1;`, tokenHash)
if err != nil {
return fmt.Errorf("delete: %w", err)
}
return nil
}
func (ss *SessionService) User(token string) (*User, error) {
token_hash := hash(token)
var user User
row := ss.DB.QueryRow(`
SELECT users.id,
users.email,
users.password_hash
FROM sessions
JOIN users ON users.id = sessions.user_id
WHERE sessions.token_hash = $1;
`, token_hash)
err := row.Scan(&user.ID, &user.Email, &user.PasswordHash)
if err != nil {
return nil, fmt.Errorf("user: %w", err)
}
return &user, err
}

80
models/user.go Normal file
View File

@@ -0,0 +1,80 @@
package models
import (
"database/sql"
"fmt"
"strings"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID int
Email string
PasswordHash string
}
type UserService struct {
DB *sql.DB
}
func (us *UserService) Create(email, password string) (*User, error) {
email = strings.ToLower(email)
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
passwordHash := string(hashedBytes)
user := User{
Email: email,
PasswordHash: passwordHash,
}
row := us.DB.QueryRow(`
INSERT INTO users (email, password_hash)
VALUES ($1, $2) RETURNING id
`, email, passwordHash)
err = row.Scan(&user.ID)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
return &user, nil
}
func (us *UserService) UpdatePassword(userID int, password string) error {
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("update password: %w", err)
}
passwordHash := string(hashedBytes)
_, err = us.DB.Exec(`
UPDATE users
SET password_hash = $2
WHERE id = $1;`, userID, passwordHash)
if err != nil {
return fmt.Errorf("update password: %w", err)
}
return nil
}
func (us UserService) Authenticate(email, password string) (*User, error) {
user := User{
Email: strings.ToLower(email),
}
row := us.DB.QueryRow(`
SELECT id, password_hash
FROM users WHERE email=$1
`, email)
err := row.Scan(&user.ID, &user.PasswordHash)
if err != nil {
return nil, fmt.Errorf("authenticate: %w", err)
}
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
if err != nil {
return nil, fmt.Errorf("authenticate: %w", err)
}
return &user, nil
}

12
templates/contact.gohtml Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html>
{{template "head" .}}
<body>
{{template "header".}}
<main class="px-6">
<h1 class="py-4 text-4xl semibold tracking-tight font-sans">Contact Page</h1>
<p>To get in touch, email me at <a class="underline" href="mailto:example@example.com">example@example.com</a></p>
</main>
{{template "footer" .}}
</body>
</html>

27
templates/faq.gohtml Normal file
View File

@@ -0,0 +1,27 @@
<!doctype html>
<html>
{{template "head" .}}
<body>
{{template "header".}}
<main class="px-6">
<h1 class="py-4 text-4xl semibold tracking-tight">FAQ</h1>
<hr class="pb-4">
<div class="flex flex-col gap-8">
{{range .}}
{{template "qa" .}}
{{end}}
</div>
</main>
{{template "footer" .}}
</body>
</html>
{{define "qa"}}
<div>
<h3 class="block text-lg semibold">{{.Question}}</h3>
<p class="block text-sm ml-2">{{.Answer}}</p>
</div>
{{end}}

6
templates/fs.go Normal file
View File

@@ -0,0 +1,6 @@
package templates
import "embed"
//go:embed *
var FS embed.FS

View File

@@ -1 +1,29 @@
<h1>Welcome to my awesome site!</h1> <!doctype html>
<html>
{{template "head" .}}
<body>
{{template "header".}}
<main class="px-6">
<h1 class="py-4 text-4xl semibold tracking-tight">
Welcome to my awesome site!
</h1>
<div class="flex flex-col gap-4">
{{template "lorem-ipsum"}}
{{template "lorem-ipsum"}}
{{template "lorem-ipsum"}}
</div>
</main>
{{template "footer" .}}
</body>
</html>
{{define "lorem-ipsum"}}
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore
eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt
in culpa qui officia deserunt mollit anim id est laborum.
</p>
{{end}}

98
templates/pwChange.gohtml Normal file
View File

@@ -0,0 +1,98 @@
<!doctype html>
<html>
{{template "head" .}}
<body class="min-h-screen bg-gray-100">
{{template "header".}}
<main class="px-6">
<div class="py-12 flex justify-center">
<div class="px-8 py-8 bg-white rounded shadow">
<h1 class="pt-4 pb-8 text-center text-3xl font-bold text-gray-900">
Reset your password
</h1>
<form action="/reset-pw" method="post">
<div class="hidden">
{{csrfField}}
</div>
<div class="py-2">
<label for="password" class="text-sm font-semibold text-gray-800"
>New password</label
>
<input
name="password"
id="password"
type="password"
placeholder="Password"
required
class="
w-full
px-3
py-2
border border-gray-300
placeholder-gray-500
text-gray-800
rounded
"
autofocus
/>
</div>
{{if .Token}}
<div class="hidden">
<input type="hidden" id="token" name="token" value="{{.Token}}" />
</div>
{{else}}
<div class="py-2">
<label for="token" class="text-sm font-semibold text-gray-800"
>Password Reset Token</label
>
<input
name="token"
id="token"
type="text"
placeholder="Check your email"
required
class="
w-full
px-3
py-2
border border-gray-300
placeholder-gray-500
text-gray-800
rounded
"
/>
</div>
{{end}}
<div class="py-4">
<button
type="submit"
class="
w-full
py-4
px-2
bg-indigo-600
hover:bg-indigo-700
text-white
rounded
font-bold
text-lg
"
>
Update password
</button>
</div>
<div class="py-2 w-full flex justify-between">
<p class="text-xs text-gray-500">
<a href="/signup" class="underline">Sign up</a>
</p>
<p class="text-xs text-gray-500">
<a href="/signin" class="underline">Sign in</a>
</p>
</div>
</form>
</div>
</div>
</main>
{{template "footer" .}}
</body>
</html>

74
templates/pwReset.gohtml Normal file
View File

@@ -0,0 +1,74 @@
<!doctype html>
<html>
{{template "head" .}}
<body class="min-h-screen bg-gray-100">
{{template "header".}}
<main class="px-6">
<div class="py-12 flex justify-center">
<div class="px-8 py-8 bg-white rounded shadow">
<h1 class="pt-4 pb-8 text-center text-3xl font-bold text-gray-900">
Forgot your password?
</h1>
<p class="text-sm text-gray-600 pb-4">No problem. Enter your email address and we'll send you a link to reset your password.</p>
<form action="/forgot-pw" method="post">
<div class="hidden">
{{csrfField}}
</div>
<div class="py-2">
<label for="email" class="text-sm font-semibold text-gray-800"
>Email Address</label
>
<input
name="email"
id="email"
type="email"
placeholder="Email address"
required
autocomplete="email"
class="
w-full
px-3
py-2
border border-gray-300
placeholder-gray-500
text-gray-800
rounded
"
value="{{.Email}}"
autofocus
/>
</div>
<div class="py-4">
<button
type="submit"
class="
w-full
py-4
px-2
bg-indigo-600
hover:bg-indigo-700
text-white
rounded
font-bold
text-lg
"
>
Reset password
</button>
</div>
<div class="py-2 w-full flex justify-between">
<p class="text-xs text-gray-500">
Need an account?
<a href="/signup" class="underline">Sign up</a>
</p>
<p class="text-xs text-gray-500">
<a href="/signin" class="underline">Remember your password?</a>
</p>
</div>
</form>
</div>
</div>
</main>
{{template "footer" .}}
</body>
</html>

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html>
{{template "head" .}}
<body class="min-h-screen bg-gray-100">
{{template "header".}}
<main class="px-6">
<div class="py-12 flex justify-center">
<div class="px-8 py-8 bg-white rounded shadow">
<h1 class="pt-4 pb-8 text-center text-3xl font-bold text-gray-900">
Check your email
</h1>
<p class="text-sm text-gray-600 pb-4">An email has been sent to the email address {{.Email}} with instructions to reset your password.</p>
</div>
</div>
</main>
{{template "footer" .}}
</body>
</html>

65
templates/signin.gohtml Normal file
View File

@@ -0,0 +1,65 @@
<!doctype html>
<html>
{{template "head" .}}
<body class="min-h-screen bg-gray-100">
{{template "header".}}
<main class="py-12 flex justify-center">
<div class="px-8 py-8 bg-white rounded shadow">
<h1 class="pt-4 pb-8 text-center text-3xl font-bold text-gray-900">
Welcome back!
</h1>
<form action="/signin" method="post">
{{csrfField}}
<div class="py-2">
<label for="email" class="text-sm font-semibold text-gray-800">
Email Address
</label>
<input
name="email"
id="email"
type="email"
placeholder="Email address"
required
autocomplete="email"
class="w-full px-3 py-2 border border-gray-300 placeholder-gray-500
text-gray-800 rounded"
value="{{.Email}}"
{{if not .Email}}autofocus{{end}}
/>
</div>
<div class="py-2">
<label for="password" class="text-sm font-semibold text-gray-800">
Password
</label>
<input
name="password"
id="password"
type="password"
placeholder="Password"
required
class="w-full px-3 py-2 border border-gray-300 placeholder-gray-500
text-gray-800 rounded"
{{if .Email}}autofocus{{end}}
/>
</div>
<div class="py-4">
<button class="w-full py-4 px-2 bg-indigo-600 hover:bg-indigo-700
text-white rounded font-bold text-lg">
Sign in
</button>
</div>
<div class="py-2 w-full flex justify-between">
<p class="text-xs text-gray-500">
Need an account?
<a href="/signup" class="underline">Sign up</a>
</p>
<p class="text-xs text-gray-500">
<a href="/forgot-pw" class="underline">Forgot your password?</a>
</p>
</div>
</form>
</div>
</main>
{{template "footer" .}}
</body>
</html>

53
templates/signup.gohtml Normal file
View File

@@ -0,0 +1,53 @@
<!doctype html>
<html>
{{template "head" .}}
<body class="min-h-screen bg-gray-100">
{{template "header".}}
<main class="px-6">
<div class="py-12 flex justify-center">
<div class="px-8 py-8 bg-white rounded shadow">
<h1 class="pt-4 pb-8 text-center text-3xl font-bold text-gray-900">
Sign Up!
</h1>
<form action="/signup" method="post">
{{csrfField}}
<div>
<label for="signupEmail" class="text-sm font-semibold text-gray-800">Email Address</label>
<input name="email" id="signupEmail" type="email" placeholder="Email address" required autocomplete="email"
class="w-full px-3 py-2 boarder boarder-gray-300 placeholder-gray-500 text-gray-800 rounded"
value="{{.Email}}"
{{if not .Email}}autofocus{{end}}
/>
</div>
<div>
<label for="signupPassword" class="text-sm font-semibold text-gray-800">Password</label>
<input name="password" id="signupPassword" type="password" placeholder="password" required
class="w-full px-3 py-2 boarder boarder-gray-300 placeholder-gray-500 text-gray-800 rounded"
{{if .Email}}autofocus{{end}}
/>
</div>
<div>
<label for="referalCode" class="text-sm font-semibold text-gray-800">Referal Code</label>
<input name="referalCode" id="referalCode" type="text" placeholder="Referal Code" required
class="w-full px-3 py-2 boarder boarder-gray-300 placeholder-gray-500 text-gray-800 rounded"
/>
</div>
<div>
<button type="submit" class="w-full py-4 px-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded font-bold text-lg">Sign up</button>
</div>
<div class="py-2 w-full flex justify-between">
<p class="text-xs text-gray-500">
Already have an account?
<a href="/signin" class="underline">Sign in</a>
</p>
<p class="text-xs text-gray-500">
<a href="/forgot-pw" class="underline">Forgot your password?</a>
</p>
</div>
</form>
</div>
</div>
</main>
{{template "footer" .}}
</body>
</html>

37
templates/tailwind.gohtml Normal file
View File

@@ -0,0 +1,37 @@
{{define "head"}}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
</head>
{{end}}
{{define "header"}}
<header class="bg-gradient-to-r from-blue-800 to-indigo-800 text-white">
<nav class="px-6 py-6 flex items-center space-x-12">
<div class="text-2xl font-serif">Lenslocked</div>
<div class="flex-grow">
<a class="text-base font-semibold hover:text-blue-100 pr-8" href="/">Home</a>
<a class="text-base font-semibold hover:text-blue-100 pr-8" href="/contact">Contact</a>
<a class="text-base font-semibold hover:text-blue-100 pr-8" href="/faq">FAQ</a>
</div>
<div class="space-x-4">
{{if currentUser }}
<form action="/signout" method="post" class="inline pr-4">
{{csrfField}}
<button type="submit">Sign out</button>
</form>
{{else}}
<a href="/signin">Sign in</a>
<a href="/signup" clss="px-4 py-2 bg-blue-700 hover:bg-blue-600 rounded">Sign up</a>
{{end}}
</div>
</nav>
</header>
{{end}}
{{define "footer"}}
<br>
<hr>
<p class="text-gray-400 font-light">Copyright your mom 2057</p>
{{end}}

92
views/template.go Normal file
View File

@@ -0,0 +1,92 @@
package views
import (
"bytes"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net/http"
"os"
"strings"
userctx "git.kealoha.me/lks/lenslocked/context"
"git.kealoha.me/lks/lenslocked/models"
"github.com/gorilla/csrf"
)
type Template struct {
htmlTpl *template.Template
}
func (t Template) Execute(w http.ResponseWriter, r *http.Request, data interface{}) {
tpl, err := t.htmlTpl.Clone()
if err != nil {
log.Printf("Template Clone Error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
tpl = tpl.Funcs(template.FuncMap{
"csrfField": func() template.HTML { return csrf.TemplateField(r) },
"currentUser": func() *models.User { return userctx.User(r.Context()) },
})
w.Header().Set("Content-Type", "text/html; charset=utf8")
var buf bytes.Buffer
err = tpl.Execute(&buf, data)
if err != nil {
log.Printf("Error executing template: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
io.Copy(w, &buf)
}
func (t Template) TestTemplate(data interface{}) error {
var testWriter strings.Builder
tpl, err := t.htmlTpl.Clone()
if err != nil {
return err
}
tpl = tpl.Funcs(template.FuncMap{
"csrfField": func() template.HTML {
return `<div class="hidden">STUB: PLACEHOLDER</div>`
},
"currentUser": func() *models.User {
return &models.User{ID: 0, Email: "a@a"}
},
})
return tpl.Execute(&testWriter, data)
}
func FromFile(pattern ...string) (Template, error) {
fs := os.DirFS(".")
return FromFS(fs, pattern...)
}
func FromFS(fs fs.FS, pattern ...string) (Template, error) {
tpl := template.New(pattern[0])
tpl = tpl.Funcs(template.FuncMap{
"csrfField": func() (template.HTML, error) {
return "", fmt.Errorf("csrfField Not Implimented")
},
"currentUser": func() (*models.User, error) {
return nil, fmt.Errorf("currentUser Not Implimented")
},
})
tpl, err := tpl.ParseFS(fs, pattern...)
if err != nil {
return Template{}, fmt.Errorf("Error parsing template: %v", err)
}
return Template{
htmlTpl: tpl,
}, nil
}
func Must(t Template, err error) Template {
if err != nil {
panic(err)
}
return t
}