1 Commits

Author SHA1 Message Date
720cb0fe2c feat(security): #278 update csp directives
Some checks failed
Build Docker Image / Build-Docker-Image (push) Has been cancelled
2024-11-23 21:50:49 +01:00
61 changed files with 2767 additions and 4430 deletions

View File

@@ -11,5 +11,5 @@ jobs:
steps:
- name: Check out repository code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: docker build . -t web-app-template-test
- run: docker rmi web-app-template-test
- run: docker build . -t me-fit-test
- run: docker rmi me-fit-test

View File

@@ -11,8 +11,8 @@ jobs:
- name: Check out repository code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }}
- run: docker build . -t git.wundenbergs.de/x/web-app-template:latest -t git.wundenbergs.de/x/web-app-template:$GITHUB_SHA
- run: docker push git.wundenbergs.de/x/web-app-template:latest
- run: docker push git.wundenbergs.de/x/web-app-template:$GITHUB_SHA
- run: docker rmi git.wundenbergs.de/x/web-app-template:latest git.wundenbergs.de/x/web-app-template:$GITHUB_SHA
- run: docker build . -t git.wundenbergs.de/x/me-fit:latest -t git.wundenbergs.de/x/me-fit:$GITHUB_SHA
- run: docker push git.wundenbergs.de/x/me-fit:latest
- run: docker push git.wundenbergs.de/x/me-fit:$GITHUB_SHA
- run: docker rmi git.wundenbergs.de/x/me-fit:latest git.wundenbergs.de/x/me-fit:$GITHUB_SHA

View File

@@ -1,13 +1,12 @@
with-expecter: True
dir: mocks/
outpkg: mocks
issue-845-fix: True
packages:
web-app-template/service:
me-fit/service:
interfaces:
Random:
Clock:
Mail:
web-app-template/db:
RandomService:
ClockService:
MailService:
me-fit/db:
interfaces:
Auth:
AuthDb:

View File

@@ -1,32 +1,26 @@
FROM golang:1.24.0 AS builder_go
WORKDIR /web-app-template
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
RUN go install github.com/a-h/templ/cmd/templ@latest
RUN go install github.com/vektra/mockery/v2@latest
FROM golang:1.23.3@sha256:73f06be4578c9987ce560087e2e2ea6485fb605e3910542cadd8fa09fc5f3e31 AS builder_go
WORKDIR /me-fit
RUN go install github.com/a-h/templ/cmd/templ@latest && go install github.com/vektra/mockery/v2@latest
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
RUN templ generate
RUN mockery --log-level warn
RUN go test ./...
RUN golangci-lint run ./...
RUN go build -o /web-app-template/web-app-template .
RUN templ generate && mockery && go test ./... && go build -o /me-fit/me-fit .
FROM node:22.14.0@sha256:f6b9c31ace05502dd98ef777aaa20464362435dcc5e312b0e213121dcf7d8b95 AS builder_node
WORKDIR /web-app-template
FROM node:22.11.0@sha256:5c76d05034644fa8ecc9c2aa84e0a83cd981d0ef13af5455b87b9adf5b216561 AS builder_node
WORKDIR /me-fit
COPY package.json package-lock.json ./
RUN npm clean-install
COPY . ./
RUN npm run build
FROM debian:12.9@sha256:35286826a88dc879b4f438b645ba574a55a14187b483d09213a024dc0c0a64ed
WORKDIR /web-app-template
FROM debian:12.8@sha256:10901ccd8d249047f9761845b4594f121edef079cfd8224edebd9ea726f0a7f6
WORKDIR /me-fit
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
COPY migration ./migration
COPY --from=builder_go /web-app-template/web-app-template ./web-app-template
COPY --from=builder_node /web-app-template/static ./static
COPY --from=builder_go /me-fit/me-fit ./me-fit
COPY --from=builder_node /me-fit/static ./static
EXPOSE 8080
ENTRYPOINT ["/web-app-template/web-app-template"]
ENTRYPOINT ["/me-fit/me-fit"]

View File

@@ -1,98 +1,44 @@
# Web-App-Template
# stackFAST
A basic template with authentication to easily host on a VPC.
Your (almost) independent tech stack to host on a VPC.
## Features
This template includes everything essential to build an app. It includes the following features:
stackFAST includes everything you need to build your App. Focus yourself on developing your idea, instead of "wasting" time on things like setting up auth and observability. This blueprint tries to include as much as possible, but still keep it simple.
- Authentication: Users can login, logout, register and reset their password. (for increased security TOTP is planned aswell.)
- Observability: The stack contains an Grafana+Prometheus instance for basic monitoring. You are able to add alerts and get notified on your phone.
The blueprint contains the following features:
- Authentication: Users can login, logout, register and reset their password. For increased security TOTP is available aswell.
- Observability: The stack contains an Grafana+Prometheus instance for basic monitoring. You are able to add alerts and get notified on your phone. For web analytics umami is included, which is an lighweight self hosted alternative to google analytics.
- Mail: You are able to send mail with SMTP. You still need an external Mail Server, but a guide on how to set that up with a custom domain is included.
- SSL: This is included by using traefik as reverse proxy. It handles SSL certificates automatically. Furthermore all services are accessible through subdomains.
- Stack: Tailwindcss + HTMX + GO Backend with templ and sqlite
- SSL: This is included by using traefik as reverse proxy. It handles SSL certificates automatically. Furthermore all services are accessible through subdomains. Best thing is, you can add your more with 3 lines of code
- Actual Stack: SSG SvelteKit + Tailwindcss + DaisyUI + GO Backend for easy and fast feature development
## Architecture Design Decisions
### Authentication
Authentication is a broad topic. Many people think you should not consider implementing authentication yourself. On the other hand, If only security experts are allowed to write software, what does that result in? I'm going to explain my criterions and afterwards take a decision.
Authentication is a broad topic. Many people think you should not consider implementing authentication yourself. On the other hand, experts at OWASP don't recommend this in their cheat sheet on that topic. I'm going to explain my criterions and afterwards take a decision.
There are a few restrictions I would like to contain:
- I want this template do as much as as possible without relying on external services. This way the setup cost and dependencies can be minimized.
- It should still be possible to run on a small VPC (2vcpu, 2GB).
- I want this blueprint do as much as as possible without relying on external services. This way the things needs to be done on other website are very minimal. Furthermore I would like to take back privacy from BigTech.
- I think most cloud services are overpriced. I want to provide an alternative approach with self holsting. But I don't like the idea to spin up 30 services for a small app with 0 users. It should still be possible to run on a small VPC (2vcpu, 2GB).
- It should be as secure as possible
I determined 4 options:
1. Implement the authentication myself
2. Using OAuth2 with Keycloak
3. Using OAuth2 with Google and Apple
4. Firebase, Clerk, etc.
As of 2024 there are 4 options:
- Implement the authentication myself: If I'm holding thight to the cheat sheet, I "should" be able to doge "most" security risks and attacks according to this topic. Unfortanatly I'm not an expert in this field and will do some errors. If people will buy this blueprint, I probably can't sleep well. Especially if real users start using it. At least this has the advantage of not adding adittional services or configuration to the project.
- Using OAuth2 with Google and Apple: Using OAuth2 is the standard for secure applications. Google and Apple has their experts. They deal with attacks every hour of the day. This has the advantage, that users don't have to create new credentials. The only disatvantage is my personal hate on big tech.
- Using OAuth2 with Keycloak: Same as above, just that the OAuth2 endpoint is another self hosted service. The only advantage is, it's not proprietary and self hosted. But users are not used to get redirected to a key cloak on sign up. They are used to sign in with Google though. Furthermore Google et. al are protecting themselves against credential stuffing attacks etc.
- Firebase, Clerk, etc.: Users have to sign up again AND blueprint users have to setup another project.
#### 1. Implement the authentication myself
It's always possible to implement it myself. The topic of authentication is something special though.
Pros:
- Great Cheat cheets from OWASP
- No adittional configuration or services needed
- Great learning experience on the topic "security"
Cons:
- Great attack vector
- Introcution of vlunerabillities is possible
- No DDOS protection
#### 2. Using OAuth2 with Google and Apple
Instead of implementing authentication from scratch, an external OAuth2 provider is embedded into the application.
Pros:
- The Systems of BigTech are probably safer. They have security experts employed.
- The other external system is responsible to prevent credential stuffing attacks, etc.
- Users don't have to create new credentials
Cons:
- High dependency on those providers
- Single Point of failure (If your account is banned, your application access get's lost as well.)
- It's possible that these providers ban the whole application (All users lose access)
- There still needs to be implemented some logic
- Full application integration can be difficult
#### 3. Using OAuth2 with Keycloak
This option is almost identical with the previois one, but the provider is self hosted.
Pros:
- Indipendent from 3rd party providers
- The credentials are stored safly
Cons:
- Self hosted (no DDOS protection, etc.)
- There still needs to be implemented some logic server side
- Full application integration can be difficult
#### 4. Firebase, Clerk, etc.
Users can sign in with a seperate sdk on your website
Pros:
- Safe and Sound authentication
Cons:
- Dependent on those providers / adittional setup needed
- Application can be banned
- Still some integration code needed
#### Decision
I've decided on implementing authentication myself, as this is a great learning opportunity. It may not be as secure as other solutions, but if I keep tighly to the OWASP recommendations, it should should good enough.
Even though I would really implement authentication myself, I think OAuth2 with external providers is the best bet. Especially because my reasoning is privacy, which most people just don't care about enough. Using this approach, adding in a keycloak is possible without breaking changes at a later point, as long as I keep the Google Sign In.
### Email
For Email verification, etc. a mail server is needed, that can send a whole lot of mails. Aditionally, a mail account is needed for incoming emails. I thought about self hosting, but unfortunatly this is a hastle to maintain. Not only you have to setup a mail server, which is not as easy as it sounds, you also have to "register" your mail server for diffrent providers. Otherwise you are not able to send and receive emails.
For Email verification, etc. a mail server is needed, that can send a whole lot of mails. Aditionally, a mail account is needed for incoming mails. I thought about self hosting, but unfortunatly this is a hastle to maintain. Not only you have to setup a mail server, which is not as easy as it sounds, you also have to "register" your mail server for diffrent providers. Otherwise you are not able to send and receive emails. Thus, the first external service is needed.
In order to not vendor lock in, I decided to use an SMTP relay in favor of a vendor specific API. I chose brevo.com. They have a generous free tier of 300 mails per day. You can either upgrade to a monthly plan 10$ for 20k mails or buy credits for 30$ for 5k mails.
In order to not vendor lock in, I decided to use an SMTP relay in favor of a vendor specific API. You are free to choose a transactional mail provider. I chose brevo.com. They have a generous free tier of 300 mails per day. You can either upgrade to a monthly plan 10$ for 20k mails or buy credits for 30$ for 5k mails. Most provider provide 100 mails / day for free.

View File

@@ -1,8 +1,8 @@
package db
import (
"web-app-template/log"
"web-app-template/types"
"me-fit/types"
"me-fit/utils"
"database/sql"
"errors"
@@ -13,72 +13,92 @@ import (
)
var (
ErrNotFound = errors.New("value not found")
ErrAlreadyExists = errors.New("row already exists")
ErrUserNotFound = errors.New("User not found")
ErrUserExists = errors.New("User already exists")
ErrSessionNotFound = errors.New("Session not found")
)
type Auth interface {
InsertUser(user *types.User) error
UpdateUser(user *types.User) error
GetUserByEmail(email string) (*types.User, error)
GetUser(userId uuid.UUID) (*types.User, error)
DeleteUser(userId uuid.UUID) error
InsertToken(token *types.Token) error
GetToken(token string) (*types.Token, error)
GetTokensByUserIdAndType(userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error)
GetTokensBySessionIdAndType(sessionId string, tokenType types.TokenType) ([]*types.Token, error)
DeleteToken(token string) error
InsertSession(session *types.Session) error
GetSession(sessionId string) (*types.Session, error)
GetSessions(userId uuid.UUID) ([]*types.Session, error)
DeleteSession(sessionId string) error
DeleteOldSessions(userId uuid.UUID) error
type User struct {
Id uuid.UUID
Email string
EmailVerified bool
EmailVerifiedAt *time.Time
IsAdmin bool
Password []byte
Salt []byte
CreateAt time.Time
}
type AuthSqlite struct {
func NewUser(id uuid.UUID, email string, emailVerified bool, emailVerifiedAt *time.Time, isAdmin bool, password []byte, salt []byte, createAt time.Time) *User {
return &User{
Id: id,
Email: email,
EmailVerified: emailVerified,
EmailVerifiedAt: emailVerifiedAt,
IsAdmin: isAdmin,
Password: password,
Salt: salt,
CreateAt: createAt,
}
}
type Session struct {
Id string
UserId uuid.UUID
CreatedAt time.Time
}
func NewSession(id string, userId uuid.UUID, createdAt time.Time) *Session {
return &Session{
Id: id,
UserId: userId,
CreatedAt: createdAt,
}
}
type AuthDb interface {
InsertUser(user *User) error
GetUser(email string) (*User, error)
GetUserById(userId uuid.UUID) (*User, error)
DeleteUser(userId uuid.UUID) error
UpdateUserPassword(userId uuid.UUID, newHash []byte) error
InsertEmailVerificationToken(userId uuid.UUID, token string) error
GetEmailVerificationToken(userId uuid.UUID) (string, error)
InsertSession(session *Session) error
GetSession(sessionId string) (*Session, error)
DeleteOldSessions(userId uuid.UUID) error
DeleteSession(sessionId string) error
}
type AuthDbSqlite struct {
db *sql.DB
}
func NewAuthSqlite(db *sql.DB) *AuthSqlite {
return &AuthSqlite{db: db}
func NewAuthDbSqlite(db *sql.DB) *AuthDbSqlite {
return &AuthDbSqlite{db: db}
}
func (db AuthSqlite) InsertUser(user *types.User) error {
func (db AuthDbSqlite) InsertUser(user *User) error {
_, err := db.db.Exec(`
INSERT INTO user (user_id, email, email_verified, email_verified_at, is_admin, password, salt, created_at)
INSERT INTO user (user_uuid, email, email_verified, email_verified_at, is_admin, password, salt, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
user.Id, user.Email, user.EmailVerified, user.EmailVerifiedAt, user.IsAdmin, user.Password, user.Salt, user.CreateAt)
if err != nil {
if strings.Contains(err.Error(), "email") {
return ErrAlreadyExists
return ErrUserExists
}
log.Error("SQL error InsertUser: %v", err)
utils.LogError("SQL error InsertUser", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) UpdateUser(user *types.User) error {
_, err := db.db.Exec(`
UPDATE user
SET email_verified = ?, email_verified_at = ?, password = ?
WHERE user_id = ?`,
user.EmailVerified, user.EmailVerifiedAt, user.Password, user.Id)
if err != nil {
log.Error("SQL error UpdateUser: %v", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) GetUserByEmail(email string) (*types.User, error) {
func (db AuthDbSqlite) GetUser(email string) (*User, error) {
var (
userId uuid.UUID
emailVerified bool
@@ -90,22 +110,22 @@ func (db AuthSqlite) GetUserByEmail(email string) (*types.User, error) {
)
err := db.db.QueryRow(`
SELECT user_id, email_verified, email_verified_at, password, salt, created_at
SELECT user_uuid, email_verified, email_verified_at, password, salt, created_at
FROM user
WHERE email = ?`, email).Scan(&userId, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrNotFound
return nil, ErrUserNotFound
} else {
log.Error("SQL error GetUser: %v", err)
utils.LogError("SQL error GetUser", err)
return nil, types.ErrInternal
}
}
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
return NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
}
func (db AuthSqlite) GetUser(userId uuid.UUID) (*types.User, error) {
func (db AuthDbSqlite) GetUserById(userId uuid.UUID) (*User, error) {
var (
email string
emailVerified bool
@@ -119,287 +139,153 @@ func (db AuthSqlite) GetUser(userId uuid.UUID) (*types.User, error) {
err := db.db.QueryRow(`
SELECT email, email_verified, email_verified_at, password, salt, created_at
FROM user
WHERE user_id = ?`, userId).Scan(&email, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
WHERE user_uuid = ?`, userId).Scan(&email, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrNotFound
return nil, ErrUserNotFound
} else {
log.Error("SQL error GetUser %v", err)
utils.LogError("SQL error GetUser", err)
return nil, types.ErrInternal
}
}
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
return NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
}
func (db AuthSqlite) DeleteUser(userId uuid.UUID) error {
func (db AuthDbSqlite) DeleteUser(userId uuid.UUID) error {
tx, err := db.db.Begin()
if err != nil {
log.Error("Could not start transaction: %v", err)
utils.LogError("Could not start transaction", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM workout WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
log.Error("Could not delete workouts: %v", err)
tx.Rollback()
utils.LogError("Could not delete workouts", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM token WHERE user_id = ?", userId)
_, err = tx.Exec("DELETE FROM user_token WHERE user_uuid = ?", userId)
if err != nil {
_ = tx.Rollback()
log.Error("Could not delete user tokens: %v", err)
tx.Rollback()
utils.LogError("Could not delete user tokens", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM session WHERE user_id = ?", userId)
_, err = tx.Exec("DELETE FROM session WHERE user_uuid = ?", userId)
if err != nil {
_ = tx.Rollback()
log.Error("Could not delete sessions: %v", err)
tx.Rollback()
utils.LogError("Could not delete sessions", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM user WHERE user_id = ?", userId)
_, err = tx.Exec("DELETE FROM user WHERE user_uuid = ?", userId)
if err != nil {
_ = tx.Rollback()
log.Error("Could not delete user: %v", err)
tx.Rollback()
utils.LogError("Could not delete user", err)
return types.ErrInternal
}
err = tx.Commit()
if err != nil {
log.Error("Could not commit transaction: %v", err)
utils.LogError("Could not commit transaction", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) InsertToken(token *types.Token) error {
func (db AuthDbSqlite) UpdateUserPassword(userId uuid.UUID, newHash []byte) error {
_, err := db.db.Exec("UPDATE user SET password = ? WHERE user_uuid = ?", newHash, userId)
if err != nil {
utils.LogError("Could not update password", err)
return types.ErrInternal
}
return nil
}
func (db AuthDbSqlite) InsertEmailVerificationToken(userId uuid.UUID, token string) error {
_, err := db.db.Exec(`
INSERT INTO token (user_id, session_id, type, token, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`, token.UserId, token.SessionId, token.Type, token.Token, token.CreatedAt, token.ExpiresAt)
INSERT INTO user_token (user_uuid, type, token, created_at)
VALUES (?, 'email_verify', ?, datetime())`, userId, token)
if err != nil {
log.Error("Could not insert token: %v", err)
utils.LogError("Could not insert token", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) GetToken(token string) (*types.Token, error) {
var (
userId uuid.UUID
sessionId string
tokenType types.TokenType
createdAtStr string
expiresAtStr string
createdAt time.Time
expiresAt time.Time
)
func (db AuthDbSqlite) GetEmailVerificationToken(userId uuid.UUID) (string, error) {
var token string
err := db.db.QueryRow(`
SELECT user_id, session_id, type, created_at, expires_at
FROM token
WHERE token = ?`, token).Scan(&userId, &sessionId, &tokenType, &createdAtStr, &expiresAtStr)
SELECT token
FROM user_token
WHERE user_uuid = ?
AND type = 'email_verify'`, userId).Scan(&token)
if err != nil {
if err == sql.ErrNoRows {
log.Info("Token '%v' not found", token)
return nil, ErrNotFound
} else {
log.Error("Could not get token: %v", err)
return nil, types.ErrInternal
}
if err != nil && err != sql.ErrNoRows {
utils.LogError("Could not get token", err)
return "", types.ErrInternal
}
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
if err != nil {
log.Error("Could not parse token.created_at: %v", err)
return nil, types.ErrInternal
}
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
if err != nil {
log.Error("Could not parse token.expires_at: %v", err)
return nil, types.ErrInternal
}
return types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt), nil
return token, nil
}
func (db AuthSqlite) GetTokensByUserIdAndType(userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error) {
query, err := db.db.Query(`
SELECT token, created_at, expires_at
FROM token
WHERE user_id = ?
AND type = ?`, userId, tokenType)
if err != nil {
log.Error("Could not get token: %v", err)
return nil, types.ErrInternal
}
return getTokensFromQuery(query, userId, "", tokenType)
}
func (db AuthSqlite) GetTokensBySessionIdAndType(sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
query, err := db.db.Query(`
SELECT token, created_at, expires_at
FROM token
WHERE session_id = ?
AND type = ?`, sessionId, tokenType)
if err != nil {
log.Error("Could not get token: %v", err)
return nil, types.ErrInternal
}
return getTokensFromQuery(query, uuid.Nil, sessionId, tokenType)
}
func getTokensFromQuery(query *sql.Rows, userId uuid.UUID, sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
var tokens []*types.Token
hasRows := false
for query.Next() {
hasRows = true
var (
token string
createdAtStr string
expiresAtStr string
createdAt time.Time
expiresAt time.Time
)
err := query.Scan(&token, &createdAtStr, &expiresAtStr)
if err != nil {
log.Error("Could not scan token: %v", err)
return nil, types.ErrInternal
}
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
if err != nil {
log.Error("Could not parse token.created_at: %v", err)
return nil, types.ErrInternal
}
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
if err != nil {
log.Error("Could not parse token.expires_at: %v", err)
return nil, types.ErrInternal
}
tokens = append(tokens, types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt))
}
if !hasRows {
return nil, ErrNotFound
}
return tokens, nil
}
func (db AuthSqlite) DeleteToken(token string) error {
_, err := db.db.Exec("DELETE FROM token WHERE token = ?", token)
if err != nil {
log.Error("Could not delete token: %v", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) InsertSession(session *types.Session) error {
func (db AuthDbSqlite) InsertSession(session *Session) error {
_, err := db.db.Exec(`
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)`, session.Id, session.UserId, session.CreatedAt, session.ExpiresAt)
INSERT INTO session (session_id, user_uuid, created_at)
VALUES (?, ?, ?)`, session.Id, session.UserId, session.CreatedAt)
if err != nil {
log.Error("Could not insert new session %v", err)
utils.LogError("Could not insert new session", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) GetSession(sessionId string) (*types.Session, error) {
func (db AuthDbSqlite) GetSession(sessionId string) (*Session, error) {
var (
userId uuid.UUID
createdAt time.Time
expiresAt time.Time
sessionCreatedAt time.Time
)
err := db.db.QueryRow(`
SELECT user_id, created_at, expires_at
FROM session
WHERE session_id = ?`, sessionId).Scan(&userId, &createdAt, &expiresAt)
SELECT u.user_uuid, s.created_at
FROM session s
INNER JOIN user u ON s.user_uuid = u.user_uuid
WHERE session_id = ?`, sessionId).Scan(&userId, &sessionCreatedAt)
if err != nil {
log.Warn("Session not found: %v", err)
return nil, ErrNotFound
return nil, ErrSessionNotFound
}
return types.NewSession(sessionId, userId, createdAt, expiresAt), nil
return NewSession(sessionId, userId, sessionCreatedAt), nil
}
func (db AuthSqlite) GetSessions(userId uuid.UUID) ([]*types.Session, error) {
sessions, err := db.db.Query(`
SELECT session_id, created_at, expires_at
FROM session
WHERE user_id = ?`, userId)
func (db AuthDbSqlite) DeleteOldSessions(userId uuid.UUID) error {
// Delete old inactive sessions
_, err := db.db.Exec("DELETE FROM session WHERE created_at < datetime('now','-8 hours') AND user_uuid = ?", userId)
if err != nil {
log.Error("Could not get sessions: %v", err)
return nil, types.ErrInternal
}
var result []*types.Session
for sessions.Next() {
var (
sessionId string
createdAt time.Time
expiresAt time.Time
)
err := sessions.Scan(&sessionId, &createdAt, &expiresAt)
if err != nil {
log.Error("Could not scan session: %v", err)
return nil, types.ErrInternal
}
session := types.NewSession(sessionId, userId, createdAt, expiresAt)
result = append(result, session)
}
return result, nil
}
func (db AuthSqlite) DeleteOldSessions(userId uuid.UUID) error {
_, err := db.db.Exec(`
DELETE FROM session
WHERE expires_at < datetime('now')
AND user_id = ?`, userId)
if err != nil {
log.Error("Could not delete old sessions: %v", err)
utils.LogError("Could not delete old sessions", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) DeleteSession(sessionId string) error {
func (db AuthDbSqlite) DeleteSession(sessionId string) error {
if sessionId != "" {
_, err := db.db.Exec("DELETE FROM session WHERE session_id = ?", sessionId)
if err != nil {
log.Error("Could not delete session: %v", err)
utils.LogError("Could not delete session", err)
return types.ErrInternal
}
}

View File

@@ -1,8 +1,9 @@
package db
import (
"me-fit/utils"
"database/sql"
"web-app-template/types"
"testing"
"time"
@@ -19,7 +20,7 @@ func setupDb(t *testing.T) *sql.DB {
db.Close()
})
err = RunMigrations(db, "../")
err = utils.RunMigrations(db, "../")
if err != nil {
t.Fatalf("Error running migrations: %v", err)
}
@@ -30,170 +31,82 @@ func setupDb(t *testing.T) *sql.DB {
func TestUser(t *testing.T) {
t.Parallel()
t.Run("should insert and get the same", func(t *testing.T) {
t.Run("should return UserNotFound", func(t *testing.T) {
t.Parallel()
db := setupDb(t)
underTest := AuthSqlite{db: db}
underTest := AuthDbSqlite{db: db}
_, err := underTest.GetUser("someNonExistentEmail")
assert.Equal(t, ErrUserNotFound, err)
})
t.Run("should insert and get user", func(t *testing.T) {
t.Parallel()
db := setupDb(t)
underTest := AuthDbSqlite{db: db}
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
expected := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
expected := NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(expected)
assert.Nil(t, err)
actual, err := underTest.GetUser(expected.Id)
actual, err := underTest.GetUser(expected.Email)
assert.Nil(t, err)
assert.Equal(t, expected, actual)
actual, err = underTest.GetUserByEmail(expected.Email)
assert.Nil(t, err)
assert.Equal(t, expected, actual)
})
t.Run("should return ErrNotFound", func(t *testing.T) {
t.Run("should throw error if user already exists", func(t *testing.T) {
t.Parallel()
db := setupDb(t)
underTest := AuthSqlite{db: db}
_, err := underTest.GetUserByEmail("nonExistentEmail")
assert.Equal(t, ErrNotFound, err)
})
t.Run("should return ErrUserExist", func(t *testing.T) {
t.Parallel()
db := setupDb(t)
underTest := AuthSqlite{db: db}
underTest := AuthDbSqlite{db: db}
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
user := NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(user)
assert.Nil(t, err)
err = underTest.InsertUser(user)
assert.Equal(t, ErrAlreadyExists, err)
})
t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) {
t.Parallel()
db := setupDb(t)
underTest := AuthSqlite{db: db}
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
err := underTest.InsertUser(user)
assert.Equal(t, types.ErrInternal, err)
assert.Equal(t, ErrUserExists, err)
})
}
func TestToken(t *testing.T) {
func TestEmailVerification(t *testing.T) {
t.Parallel()
t.Run("should insert and get the same", func(t *testing.T) {
t.Run("should return empty string if no token is safed", func(t *testing.T) {
t.Parallel()
db := setupDb(t)
underTest := AuthSqlite{db: db}
underTest := AuthDbSqlite{db: db}
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
expiresAt := createAt.Add(24 * time.Hour)
expected := types.NewToken(uuid.New(), "sessionId", "token", types.TokenTypeCsrf, createAt, expiresAt)
token, err := underTest.GetEmailVerificationToken(uuid.New())
err := underTest.InsertToken(expected)
assert.Nil(t, err)
actual, err := underTest.GetToken(expected.Token)
assert.Nil(t, err)
assert.Equal(t, expected, actual)
expected.SessionId = ""
actuals, err := underTest.GetTokensByUserIdAndType(expected.UserId, expected.Type)
assert.Nil(t, err)
assert.Equal(t, []*types.Token{expected}, actuals)
expected.SessionId = "sessionId"
expected.UserId = uuid.Nil
actuals, err = underTest.GetTokensBySessionIdAndType(expected.SessionId, expected.Type)
assert.Nil(t, err)
assert.Equal(t, []*types.Token{expected}, actuals)
assert.Equal(t, "", token)
})
t.Run("should insert and return multiple tokens", func(t *testing.T) {
t.Run("should insert and return token", func(t *testing.T) {
t.Parallel()
db := setupDb(t)
underTest := AuthSqlite{db: db}
underTest := AuthDbSqlite{db: db}
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
expiresAt := createAt.Add(24 * time.Hour)
userId := uuid.New()
expected1 := types.NewToken(userId, "sessionId", "token1", types.TokenTypeCsrf, createAt, expiresAt)
expected2 := types.NewToken(userId, "sessionId", "token2", types.TokenTypeCsrf, createAt, expiresAt)
expectedToken := "someToken"
err := underTest.InsertToken(expected1)
assert.Nil(t, err)
err = underTest.InsertToken(expected2)
err := underTest.InsertEmailVerificationToken(userId, expectedToken)
assert.Nil(t, err)
expected1.UserId = uuid.Nil
expected2.UserId = uuid.Nil
actuals, err := underTest.GetTokensBySessionIdAndType(expected1.SessionId, expected1.Type)
assert.Nil(t, err)
assert.Equal(t, []*types.Token{expected1, expected2}, actuals)
expected1.SessionId = ""
expected2.SessionId = ""
expected1.UserId = userId
expected2.UserId = userId
actuals, err = underTest.GetTokensByUserIdAndType(userId, expected1.Type)
assert.Nil(t, err)
assert.Equal(t, []*types.Token{expected1, expected2}, actuals)
})
t.Run("should return ErrNotFound", func(t *testing.T) {
t.Parallel()
db := setupDb(t)
underTest := AuthSqlite{db: db}
_, err := underTest.GetToken("nonExistent")
assert.Equal(t, ErrNotFound, err)
_, err = underTest.GetTokensByUserIdAndType(uuid.New(), types.TokenTypeEmailVerify)
assert.Equal(t, ErrNotFound, err)
_, err = underTest.GetTokensBySessionIdAndType("sessionId", types.TokenTypeEmailVerify)
assert.Equal(t, ErrNotFound, err)
})
t.Run("should return ErrAlreadyExists", func(t *testing.T) {
t.Parallel()
db := setupDb(t)
underTest := AuthSqlite{db: db}
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(user)
actualToken, err := underTest.GetEmailVerificationToken(userId)
assert.Nil(t, err)
err = underTest.InsertUser(user)
assert.Equal(t, ErrAlreadyExists, err)
})
t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) {
t.Parallel()
db := setupDb(t)
underTest := AuthSqlite{db: db}
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
err := underTest.InsertUser(user)
assert.Equal(t, types.ErrInternal, err)
assert.Equal(t, expectedToken, actualToken)
})
}

View File

@@ -1,8 +1,8 @@
package db
import (
"web-app-template/log"
"web-app-template/types"
"me-fit/types"
"me-fit/utils"
"database/sql"
"errors"
@@ -59,7 +59,7 @@ func (db WorkoutDbSqlite) InsertWorkout(userId uuid.UUID, workout *WorkoutInsert
VALUES (?, ?, ?, ?, ?)
RETURNING rowid`, userId, workout.Date, workout.Type, workout.Sets, workout.Reps).Scan(&rowId)
if err != nil {
log.Error("Error inserting workout: %v", err)
utils.LogError("Error inserting workout", err)
return nil, types.ErrInternal
}
@@ -70,7 +70,7 @@ func (db WorkoutDbSqlite) GetWorkouts(userId uuid.UUID) ([]Workout, error) {
rows, err := db.db.Query("SELECT rowid, date, type, sets, reps FROM workout WHERE user_id = ? ORDER BY date desc", userId)
if err != nil {
log.Error("Could not get workouts: %v", err)
utils.LogError("Could not get workouts", err)
return nil, types.ErrInternal
}
@@ -83,13 +83,13 @@ func (db WorkoutDbSqlite) GetWorkouts(userId uuid.UUID) ([]Workout, error) {
err = rows.Scan(&workout.RowId, &date, &workout.Type, &workout.Sets, &workout.Reps)
if err != nil {
log.Error("Could not scan workout: %v", err)
utils.LogError("Could not scan workout", err)
return nil, types.ErrInternal
}
workout.Date, err = time.Parse("2006-01-02 15:04:05-07:00", date)
if err != nil {
log.Error("Could not parse date: %v", err)
utils.LogError("Could not parse date", err)
return nil, types.ErrInternal
}

25
go.mod
View File

@@ -1,19 +1,16 @@
module web-app-template
module me-fit
go 1.23
toolchain go1.23.5
go 1.22.5
require (
github.com/a-h/templ v0.3.833
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/a-h/templ v0.2.793
github.com/golang-migrate/migrate/v4 v4.18.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24
github.com/prometheus/client_golang v1.21.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.33.0
golang.org/x/net v0.35.0
github.com/prometheus/client_golang v1.20.5
github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.29.0
)
require (
@@ -22,15 +19,15 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
golang.org/x/sys v0.27.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

38
go.sum
View File

@@ -1,13 +1,13 @@
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY=
github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -19,8 +19,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -35,30 +35,28 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
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/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA=
github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -1,104 +1,115 @@
package handler
import (
"web-app-template/handler/middleware"
"web-app-template/log"
"web-app-template/service"
"web-app-template/template/auth"
"web-app-template/types"
"web-app-template/utils"
"me-fit/service"
"me-fit/template"
"me-fit/template/auth"
"me-fit/types"
"me-fit/utils"
"database/sql"
"errors"
"net/http"
"net/url"
"time"
)
type Auth interface {
Handle(router *http.ServeMux)
type HandlerAuth interface {
handle(router *http.ServeMux)
}
type AuthImpl struct {
service service.Auth
render *Render
type HandlerAuthImpl struct {
db *sql.DB
service service.AuthService
serverSettings *types.ServerSettings
}
func NewAuth(service service.Auth, render *Render) Auth {
return AuthImpl{
func NewHandlerAuth(db *sql.DB, service service.AuthService, serverSettings *types.ServerSettings) HandlerAuth {
return HandlerAuthImpl{
db: db,
service: service,
render: render,
serverSettings: serverSettings,
}
}
func (handler AuthImpl) Handle(router *http.ServeMux) {
router.Handle("GET /auth/signin", handler.handleSignInPage())
router.Handle("POST /api/auth/signin", handler.handleSignIn())
func (handler HandlerAuthImpl) handle(router *http.ServeMux) {
// Don't use auth middleware for these routes, as it makes redirecting very difficult, if the mail is not yet verified
router.Handle("/auth/signin", handler.handleSignInPage())
router.Handle("/auth/signup", handler.handleSignUpPage())
router.Handle("/auth/verify", handler.handleSignUpVerifyPage())
router.Handle("/api/auth/verify-resend", handler.handleVerifyResendComp())
router.Handle("/auth/verify-email", handler.handleSignUpVerifyResponsePage())
router.Handle("/api/auth/signup", handler.handleSignUp())
router.Handle("POST /api/auth/signout", handler.handleSignOut())
router.Handle("/auth/verify", handler.handleSignUpVerifyPage()) // Hint for the user to verify their email
router.Handle("/auth/delete-account", handler.handleDeleteAccountPage())
router.Handle("/api/auth/delete-account", handler.handleDeleteAccountComp())
router.Handle("GET /auth/change-password", handler.handleChangePasswordPage())
router.Handle("POST /api/auth/change-password", handler.handleChangePasswordComp())
router.Handle("GET /auth/forgot-password", handler.handleForgotPasswordPage())
router.Handle("POST /api/auth/forgot-password", handler.handleForgotPasswordComp())
router.Handle("POST /api/auth/forgot-password-actual", handler.handleForgotPasswordResponseComp())
router.Handle("/auth/verify-email", service.HandleSignUpVerifyResponsePage(handler.db)) // The link contained in the email
router.Handle("/auth/change-password", handler.handleChangePasswordPage())
router.Handle("/auth/reset-password", handler.handleResetPasswordPage())
router.Handle("/api/auth/signup", handler.handleSignUp())
router.Handle("/api/auth/signin", handler.handleSignIn())
router.Handle("/api/auth/signout", handler.handleSignOut())
router.Handle("/api/auth/delete-account", handler.HandleDeleteAccountComp())
router.Handle("/api/auth/verify-resend", handler.HandleVerifyResendComp())
router.Handle("/api/auth/change-password", handler.HandleChangePasswordComp())
router.Handle("/api/auth/reset-password", service.HandleResetPasswordComp(handler.db, handler.serverSettings))
router.Handle("/api/auth/reset-password-actual", service.HandleActualResetPasswordComp(handler.db))
}
var (
securityWaitDuration = 250 * time.Millisecond
)
func (handler AuthImpl) handleSignInPage() http.HandlerFunc {
func (handler HandlerAuthImpl) handleSignInPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user != nil {
user, err := handler.service.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
userComp := service.UserInfoComp(nil)
signIn := auth.SignInOrUpComp(true)
err := template.Layout(signIn, userComp, handler.serverSettings.Environment).Render(r.Context(), w)
if err != nil {
utils.LogError("Failed to render sign in page", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
if !user.EmailVerified {
utils.DoRedirect(w, r, "/auth/verify")
} else {
utils.DoRedirect(w, r, "/")
}
return
}
comp := auth.SignInOrUpComp(true)
handler.render.RenderLayout(r, w, comp, nil)
}
}
func (handler AuthImpl) handleSignIn() http.HandlerFunc {
func (handler HandlerAuthImpl) handleSignIn() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*service.User, error) {
var email = r.FormValue("email")
var password = r.FormValue("password")
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*types.User, error) {
session := middleware.GetSession(r)
email := r.FormValue("email")
password := r.FormValue("password")
session, user, err := handler.service.SignIn(session, email, password)
session, err := handler.service.SignIn(email, password)
if err != nil {
return nil, err
}
cookie := middleware.CreateSessionCookie(session.Id)
cookie := http.Cookie{
Name: "id",
Value: session.Id,
MaxAge: 60 * 60 * 8, // 8 hours
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Path: "/",
}
http.SetCookie(w, &cookie)
return user, nil
return session.User, nil
})
if err != nil {
if err == service.ErrInvalidCredentials {
utils.TriggerToast(w, r, "error", "Invalid email or password", http.StatusUnauthorized)
if err == service.ErrInvaidCredentials {
utils.TriggerToast(w, r, "error", "Invalid email or password")
http.Error(w, "Invalid email or password", http.StatusUnauthorized)
} else {
utils.TriggerToast(w, r, "error", "An error occurred", http.StatusInternalServerError)
utils.LogError("Error signing in", err)
http.Error(w, "An error occurred", http.StatusInternalServerError)
}
return
}
@@ -111,126 +122,66 @@ func (handler AuthImpl) handleSignIn() http.HandlerFunc {
}
}
func (handler AuthImpl) handleSignUpPage() http.HandlerFunc {
func (handler HandlerAuthImpl) handleSignUpPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
user, err := handler.service.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
userComp := service.UserInfoComp(nil)
signUpComp := auth.SignInOrUpComp(false)
err := template.Layout(signUpComp, userComp, handler.serverSettings.Environment).Render(r.Context(), w)
if err != nil {
utils.LogError("Failed to render sign up page", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
if user != nil {
if !user.EmailVerified {
utils.DoRedirect(w, r, "/auth/verify")
} else {
utils.DoRedirect(w, r, "/")
}
return
}
signUpComp := auth.SignInOrUpComp(false)
handler.render.RenderLayout(r, w, signUpComp, nil)
}
}
func (handler AuthImpl) handleSignUpVerifyPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
if user.EmailVerified {
utils.DoRedirect(w, r, "/")
return
}
signIn := auth.VerifyComp()
handler.render.RenderLayout(r, w, signIn, user)
}
}
func (handler AuthImpl) handleVerifyResendComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
go handler.service.SendVerificationMail(user.Id, user.Email)
_, err := w.Write([]byte("<p class=\"mt-8\">Verification email sent</p>"))
if err != nil {
log.Error("Could not write response: %v", err)
}
}
}
func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
err := handler.service.VerifyUserEmail(token)
isVerified := err == nil
comp := auth.VerifyResponseComp(isVerified)
var status int
if isVerified {
status = http.StatusOK
} else {
status = http.StatusBadRequest
}
handler.render.RenderLayoutWithStatus(r, w, comp, nil, status)
}
}
func (handler AuthImpl) handleSignUp() http.HandlerFunc {
func (handler HandlerAuthImpl) handleSignUp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var email = r.FormValue("email")
var password = r.FormValue("password")
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (interface{}, error) {
log.Info("Signing up %v", email)
user, err := handler.service.SignUp(email, password)
if err != nil {
return nil, err
}
log.Info("Sending verification email to %v", user.Email)
go handler.service.SendVerificationMail(user.Id, user.Email)
return nil, nil
})
if err != nil {
if errors.Is(err, types.ErrInternal) {
utils.TriggerToast(w, r, "error", "An error occurred", http.StatusInternalServerError)
utils.TriggerToast(w, r, "error", "An error occurred")
return
} else if errors.Is(err, service.ErrInvalidEmail) {
utils.TriggerToast(w, r, "error", "The email provided is invalid", http.StatusBadRequest)
return
} else if errors.Is(err, service.ErrInvalidPassword) {
utils.TriggerToast(w, r, "error", service.ErrInvalidPassword.Error(), http.StatusBadRequest)
utils.TriggerToast(w, r, "error", "The email provided is invalid")
return
}
// If err is "service.ErrAccountExists", then just continue
// If the "service.ErrAccountExists", then just continue
}
utils.TriggerToast(w, r, "success", "An activation link has been send to your email", http.StatusOK)
utils.TriggerToast(w, r, "success", "A link to activate your account has been emailed to the address provided.")
}
}
func (handler AuthImpl) handleSignOut() http.HandlerFunc {
func (handler HandlerAuthImpl) handleSignOut() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := middleware.GetSession(r)
if session != nil {
err := handler.service.SignOut(session.Id)
err := handler.service.SignOut(utils.GetSessionID(r))
if err != nil {
http.Error(w, "An error occurred", http.StatusInternalServerError)
utils.TriggerToast(w, r, "error", "Internal Server Error")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
c := http.Cookie{
Name: "id",
@@ -247,36 +198,122 @@ func (handler AuthImpl) handleSignOut() http.HandlerFunc {
}
}
func (handler AuthImpl) handleDeleteAccountPage() http.HandlerFunc {
func (handler HandlerAuthImpl) handleSignUpVerifyPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
user, err := handler.service.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
utils.DoRedirect(w, r, "/auth/signin")
}
if user.EmailVerified {
utils.DoRedirect(w, r, "/")
} else {
userComp := service.UserInfoComp(user)
signIn := auth.VerifyComp()
err := template.Layout(signIn, userComp, handler.serverSettings.Environment).Render(r.Context(), w)
if err != nil {
utils.LogError("Failed to render verify page", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
}
func (handler HandlerAuthImpl) handleDeleteAccountPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// An unverified email should be able to delete their account
user, err := handler.service.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
utils.DoRedirect(w, r, "/auth/signin")
}
userComp := service.UserInfoComp(user)
comp := auth.DeleteAccountComp()
err = template.Layout(comp, userComp, handler.serverSettings.Environment).Render(r.Context(), w)
if err != nil {
utils.LogError("Failed to render delete account page", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
func (handler HandlerAuthImpl) handleChangePasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
isPasswordReset := r.URL.Query().Has("token")
user, _ := handler.service.GetUserFromSessionId(utils.GetSessionID(r))
if user == nil && !isPasswordReset {
utils.DoRedirect(w, r, "/auth/signin")
} else {
userComp := service.UserInfoComp(user)
comp := auth.ChangePasswordComp(isPasswordReset)
err := template.Layout(comp, userComp, handler.serverSettings.Environment).Render(r.Context(), w)
if err != nil {
utils.LogError("Failed to render change password page", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
}
func (handler HandlerAuthImpl) handleResetPasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := handler.service.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
utils.DoRedirect(w, r, "/auth/signin")
}
userComp := service.UserInfoComp(user)
comp := auth.ResetPasswordComp()
err = template.Layout(comp, userComp, handler.serverSettings.Environment).Render(r.Context(), w)
if err != nil {
utils.LogError("Failed to render change password page", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
func (handler HandlerAuthImpl) HandleResetPasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := handler.service.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
comp := auth.DeleteAccountComp()
handler.render.RenderLayout(r, w, comp, user)
userComp := service.UserInfoComp(user)
comp := auth.ResetPasswordComp()
err = template.Layout(comp, userComp, handler.serverSettings.Environment).Render(r.Context(), w)
if err != nil {
utils.LogError("Failed to render change password page", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
func (handler HandlerAuthImpl) HandleDeleteAccountComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
user, err := handler.service.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
password := r.FormValue("password")
err := handler.service.DeleteAccount(user, password)
_, err = handler.service.SignIn(user.Email, password)
if err != nil {
if err == service.ErrInvalidCredentials {
utils.TriggerToast(w, r, "error", "Password not correct", http.StatusBadRequest)
} else {
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
utils.TriggerToast(w, r, "error", "Password not correct")
return
}
err = handler.service.DeleteAccount(user)
if err != nil {
utils.TriggerToast(w, r, "error", "Internal Server Error")
return
}
@@ -284,99 +321,38 @@ func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
}
}
func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc {
func (handler HandlerAuthImpl) HandleVerifyResendComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
isPasswordReset := r.URL.Query().Has("token")
user := middleware.GetUser(r)
if user == nil && !isPasswordReset {
user, err := handler.service.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
comp := auth.ChangePasswordComp(isPasswordReset)
handler.render.RenderLayout(r, w, comp, user)
go handler.service.SendVerificationMail(user.Id, user.Email)
w.Write([]byte("<p class=\"mt-8\">Verification email sent</p>"))
}
}
func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
func (handler HandlerAuthImpl) HandleChangePasswordComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := middleware.GetSession(r)
user := middleware.GetUser(r)
if session == nil || user == nil {
utils.TriggerToast(w, r, "error", "Unathorized", http.StatusUnauthorized)
user, err := handler.service.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
currPass := r.FormValue("current-password")
newPass := r.FormValue("new-password")
err := handler.service.ChangePassword(user, session.Id, currPass, newPass)
err = handler.service.ChangePassword(user, currPass, newPass)
if err != nil {
utils.TriggerToast(w, r, "error", "Password not correct", http.StatusBadRequest)
utils.TriggerToast(w, r, "error", "Password not correct")
return
}
utils.TriggerToast(w, r, "success", "Password changed", http.StatusOK)
}
}
func (handler AuthImpl) handleForgotPasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user != nil {
utils.DoRedirect(w, r, "/")
return
}
comp := auth.ResetPasswordComp()
handler.render.RenderLayout(r, w, comp, user)
}
}
func (handler AuthImpl) handleForgotPasswordComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
email := r.FormValue("email")
if email == "" {
utils.TriggerToast(w, r, "error", "Please enter an email", http.StatusBadRequest)
return
}
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (interface{}, error) {
err := handler.service.SendForgotPasswordMail(email)
return nil, err
})
if err != nil {
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
} else {
utils.TriggerToast(w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
}
}
}
func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
pageUrl, err := url.Parse(r.Header.Get("HX-Current-URL"))
if err != nil {
log.Error("Could not get current URL: %v", err)
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
return
}
token := pageUrl.Query().Get("token")
newPass := r.FormValue("new-password")
err = handler.service.ForgotPassword(token, newPass)
if err != nil {
utils.TriggerToast(w, r, "error", err.Error(), http.StatusBadRequest)
} else {
utils.TriggerToast(w, r, "success", "Password changed", http.StatusOK)
}
utils.TriggerToast(w, r, "success", "Password changed")
}
}

46
handler/default.go Normal file
View File

@@ -0,0 +1,46 @@
package handler
import (
"me-fit/db"
"me-fit/middleware"
"me-fit/service"
"me-fit/types"
"database/sql"
"net/http"
)
func GetHandler(d *sql.DB, serverSettings *types.ServerSettings) http.Handler {
var router = http.NewServeMux()
authDb := db.NewAuthDbSqlite(d)
workoutDb := db.NewWorkoutDbSqlite(d)
randomService := service.NewRandomServiceImpl()
clockService := service.NewClockServiceImpl()
mailService := service.NewMailServiceImpl(serverSettings)
authService := service.NewAuthServiceImpl(authDb, randomService, clockService, mailService, serverSettings)
workoutService := service.NewWorkoutServiceImpl(workoutDb, randomService, clockService, mailService, serverSettings)
indexHandler := NewIndexHandler(d, authService, serverSettings)
authHandler := NewHandlerAuth(d, authService, serverSettings)
workoutHandler := NewWorkoutHandler(d, workoutService, authService, serverSettings)
indexHandler.handle(router)
// Serve static files (CSS, JS and images)
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
workoutHandler.handle(router)
authHandler.handle(router)
return middleware.Wrapper(
router,
middleware.Log,
middleware.SecFetchFilter,
middleware.ContentSecurityPolicy,
middleware.Cors(serverSettings),
middleware.Corp,
)
}

View File

@@ -1,50 +1,57 @@
package handler
import (
"web-app-template/handler/middleware"
"web-app-template/service"
"web-app-template/template"
"me-fit/service"
"me-fit/template"
"me-fit/types"
"me-fit/utils"
"database/sql"
"net/http"
"github.com/a-h/templ"
)
type Index interface {
Handle(router *http.ServeMux)
type IndexHandler interface {
handle(router *http.ServeMux)
}
type IndexImpl struct {
service service.Auth
render *Render
type IndexHandlerImpl struct {
db *sql.DB
service service.AuthService
serverSettings *types.ServerSettings
}
func NewIndex(service service.Auth, render *Render) Index {
return IndexImpl{
func NewIndexHandler(db *sql.DB, service service.AuthService, serverSettings *types.ServerSettings) IndexHandler {
return IndexHandlerImpl{
db: db,
service: service,
render: render,
serverSettings: serverSettings,
}
}
func (handler IndexImpl) Handle(router *http.ServeMux) {
func (handler IndexHandlerImpl) handle(router *http.ServeMux) {
router.Handle("/", handler.handleIndexAnd404())
}
func (handler IndexImpl) handleIndexAnd404() http.HandlerFunc {
func (handler IndexHandlerImpl) handleIndexAnd404() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
user, err := handler.service.GetUserFromSessionId(utils.GetSessionID(r))
var comp templ.Component
var comp templ.Component = nil
userComp := service.UserInfoComp(user)
var status int
if r.URL.Path != "/" {
comp = template.NotFound()
status = http.StatusNotFound
comp = template.Layout(template.NotFound(), userComp, handler.serverSettings.Environment)
w.WriteHeader(http.StatusNotFound)
} else {
comp = template.Index()
status = http.StatusOK
comp = template.Layout(template.Index(), userComp, handler.serverSettings.Environment)
}
handler.render.RenderLayoutWithStatus(r, w, comp, user, status)
err = comp.Render(r.Context(), w)
if err != nil {
utils.LogError("Failed to render index", err)
http.Error(w, "Failed to render index", http.StatusInternalServerError)
}
}
}

View File

@@ -1,71 +0,0 @@
package middleware
import (
"context"
"net/http"
"web-app-template/service"
"web-app-template/types"
)
type ContextKey string
var SessionKey ContextKey = "session"
var UserKey ContextKey = "user"
func Authenticate(service service.Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionId := getSessionID(r)
session, user, _ := service.SignInSession(sessionId)
var err error
// Always sign in anonymous
// This way, we can always generate csrf tokens
if session == nil {
session, err = service.SignInAnonymous()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
cookie := CreateSessionCookie(session.Id)
http.SetCookie(w, &cookie)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserKey, user)
ctx = context.WithValue(ctx, SessionKey, session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetUser(r *http.Request) *types.User {
obj := r.Context().Value(UserKey)
if obj == nil {
return nil
}
return obj.(*types.User)
}
func GetSession(r *http.Request) *types.Session {
obj := r.Context().Value(SessionKey)
if obj == nil {
return nil
}
return obj.(*types.Session)
}
func getSessionID(r *http.Request) string {
cookie, err := r.Cookie("id")
if err != nil {
return ""
}
return cookie.Value
}

View File

@@ -1,23 +0,0 @@
package middleware
import (
"net/http"
"strings"
)
func CacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
cached := false
if strings.HasPrefix(path, "/static") {
cached = true
}
if !cached {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
}
next.ServeHTTP(w, r)
})
}

View File

@@ -1,74 +0,0 @@
package middleware
import (
"fmt"
"net/http"
"strings"
"web-app-template/log"
"web-app-template/service"
"web-app-template/types"
"web-app-template/utils"
)
type csrfResponseWriter struct {
http.ResponseWriter
auth service.Auth
session *types.Session
}
func newCsrfResponseWriter(w http.ResponseWriter, auth service.Auth, session *types.Session) *csrfResponseWriter {
return &csrfResponseWriter{
ResponseWriter: w,
auth: auth,
session: session,
}
}
func (rr *csrfResponseWriter) Write(data []byte) (int, error) {
dataStr := string(data)
csrfToken, err := rr.auth.GetCsrfToken(rr.session)
if err == nil {
csrfInput := fmt.Sprintf(`<input type="hidden" name="csrf-token" value="%s" />`, csrfToken)
dataStr = strings.ReplaceAll(dataStr, "</form>", csrfInput+"</form>")
dataStr = strings.ReplaceAll(dataStr, "CSRF_TOKEN", csrfToken)
}
return rr.ResponseWriter.Write([]byte(dataStr))
}
func (rr *csrfResponseWriter) WriteHeader(statusCode int) {
rr.ResponseWriter.WriteHeader(statusCode)
}
func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := GetSession(r)
if r.Method == http.MethodPost ||
r.Method == http.MethodPut ||
r.Method == http.MethodDelete ||
r.Method == http.MethodPatch {
csrfToken := r.FormValue("csrf-token")
if csrfToken == "" {
csrfToken = r.Header.Get("csrf-token")
}
if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(csrfToken, session.Id) {
log.Info("CSRF-Token not correct")
if r.Header.Get("HX-Request") == "true" {
utils.TriggerToast(w, r, "error", "CSRF-Token not correct", http.StatusBadRequest)
} else {
http.Error(w, "CSRF-Token not correct", http.StatusBadRequest)
}
return
}
}
responseWriter := newCsrfResponseWriter(w, auth, session)
next.ServeHTTP(responseWriter, r)
})
}
}

View File

@@ -1,15 +0,0 @@
package middleware
import "net/http"
func CreateSessionCookie(sessionId string) http.Cookie {
return http.Cookie{
Name: "id",
Value: sessionId,
MaxAge: 60 * 60 * 8, // 8 hours
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Path: "/",
}
}

View File

@@ -1,40 +0,0 @@
package middleware
import (
"net/http"
"web-app-template/types"
)
func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Access-Control-Allow-Origin", serverSettings.BaseUrl)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE")
w.Header().Set("Content-Security-Policy",
"default-src 'none'; "+
"script-src 'self'; "+
"connect-src 'self'; "+
"img-src 'self'; "+
"style-src 'self'; "+
"form-action 'self'; "+
"frame-ancestors 'none'; ",
)
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=(), interest-cohort=()")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -1,53 +0,0 @@
package handler
import (
"web-app-template/log"
"web-app-template/template"
"web-app-template/template/auth"
"web-app-template/types"
"net/http"
"github.com/a-h/templ"
)
type Render struct {
}
func NewRender() *Render {
return &Render{}
}
func (render *Render) RenderWithStatus(r *http.Request, w http.ResponseWriter, comp templ.Component, status int) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(status)
err := comp.Render(r.Context(), w)
if err != nil {
log.Error("Failed to render layout: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (render *Render) Render(r *http.Request, w http.ResponseWriter, comp templ.Component) {
render.RenderWithStatus(r, w, comp, http.StatusOK)
}
func (render *Render) RenderLayout(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User) {
render.RenderLayoutWithStatus(r, w, slot, user, http.StatusOK)
}
func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User, status int) {
userComp := render.getUserComp(user)
layout := template.Layout(slot, userComp)
render.RenderWithStatus(r, w, layout, status)
}
func (render *Render) getUserComp(user *types.User) templ.Component {
if user != nil {
return auth.UserComp(user.Email)
} else {
return auth.UserComp("")
}
}

View File

@@ -1,59 +1,69 @@
package handler
import (
"web-app-template/handler/middleware"
"web-app-template/service"
"web-app-template/template/workout"
"web-app-template/utils"
"me-fit/service"
"me-fit/template"
"me-fit/template/workout"
"me-fit/types"
"me-fit/utils"
"database/sql"
"log/slog"
"net/http"
"strconv"
"time"
)
type Workout interface {
Handle(router *http.ServeMux)
type WorkoutHandler interface {
handle(router *http.ServeMux)
}
type WorkoutImpl struct {
service service.Workout
auth service.Auth
render *Render
type WorkoutHandlerImpl struct {
db *sql.DB
service service.WorkoutService
auth service.AuthService
serverSettings *types.ServerSettings
}
func NewWorkout(service service.Workout, auth service.Auth, render *Render) Workout {
return WorkoutImpl{
func NewWorkoutHandler(db *sql.DB, service service.WorkoutService, auth service.AuthService, serverSettings *types.ServerSettings) HandlerAuth {
return WorkoutHandlerImpl{
db: db,
service: service,
auth: auth,
render: render,
serverSettings: serverSettings,
}
}
func (handler WorkoutImpl) Handle(router *http.ServeMux) {
func (handler WorkoutHandlerImpl) handle(router *http.ServeMux) {
router.Handle("/workout", handler.handleWorkoutPage())
router.Handle("POST /api/workout", handler.handleAddWorkout())
router.Handle("GET /api/workout", handler.handleGetWorkout())
router.Handle("DELETE /api/workout/{id}", handler.handleDeleteWorkout())
}
func (handler WorkoutImpl) handleWorkoutPage() http.HandlerFunc {
func (handler WorkoutHandlerImpl) handleWorkoutPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
user, err := handler.auth.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
currentDate := time.Now().Format("2006-01-02")
comp := workout.WorkoutComp(currentDate)
handler.render.RenderLayout(r, w, comp, user)
inner := workout.WorkoutComp(currentDate)
userComp := service.UserInfoComp(user)
err = template.Layout(inner, userComp, handler.serverSettings.Environment).Render(r.Context(), w)
if err != nil {
utils.LogError("Failed to render workout page", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
func (handler WorkoutImpl) handleAddWorkout() http.HandlerFunc {
func (handler WorkoutHandlerImpl) handleAddWorkout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
user, err := handler.auth.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
@@ -64,23 +74,27 @@ func (handler WorkoutImpl) handleAddWorkout() http.HandlerFunc {
var repsStr = r.FormValue("reps")
wo := service.NewWorkoutDto("", dateStr, typeStr, setsStr, repsStr)
wo, err := handler.service.AddWorkout(user, wo)
wo, err = handler.service.AddWorkout(user, wo)
if err != nil {
utils.TriggerToast(w, r, "error", "Invalid input values", http.StatusBadRequest)
utils.TriggerToast(w, r, "error", "Invalid input values")
http.Error(w, "Invalid input values", http.StatusBadRequest)
return
}
wor := workout.Workout{Id: wo.RowId, Date: wo.Date, Type: wo.Type, Sets: wo.Sets, Reps: wo.Reps}
comp := workout.WorkoutItemComp(wor, true)
handler.render.Render(r, w, comp)
err = workout.WorkoutItemComp(wor, true).Render(r.Context(), w)
if err != nil {
utils.LogError("Could not render workoutitem", err)
utils.TriggerToast(w, r, "error", "Internal Server Error")
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (handler WorkoutImpl) handleGetWorkout() http.HandlerFunc {
func (handler WorkoutHandlerImpl) handleGetWorkout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
user, err := handler.auth.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
@@ -95,34 +109,39 @@ func (handler WorkoutImpl) handleGetWorkout() http.HandlerFunc {
wos = append(wos, workout.Workout{Id: wo.RowId, Date: wo.Date, Type: wo.Type, Sets: wo.Sets, Reps: wo.Reps})
}
comp := workout.WorkoutListComp(wos)
handler.render.Render(r, w, comp)
workout.WorkoutListComp(wos).Render(r.Context(), w)
}
}
func (handler WorkoutImpl) handleDeleteWorkout() http.HandlerFunc {
func (handler WorkoutHandlerImpl) handleDeleteWorkout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
user, err := handler.auth.GetUserFromSessionId(utils.GetSessionID(r))
if err != nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
rowId := r.PathValue("id")
if rowId == "" {
utils.TriggerToast(w, r, "error", "Missing ID field", http.StatusBadRequest)
http.Error(w, "Missing required fields", http.StatusBadRequest)
slog.Warn("Missing required fields for workout delete")
utils.TriggerToast(w, r, "error", "Missing ID field")
return
}
rowIdInt, err := strconv.Atoi(rowId)
if err != nil {
utils.TriggerToast(w, r, "error", "Invalid ID", http.StatusBadRequest)
http.Error(w, "Invalid ID", http.StatusBadRequest)
slog.Warn("Invalid ID for workout delete")
utils.TriggerToast(w, r, "error", "Invalid ID")
return
}
err = handler.service.DeleteWorkout(user, rowIdInt)
if err != nil {
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
slog.Error("Could not delete workout: " + err.Error())
utils.TriggerToast(w, r, "error", "Internal Server Error")
return
}
}

View File

@@ -1,18 +0,0 @@
@import 'tailwindcss';
@source './static/**/*.js';
@source './template/**/*.templ';
@theme {
--animate-fade: fadeOut 0.25s ease-in;
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
}

View File

@@ -1,56 +0,0 @@
package log
import (
"fmt"
"log"
"log/slog"
"strings"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
errorMetric = promauto.NewCounter(
prometheus.CounterOpts{
Name: "mefit_error_total",
Help: "The total number of errors during processing",
},
)
)
func Fatal(message string, args ...interface{}) {
s := format(message, args)
log.Fatal(s)
errorMetric.Inc()
}
func Error(message string, args ...interface{}) {
s := format(message, args)
slog.Error(s)
errorMetric.Inc()
}
func Warn(message string, args ...interface{}) {
s := format(message, args)
slog.Warn(s)
}
func Info(message string, args ...interface{}) {
s := format(message, args)
slog.Info(s)
}
func format(message string, args []interface{}) string {
var w strings.Builder
if len(args) > 0 {
fmt.Fprintf(&w, message, args...)
} else {
w.WriteString(message)
}
return w.String()
}

69
main.go
View File

@@ -1,15 +1,14 @@
package main
import (
"web-app-template/db"
"web-app-template/handler"
"web-app-template/handler/middleware"
"web-app-template/log"
"web-app-template/service"
"web-app-template/types"
"me-fit/handler"
"me-fit/types"
"me-fit/utils"
"context"
"database/sql"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
@@ -30,26 +29,27 @@ func main() {
db, err := sql.Open("sqlite3", "./data.db")
if err != nil {
log.Fatal("Could not open Database data.db: %v", err)
log.Fatal("Could not open Database data.db: ", err)
}
defer db.Close()
run(context.Background(), db, os.Getenv)
}
func run(ctx context.Context, database *sql.DB, env func(string) string) {
func run(ctx context.Context, db *sql.DB, env func(string) string) {
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
log.Info("Starting server...")
slog.Info("Starting server...")
// init server settings
serverSettings := types.NewSettingsFromEnv(env)
serverSettings := types.NewServerSettingsFromEnv(env)
// init db
err := db.RunMigrations(database, "")
err := utils.RunMigrations(db, "")
if err != nil {
log.Fatal("Could not run migrations: %v", err)
slog.Error("Could not run migrations: " + err.Error())
os.Exit(1)
}
// init servers
@@ -64,7 +64,7 @@ func run(ctx context.Context, database *sql.DB, env func(string) string) {
httpServer := &http.Server{
Addr: ":" + serverSettings.Port,
Handler: createHandler(database, serverSettings),
Handler: handler.GetHandler(db, serverSettings),
}
go startServer(httpServer)
@@ -77,9 +77,9 @@ func run(ctx context.Context, database *sql.DB, env func(string) string) {
}
func startServer(s *http.Server) {
log.Info("Starting server on %q", s.Addr)
slog.Info("Starting server on " + s.Addr)
if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("error listening and serving: %v", err)
slog.Error("error listening and serving: " + err.Error())
}
}
@@ -94,43 +94,8 @@ func shutdownServer(s *http.Server, ctx context.Context, wg *sync.WaitGroup) {
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer cancel()
if err := s.Shutdown(shutdownCtx); err != nil {
log.Error("error shutting down http server: %v", err)
slog.Error("error shutting down http server: " + err.Error())
} else {
log.Info("Gracefully stopped http server on %v", s.Addr)
slog.Info("Gracefully stopped http server on " + s.Addr)
}
}
func createHandler(d *sql.DB, serverSettings *types.Settings) http.Handler {
var router = http.NewServeMux()
authDb := db.NewAuthSqlite(d)
workoutDb := db.NewWorkoutDbSqlite(d)
randomService := service.NewRandomImpl()
clockService := service.NewClockImpl()
mailService := service.NewMailImpl(serverSettings)
authService := service.NewAuthImpl(authDb, randomService, clockService, mailService, serverSettings)
workoutService := service.NewWorkoutImpl(workoutDb, randomService, clockService, mailService, serverSettings)
render := handler.NewRender()
indexHandler := handler.NewIndex(authService, render)
authHandler := handler.NewAuth(authService, render)
workoutHandler := handler.NewWorkout(workoutService, authService, render)
indexHandler.Handle(router)
workoutHandler.Handle(router)
authHandler.Handle(router)
// Serve static files (CSS, JS and images)
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
return middleware.Wrapper(
router,
middleware.Log,
middleware.CacheControl,
middleware.SecurityHeaders(serverSettings),
middleware.Authenticate(authService),
middleware.CrossSiteRequestForgery(authService),
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
package middleware
import "net/http"
func ContentSecurityPolicy(next http.Handler) http.Handler {
values := map[string]string{
"default-src": "'none'",
"script-src": "'self' https://umami.me-fit.eu",
"connect-src": "'self' https://umami.me-fit.eu",
"img-src": "'self'",
"style-src": "'self'",
"form-action": "'self'",
"frame-ancestors": "'none'",
}
var headerValue string
for key, value := range values {
headerValue += key + " " + value + "; "
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// While this value can be overridden, it can't be moved to after the next.ServeHTTP call,
// because if the response writer get's closed, the headers can't be set anymore
w.Header().Set("Content-Security-Policy", headerValue)
next.ServeHTTP(w, r)
})
}

13
middleware/corp.go Normal file
View File

@@ -0,0 +1,13 @@
package middleware
import (
"net/http"
)
func Corp(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}

23
middleware/cors.go Normal file
View File

@@ -0,0 +1,23 @@
package middleware
import (
"me-fit/types"
"net/http"
)
func Cors(serverSettings *types.ServerSettings) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", serverSettings.BaseUrl)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -1,12 +1,11 @@
package middleware
import (
"log/slog"
"net/http"
"strconv"
"time"
"web-app-template/log"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
@@ -41,7 +40,7 @@ func Log(next http.Handler) http.Handler {
}
next.ServeHTTP(wrapped, r)
log.Info(r.RemoteAddr + " " + strconv.Itoa(wrapped.StatusCode) + " " + r.Method + " " + r.URL.Path + " " + time.Since(start).String())
slog.Info(r.RemoteAddr + " " + strconv.Itoa(wrapped.StatusCode) + " " + r.Method + " " + r.URL.Path + " " + time.Since(start).String())
metrics.WithLabelValues(r.URL.Path, r.Method, http.StatusText(wrapped.StatusCode)).Inc()
})
}

View File

@@ -0,0 +1,29 @@
package middleware
import "net/http"
func SecFetchFilter(next http.Handler) http.Handler {
// A map is slower than a slice, but it's easier to check if a value exists
allowedSites := map[string]interface{}{
"same-origin": nil,
"none": nil,
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
secFetchSite := r.Header.Get("Sec-Fetch-Site")
if secFetchSite == "" {
next.ServeHTTP(w, r)
return
}
_, exists := allowedSites[r.Header.Get("Sec-Fetch-Site")]
if !exists {
next.ServeHTTP(w, r)
return
}
w.WriteHeader(http.StatusForbidden)
})
}

View File

@@ -1,40 +1,4 @@
CREATE TABLE user (
user_id TEXT NOT NULL UNIQUE PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
email_verified BOOLEAN NOT NULL,
email_verified_at DATETIME,
is_admin BOOLEAN NOT NULL,
password BLOB NOT NULL,
salt BLOB NOT NULL,
created_at DATETIME NOT NULL
) WITHOUT ROWID;
CREATE TABLE session (
session_id TEXT NOT NULL UNIQUE PRIMARY KEY,
user_id TEXT NOT NULL,
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL
) WITHOUT ROWID;
CREATE TABLE token (
token TEXT NOT NULL UNIQUE PRIMARY KEY,
user_id TEXT,
session_id TEXT,
type TEXT NOT NULL,
created_at DATETIME NOT NULL,
expires_at DATETIME
);
CREATE TABLE workout (
user_id INTEGER NOT NULL,
date TEXT NOT NULL,
@@ -42,3 +6,4 @@ CREATE TABLE workout (
sets INTEGER NOT NULL,
reps INTEGER NOT NULL
);

View File

@@ -0,0 +1,21 @@
CREATE TABLE user (
user_uuid TEXT NOT NULL UNIQUE PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
email_verified BOOLEAN NOT NULL,
is_admin BOOLEAN NOT NULL,
password BLOB NOT NULL,
salt BLOB NOT NULL,
created_at DATETIME NOT NULL
) WITHOUT ROWID;
CREATE TABLE session (
session_id TEXT NOT NULL UNIQUE PRIMARY KEY,
user_uuid TEXT NOT NULL,
created_at DATETIME NOT NULL
) WITHOUT ROWID;

View File

@@ -0,0 +1,2 @@
ALTER TABLE user ADD COLUMN email_verified_at DATETIME DEFAULT NULL;

View File

@@ -0,0 +1,11 @@
-- E.G. email-verifications, password-resets, unsubscribe-from-newsletter etc.
CREATE TABLE user_token (
user_uuid TEXT NOT NULL,
type TEXT NOT NULL,
token TEXT NOT NULL UNIQUE PRIMARY KEY,
created_at DATETIME NOT NULL,
expires_at DATETIME
);

View File

@@ -1 +0,0 @@
package mocks

2041
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,19 @@
{
"name": "web-app-template",
"name": "me-fit",
"version": "1.0.0",
"description": "Your (almost) independent tech stack to host on a VPC.",
"main": "index.js",
"scripts": {
"build": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -o static/css/tailwind.css --minify",
"watch": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -o static/css/tailwind.css --watch"
"build": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss build -o static/css/tailwind.css --minify",
"watch": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss build -o static/css/tailwind.css --watch",
"test": ""
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"htmx.org": "2.0.4",
"tailwindcss": "4.0.9",
"@tailwindcss/cli": "4.0.9"
"htmx.org": "2.0.3",
"tailwindcss": "3.4.15",
"daisyui": "4.12.14"
}
}

View File

@@ -3,175 +3,129 @@ package service
import (
"context"
"crypto/subtle"
"database/sql"
"errors"
"log/slog"
"net/http"
"net/mail"
"net/url"
"strings"
"time"
"web-app-template/db"
"web-app-template/log"
mailTemplate "web-app-template/template/mail"
"web-app-template/types"
"me-fit/db"
"me-fit/template/auth"
tempMail "me-fit/template/mail"
"me-fit/types"
"me-fit/utils"
"github.com/a-h/templ"
"github.com/google/uuid"
"golang.org/x/crypto/argon2"
)
var (
ErrInvalidCredentials = errors.New("invalid email or password")
ErrInvalidPassword = errors.New("password needs to be 8 characters long, contain at least one number, one special, one uppercase and one lowercase character")
ErrInvalidEmail = errors.New("invalid email")
ErrAccountExists = errors.New("account already exists")
ErrSessionIdInvalid = errors.New("session ID is invalid")
ErrTokenInvalid = errors.New("token is invalid")
ErrInvaidCredentials = errors.New("Invalid email or password")
ErrInvalidPassword = errors.New("Password needs to be 8 characters long, contain at least one number, one special, one uppercase and one lowercase character")
ErrInvalidEmail = errors.New("Invalid email")
ErrAccountExists = errors.New("Account already exists")
ErrSessionIdInvalid = errors.New("Session ID is invalid")
)
type Auth interface {
SignUp(email string, password string) (*types.User, error)
type User struct {
Id uuid.UUID
Email string
EmailVerified bool
}
func NewUser(user *db.User) *User {
return &User{
Id: user.Id,
Email: user.Email,
EmailVerified: user.EmailVerified,
}
}
type Session struct {
Id string
CreatedAt time.Time
User *User
}
func NewSession(session *db.Session, user *User) *Session {
return &Session{
Id: session.Id,
CreatedAt: session.CreatedAt,
User: user,
}
}
type AuthService interface {
SignIn(email string, password string) (*Session, error)
SignUp(email string, password string) (*User, error)
SendVerificationMail(userId uuid.UUID, email string)
VerifyUserEmail(token string) error
SignIn(session *types.Session, email string, password string) (*types.Session, *types.User, error)
SignInSession(sessionId string) (*types.Session, *types.User, error)
SignInAnonymous() (*types.Session, error)
SignOut(sessionId string) error
DeleteAccount(user *User) error
ChangePassword(user *User, currPass, newPass string) error
DeleteAccount(user *types.User, currPass string) error
ChangePassword(user *types.User, sessionId string, currPass, newPass string) error
SendForgotPasswordMail(email string) error
ForgotPassword(token string, newPass string) error
IsCsrfTokenValid(tokenStr string, sessionId string) bool
GetCsrfToken(session *types.Session) (string, error)
GetUserFromSessionId(sessionId string) (*User, error)
}
type AuthImpl struct {
db db.Auth
random Random
clock Clock
mail Mail
serverSettings *types.Settings
type AuthServiceImpl struct {
dbAuth db.AuthDb
randomGenerator RandomService
clock ClockService
mailService MailService
serverSettings *types.ServerSettings
}
func NewAuthImpl(db db.Auth, random Random, clock Clock, mail Mail, serverSettings *types.Settings) *AuthImpl {
return &AuthImpl{
db: db,
random: random,
func NewAuthServiceImpl(dbAuth db.AuthDb, randomGenerator RandomService, clock ClockService, mailService MailService, serverSettings *types.ServerSettings) *AuthServiceImpl {
return &AuthServiceImpl{
dbAuth: dbAuth,
randomGenerator: randomGenerator,
clock: clock,
mail: mail,
mailService: mailService,
serverSettings: serverSettings,
}
}
func (service AuthImpl) SignIn(session *types.Session, email string, password string) (*types.Session, *types.User, error) {
user, err := service.db.GetUserByEmail(email)
func (service AuthServiceImpl) SignIn(email string, password string) (*Session, error) {
user, err := service.dbAuth.GetUser(email)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, nil, ErrInvalidCredentials
if errors.Is(err, db.ErrUserNotFound) {
return nil, ErrInvaidCredentials
} else {
return nil, nil, types.ErrInternal
return nil, types.ErrInternal
}
}
hash := GetHashPassword(password, user.Salt)
if subtle.ConstantTimeCompare(hash, user.Password) == 0 {
return nil, nil, ErrInvalidCredentials
return nil, ErrInvaidCredentials
}
err = service.cleanUpSessionWithTokens(session)
if err != nil {
return nil, nil, types.ErrInternal
}
session, err = service.createSession(user.Id)
if err != nil {
return nil, nil, types.ErrInternal
}
return session, user, nil
}
func (service AuthImpl) cleanUpSessionWithTokens(session *types.Session) error {
if session == nil {
return nil
}
err := service.db.DeleteSession(session.Id)
if err != nil {
return types.ErrInternal
}
tokens, err := service.db.GetTokensBySessionIdAndType(session.Id, types.TokenTypeCsrf)
if err != nil {
return types.ErrInternal
}
for _, token := range tokens {
err = service.db.DeleteToken(token.Token)
if err != nil {
return types.ErrInternal
}
}
return nil
}
func (service AuthImpl) SignInSession(sessionId string) (*types.Session, *types.User, error) {
if sessionId == "" {
return nil, nil, ErrSessionIdInvalid
}
session, err := service.db.GetSession(sessionId)
if err != nil {
return nil, nil, types.ErrInternal
}
if session.ExpiresAt.Before(service.clock.Now()) {
_ = service.db.DeleteSession(sessionId)
return nil, nil, nil
}
if session.UserId == uuid.Nil {
return session, nil, nil
}
user, err := service.db.GetUser(session.UserId)
if err != nil {
return nil, nil, types.ErrInternal
}
return session, user, nil
}
func (service AuthImpl) SignInAnonymous() (*types.Session, error) {
session, err := service.createSession(uuid.Nil)
session, err := service.createSession(user.Id)
if err != nil {
return nil, types.ErrInternal
}
log.Info("Anonymous session created: %v", session.Id)
return session, nil
return NewSession(session, NewUser(user)), nil
}
func (service AuthImpl) createSession(userId uuid.UUID) (*types.Session, error) {
sessionId, err := service.random.String(32)
func (service AuthServiceImpl) createSession(userId uuid.UUID) (*db.Session, error) {
sessionId, err := service.randomGenerator.String(32)
if err != nil {
return nil, types.ErrInternal
}
err = service.db.DeleteOldSessions(userId)
err = service.dbAuth.DeleteOldSessions(userId)
if err != nil {
return nil, types.ErrInternal
}
createAt := service.clock.Now()
expiresAt := createAt.Add(24 * time.Hour)
session := db.NewSession(sessionId, userId, service.clock.Now())
session := types.NewSession(sessionId, userId, createAt, expiresAt)
err = service.db.InsertSession(session)
err = service.dbAuth.InsertSession(session)
if err != nil {
return nil, types.ErrInternal
}
@@ -179,7 +133,7 @@ func (service AuthImpl) createSession(userId uuid.UUID) (*types.Session, error)
return session, nil
}
func (service AuthImpl) SignUp(email string, password string) (*types.User, error) {
func (service AuthServiceImpl) SignUp(email string, password string) (*User, error) {
_, err := mail.ParseAddress(email)
if err != nil {
return nil, ErrInvalidEmail
@@ -189,135 +143,155 @@ func (service AuthImpl) SignUp(email string, password string) (*types.User, erro
return nil, ErrInvalidPassword
}
userId, err := service.random.UUID()
userId, err := service.randomGenerator.UUID()
if err != nil {
return nil, types.ErrInternal
}
salt, err := service.random.Bytes(16)
salt, err := service.randomGenerator.Bytes(16)
if err != nil {
return nil, types.ErrInternal
}
hash := GetHashPassword(password, salt)
user := types.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now())
dbUser := db.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now())
err = service.db.InsertUser(user)
err = service.dbAuth.InsertUser(dbUser)
if err != nil {
if err == db.ErrAlreadyExists {
if err == db.ErrUserExists {
return nil, ErrAccountExists
} else {
return nil, types.ErrInternal
}
}
return user, nil
return NewUser(dbUser), nil
}
func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
func (service AuthServiceImpl) SendVerificationMail(userId uuid.UUID, email string) {
var token string
tokens, err := service.db.GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify)
if err != nil && err != db.ErrNotFound {
return
}
var token *types.Token
if len(tokens) > 0 {
token = tokens[0]
}
if token == nil {
newTokenStr, err := service.random.String(32)
token, err := service.dbAuth.GetEmailVerificationToken(userId)
if err != nil {
return
}
token = types.NewToken(userId, "", newTokenStr, types.TokenTypeEmailVerify, service.clock.Now(), service.clock.Now().Add(24*time.Hour))
if token == "" {
token, err := service.randomGenerator.String(32)
if err != nil {
return
}
err = service.db.InsertToken(token)
err = service.dbAuth.InsertEmailVerificationToken(userId, token)
if err != nil {
return
}
}
var w strings.Builder
err = mailTemplate.Register(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &w)
err = tempMail.Register(service.serverSettings.BaseUrl, token).Render(context.Background(), &w)
if err != nil {
log.Error("Could not render welcome email: %v", err)
utils.LogError("Could not render welcome email", err)
return
}
service.mail.SendMail(email, "Welcome to web-app-template", w.String())
service.mailService.SendMail(email, "Welcome to ME-FIT", w.String())
}
func (service AuthImpl) VerifyUserEmail(tokenStr string) error {
func (service AuthServiceImpl) SignOut(sessionId string) error {
if tokenStr == "" {
return types.ErrInternal
}
token, err := service.db.GetToken(tokenStr)
if err != nil {
return types.ErrInternal
}
user, err := service.db.GetUser(token.UserId)
if err != nil {
return types.ErrInternal
}
if token.Type != types.TokenTypeEmailVerify {
return types.ErrInternal
}
now := service.clock.Now()
if token.ExpiresAt.Before(now) {
return types.ErrInternal
}
user.EmailVerified = true
user.EmailVerifiedAt = &now
err = service.db.UpdateUser(user)
if err != nil {
return types.ErrInternal
}
_ = service.db.DeleteToken(token.Token)
return nil
return service.dbAuth.DeleteSession(sessionId)
}
func (service AuthImpl) SignOut(sessionId string) error {
func (service AuthServiceImpl) GetUserFromSessionId(sessionId string) (*User, error) {
if sessionId == "" {
return nil, ErrSessionIdInvalid
}
return service.db.DeleteSession(sessionId)
session, err := service.dbAuth.GetSession(sessionId)
if err != nil {
return nil, types.ErrInternal
}
user, err := service.dbAuth.GetUserById(session.UserId)
if err != nil {
return nil, types.ErrInternal
}
if session.CreatedAt.Add(time.Duration(8 * time.Hour)).Before(service.clock.Now()) {
return nil, nil
} else {
return NewUser(user), nil
}
}
func (service AuthImpl) DeleteAccount(user *types.User, currPass string) error {
// TODO
func HandleSignUpVerifyResponsePage(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
utils.DoRedirect(w, r, "/auth/verify")
return
}
result, err := db.Exec(`
UPDATE user
SET email_verified = true, email_verified_at = datetime()
WHERE user_uuid = (
SELECT user_uuid
FROM user_token
WHERE type = "email_verify"
AND token = ?
);
`, token)
userDb, err := service.db.GetUser(user.Id)
if err != nil {
return types.ErrInternal
utils.LogError("Could not update user on verify response", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
currHash := GetHashPassword(currPass, userDb.Salt)
if subtle.ConstantTimeCompare(currHash, userDb.Password) == 0 {
return ErrInvalidCredentials
i, err := result.RowsAffected()
if err != nil {
utils.LogError("Could not get rows affected on verify response", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
err = service.db.DeleteUser(user.Id)
if i == 0 {
utils.DoRedirect(w, r, "/")
} else {
utils.DoRedirect(w, r, "/auth/signin")
}
}
}
func UserInfoComp(user *User) templ.Component {
if user != nil {
return auth.UserComp(user.Email)
} else {
return auth.UserComp("")
}
}
func (service AuthServiceImpl) DeleteAccount(user *User) error {
err := service.dbAuth.DeleteUser(user.Id)
if err != nil {
return err
}
service.mail.SendMail(user.Email, "Account deleted", "Your account has been deleted")
go service.mailService.SendMail(user.Email, "Account deleted", "Your account has been deleted")
return nil
}
func (service AuthImpl) ChangePassword(user *types.User, sessionId string, currPass, newPass string) error {
func (service AuthServiceImpl) ChangePassword(user *User, currPass, newPass string) error {
if !isPasswordValid(newPass) {
return ErrInvalidPassword
@@ -327,160 +301,135 @@ func (service AuthImpl) ChangePassword(user *types.User, sessionId string, currP
return ErrInvalidPassword
}
currHash := GetHashPassword(currPass, user.Salt)
if subtle.ConstantTimeCompare(currHash, user.Password) == 0 {
return ErrInvalidCredentials
}
newHash := GetHashPassword(newPass, user.Salt)
user.Password = newHash
err := service.db.UpdateUser(user)
_, err := service.SignIn(user.Email, currPass)
if err != nil {
return err
}
sessions, err := service.db.GetSessions(user.Id)
userDb, err := service.dbAuth.GetUserById(user.Id)
if err != nil {
return types.ErrInternal
return err
}
for _, s := range sessions {
if s.Id != sessionId {
err = service.db.DeleteSession(s.Id)
newHash := GetHashPassword(newPass, userDb.Salt)
err = service.dbAuth.UpdateUserPassword(user.Id, newHash)
if err != nil {
return types.ErrInternal
}
}
return err
}
return nil
}
func (service AuthImpl) SendForgotPasswordMail(email string) error {
tokenStr, err := service.random.String(32)
func HandleActualResetPasswordComp(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
pageUrl, err := url.Parse(r.Header.Get("HX-Current-URL"))
if err != nil {
return err
utils.LogError("Could not get current URL", err)
utils.TriggerToast(w, r, "error", "Internal Server Error")
return
}
user, err := service.db.GetUserByEmail(email)
if err != nil {
if err == db.ErrNotFound {
return nil
} else {
return types.ErrInternal
}
token := pageUrl.Query().Get("token")
if token == "" {
utils.TriggerToast(w, r, "error", "No token")
return
}
token := types.NewToken(user.Id, "", tokenStr, types.TokenTypePasswordReset, service.clock.Now(), service.clock.Now().Add(15*time.Minute))
err = service.db.InsertToken(token)
if err != nil {
return types.ErrInternal
}
var mail strings.Builder
err = mailTemplate.ResetPassword(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &mail)
if err != nil {
log.Error("Could not render reset password email: %v", err)
return types.ErrInternal
}
service.mail.SendMail(email, "Reset Password", mail.String())
return nil
}
func (service AuthImpl) ForgotPassword(tokenStr string, newPass string) error {
newPass := r.FormValue("new-password")
if !isPasswordValid(newPass) {
return ErrInvalidPassword
utils.TriggerToast(w, r, "error", ErrInvalidPassword.Error())
return
}
token, err := service.db.GetToken(tokenStr)
var (
userId uuid.UUID
salt []byte
)
err = db.QueryRow(`
SELECT u.user_uuid, salt
FROM user_token t
INNER JOIN user u ON t.user_uuid = u.user_uuid
WHERE t.token = ?
AND t.type = 'password_reset'
AND t.expires_at > datetime()
`, token).Scan(&userId, &salt)
if err != nil {
return ErrTokenInvalid
slog.Warn("Could not get user from token: " + err.Error())
utils.TriggerToast(w, r, "error", "Invalid token")
return
}
err = service.db.DeleteToken(tokenStr)
_, err = db.Exec("DELETE FROM user_token WHERE token = ? AND type = 'password_reset'", token)
if err != nil {
return err
utils.LogError("Could not delete token", err)
utils.TriggerToast(w, r, "error", "Internal Server Error")
return
}
if token.Type != types.TokenTypePasswordReset ||
token.ExpiresAt.Before(service.clock.Now()) {
return ErrTokenInvalid
}
passHash := GetHashPassword(newPass, salt)
user, err := service.db.GetUser(token.UserId)
_, err = db.Exec("UPDATE user SET password = ? WHERE user_uuid = ?", passHash, userId)
if err != nil {
log.Error("Could not get user from token: %v", err)
return types.ErrInternal
utils.LogError("Could not update password", err)
utils.TriggerToast(w, r, "error", "Internal Server Error")
return
}
passHash := GetHashPassword(newPass, user.Salt)
user.Password = passHash
err = service.db.UpdateUser(user)
if err != nil {
return err
utils.TriggerToast(w, r, "success", "Password changed")
}
sessions, err := service.db.GetSessions(user.Id)
if err != nil {
return types.ErrInternal
}
for _, session := range sessions {
err = service.db.DeleteSession(session.Id)
if err != nil {
return types.ErrInternal
}
}
return nil
}
func (service AuthImpl) IsCsrfTokenValid(tokenStr string, sessionId string) bool {
token, err := service.db.GetToken(tokenStr)
func HandleResetPasswordComp(db *sql.DB, serverSettings *types.ServerSettings) http.HandlerFunc {
mailService := NewMailServiceImpl(serverSettings)
return func(w http.ResponseWriter, r *http.Request) {
email := r.FormValue("email")
if email == "" {
utils.TriggerToast(w, r, "error", "Please enter an email")
return
}
token, err := NewRandomServiceImpl().String(32)
if err != nil {
return false
return
}
if token.Type != types.TokenTypeCsrf ||
token.SessionId != sessionId ||
token.ExpiresAt.Before(service.clock.Now()) {
return false
}
return true
}
func (service AuthImpl) GetCsrfToken(session *types.Session) (string, error) {
if session == nil {
return "", types.ErrInternal
}
tokens, _ := service.db.GetTokensBySessionIdAndType(session.Id, types.TokenTypeCsrf)
if len(tokens) > 0 {
return tokens[0].Token, nil
}
tokenStr, err := service.random.String(32)
res, err := db.Exec(`
INSERT INTO user_token (user_uuid, type, token, created_at, expires_at)
SELECT user_uuid, 'password_reset', ?, datetime(), datetime('now', '+15 minute')
FROM user
WHERE email = ?
`, token, email)
if err != nil {
return "", types.ErrInternal
utils.LogError("Could not insert token", err)
utils.TriggerToast(w, r, "error", "Internal Server Error")
return
}
token := types.NewToken(session.UserId, session.Id, tokenStr, types.TokenTypeCsrf, service.clock.Now(), service.clock.Now().Add(8*time.Hour))
err = service.db.InsertToken(token)
i, err := res.RowsAffected()
if err != nil {
return "", types.ErrInternal
utils.LogError("Could not get rows affected", err)
utils.TriggerToast(w, r, "error", "Internal Server Error")
return
}
log.Info("CSRF-Token created: %v", tokenStr)
if i != 0 {
var mail strings.Builder
err = tempMail.ResetPassword(serverSettings.BaseUrl, token).Render(context.Background(), &mail)
if err != nil {
utils.LogError("Could not render reset password email", err)
utils.TriggerToast(w, r, "error", "Internal Server Error")
return
}
mailService.SendMail(email, "Reset Password", mail.String())
}
return tokenStr, nil
utils.TriggerToast(w, r, "info", "If the email exists, an email has been sent")
}
}
func GetHashPassword(password string, salt []byte) []byte {

View File

@@ -1,10 +1,11 @@
package service
import (
"web-app-template/db"
"web-app-template/mocks"
"web-app-template/types"
"me-fit/db"
"me-fit/mocks"
"me-fit/types"
"errors"
"strings"
"testing"
"time"
@@ -14,17 +15,115 @@ import (
"github.com/stretchr/testify/mock"
)
func TestSignIn(t *testing.T) {
t.Parallel()
t.Run("should return user if password is correct", func(t *testing.T) {
t.Parallel()
salt := []byte("salt")
verifiedAt := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)
user := db.NewUser(
uuid.New(),
"test@test.de",
true,
&verifiedAt,
false,
GetHashPassword("password", salt),
salt,
time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
)
dbSession := db.NewSession("sessionId", user.Id, time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))
mockAuthDb := mocks.NewMockAuthDb(t)
mockAuthDb.EXPECT().GetUser("test@test.de").Return(user, nil)
mockAuthDb.EXPECT().DeleteOldSessions(user.Id).Return(nil)
mockAuthDb.EXPECT().InsertSession(dbSession).Return(nil)
mockRandom := mocks.NewMockRandomService(t)
mockRandom.EXPECT().String(32).Return("sessionId", nil)
mockClock := mocks.NewMockClockService(t)
mockClock.EXPECT().Now().Return(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))
mockMail := mocks.NewMockMailService(t)
underTest := NewAuthServiceImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.ServerSettings{})
actualSession, err := underTest.SignIn(user.Email, "password")
assert.Nil(t, err)
expectedSession := NewSession(dbSession, NewUser(user))
assert.Equal(t, expectedSession, actualSession)
})
t.Run("should return ErrInvalidCretentials if password is not correct", func(t *testing.T) {
t.Parallel()
salt := []byte("salt")
verifiedAt := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)
user := db.NewUser(
uuid.New(),
"test@test.de",
true,
&verifiedAt,
false,
GetHashPassword("password", salt),
salt,
time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
)
mockAuthDb := mocks.NewMockAuthDb(t)
mockAuthDb.EXPECT().GetUser(user.Email).Return(user, nil)
mockRandom := mocks.NewMockRandomService(t)
mockClock := mocks.NewMockClockService(t)
mockMail := mocks.NewMockMailService(t)
underTest := NewAuthServiceImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.ServerSettings{})
_, err := underTest.SignIn("test@test.de", "wrong password")
assert.Equal(t, ErrInvaidCredentials, err)
})
t.Run("should return ErrInvalidCretentials if user has not been found", func(t *testing.T) {
t.Parallel()
mockAuthDb := mocks.NewMockAuthDb(t)
mockAuthDb.EXPECT().GetUser("test").Return(nil, db.ErrUserNotFound)
mockRandom := mocks.NewMockRandomService(t)
mockClock := mocks.NewMockClockService(t)
mockMail := mocks.NewMockMailService(t)
underTest := NewAuthServiceImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.ServerSettings{})
_, err := underTest.SignIn("test", "test")
assert.Equal(t, ErrInvaidCredentials, err)
})
t.Run("should forward ErrInternal on any other error", func(t *testing.T) {
t.Parallel()
mockAuthDb := mocks.NewMockAuthDb(t)
mockAuthDb.EXPECT().GetUser("test").Return(nil, errors.New("Some undefined error"))
mockRandom := mocks.NewMockRandomService(t)
mockClock := mocks.NewMockClockService(t)
mockMail := mocks.NewMockMailService(t)
underTest := NewAuthServiceImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.ServerSettings{})
_, err := underTest.SignIn("test", "test")
assert.Equal(t, types.ErrInternal, err)
})
}
func TestSignUp(t *testing.T) {
t.Parallel()
t.Run("should check for correct email address", func(t *testing.T) {
t.Parallel()
mockAuthDb := mocks.NewMockAuth(t)
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
mockAuthDb := mocks.NewMockAuthDb(t)
mockRandom := mocks.NewMockRandomService(t)
mockClock := mocks.NewMockClockService(t)
mockMail := mocks.NewMockMailService(t)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
underTest := NewAuthServiceImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.ServerSettings{})
_, err := underTest.SignUp("invalid email address", "SomeStrongPassword123!")
@@ -33,12 +132,12 @@ func TestSignUp(t *testing.T) {
t.Run("should check for password complexity", func(t *testing.T) {
t.Parallel()
mockAuthDb := mocks.NewMockAuth(t)
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
mockAuthDb := mocks.NewMockAuthDb(t)
mockRandom := mocks.NewMockRandomService(t)
mockClock := mocks.NewMockClockService(t)
mockMail := mocks.NewMockMailService(t)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
underTest := NewAuthServiceImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.ServerSettings{})
weakPasswords := []string{
"123!ab", // too short
@@ -55,55 +154,69 @@ func TestSignUp(t *testing.T) {
t.Run("should signup correctly", func(t *testing.T) {
t.Parallel()
mockAuthDb := mocks.NewMockAuth(t)
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
mockAuthDb := mocks.NewMockAuthDb(t)
mockRandom := mocks.NewMockRandomService(t)
mockClock := mocks.NewMockClockService(t)
mockMail := mocks.NewMockMailService(t)
userId := uuid.New()
email := "mail@mail.de"
expected := User{
Id: uuid.New(),
Email: "some@valid.email",
EmailVerified: false,
}
random := NewRandomServiceImpl()
salt, err := random.Bytes(16)
assert.Nil(t, err)
password := "SomeStrongPassword123!"
salt := []byte("salt")
mockRandom.EXPECT().UUID().Return(expected.Id, nil)
mockRandom.EXPECT().Bytes(16).Return(salt, nil)
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
expected := types.NewUser(userId, email, false, nil, false, GetHashPassword(password, salt), salt, createTime)
mockRandom.EXPECT().UUID().Return(userId, nil)
mockRandom.EXPECT().Bytes(16).Return(salt, nil)
mockClock.EXPECT().Now().Return(createTime)
mockAuthDb.EXPECT().InsertUser(expected).Return(nil)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
actual, err := underTest.SignUp(email, password)
mockAuthDb.EXPECT().InsertUser(db.NewUser(expected.Id, expected.Email, false, nil, false, GetHashPassword(password, salt), salt, createTime)).Return(nil)
underTest := NewAuthServiceImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.ServerSettings{})
actual, err := underTest.SignUp(expected.Email, password)
assert.Nil(t, err)
assert.Equal(t, expected, actual)
assert.Equal(t, expected, *actual)
})
t.Run("should return ErrAccountExists", func(t *testing.T) {
t.Parallel()
mockAuthDb := mocks.NewMockAuth(t)
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
mockAuthDb := mocks.NewMockAuthDb(t)
mockRandom := mocks.NewMockRandomService(t)
mockClock := mocks.NewMockClockService(t)
mockMail := mocks.NewMockMailService(t)
userId := uuid.New()
email := "some@valid.email"
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
user := User{
Id: uuid.New(),
Email: "some@valid.email",
}
random := NewRandomServiceImpl()
salt, err := random.Bytes(16)
assert.Nil(t, err)
password := "SomeStrongPassword123!"
salt := []byte("salt")
user := types.NewUser(userId, email, false, nil, false, GetHashPassword(password, salt), salt, createTime)
mockRandom.EXPECT().UUID().Return(user.Id, nil)
mockRandom.EXPECT().Bytes(16).Return(salt, nil)
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
mockClock.EXPECT().Now().Return(createTime)
mockAuthDb.EXPECT().InsertUser(user).Return(db.ErrAlreadyExists)
mockAuthDb.EXPECT().InsertUser(db.NewUser(user.Id, user.Email, false, nil, false, GetHashPassword(password, salt), salt, createTime)).Return(db.ErrUserExists)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
underTest := NewAuthServiceImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.ServerSettings{})
_, err := underTest.SignUp(user.Email, password)
_, err = underTest.SignUp(user.Email, password)
assert.Equal(t, ErrAccountExists, err)
})
}
@@ -114,24 +227,20 @@ func TestSendVerificationMail(t *testing.T) {
t.Run("should use stored token and send mail", func(t *testing.T) {
t.Parallel()
token := types.NewToken(uuid.New(), "sessionId", "someRandomTokenToUse", types.TokenTypeEmailVerify, time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC))
tokens := []*types.Token{token}
token := "someRandomTokenToUse"
email := "some@email.de"
userId := uuid.New()
mockAuthDb := mocks.NewMockAuth(t)
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
mockAuthDb := mocks.NewMockAuthDb(t)
mockRandom := mocks.NewMockRandomService(t)
mockClock := mocks.NewMockClockService(t)
mockMail := mocks.NewMockMailService(t)
mockAuthDb.EXPECT().GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify).Return(tokens, nil)
mockAuthDb.EXPECT().GetEmailVerificationToken(userId).Return(token, nil)
mockMail.EXPECT().SendMail(email, "Welcome to web-app-template", mock.MatchedBy(func(message string) bool {
return strings.Contains(message, token.Token)
})).Return()
mockMail.EXPECT().SendMail(email, "Welcome to ME-FIT", mock.MatchedBy(func(message string) bool { return strings.Contains(message, token) })).Return(nil)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
underTest := NewAuthServiceImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.ServerSettings{})
underTest.SendVerificationMail(userId, email)
})

View File

@@ -2,16 +2,16 @@ package service
import "time"
type Clock interface {
type ClockService interface {
Now() time.Time
}
type ClockImpl struct{}
type ClockServiceImpl struct{}
func NewClockImpl() Clock {
return &ClockImpl{}
func NewClockServiceImpl() ClockService {
return &ClockServiceImpl{}
}
func (c *ClockImpl) Now() time.Time {
func (c *ClockServiceImpl) Now() time.Time {
return time.Now()
}

View File

@@ -1,44 +1,33 @@
package service
import (
"web-app-template/log"
"web-app-template/types"
"fmt"
"me-fit/types"
"net/smtp"
)
type Mail interface {
// Sending an email is a fire and forget operation. Thus no error handling
SendMail(to string, subject string, message string)
type MailService interface {
SendMail(to string, subject string, message string) error
}
type MailImpl struct {
server *types.Settings
type MailServiceImpl struct {
serverSettings *types.ServerSettings
}
func NewMailImpl(server *types.Settings) MailImpl {
return MailImpl{server: server}
func NewMailServiceImpl(serverSettings *types.ServerSettings) MailServiceImpl {
return MailServiceImpl{serverSettings: serverSettings}
}
func (m MailImpl) SendMail(to string, subject string, message string) {
go m.internalSendMail(to, subject, message)
}
func (m MailImpl) internalSendMail(to string, subject string, message string) {
if m.server.Smtp == nil {
return
func (m MailServiceImpl) SendMail(to string, subject string, message string) error {
if m.serverSettings.Smtp == nil {
return nil
}
s := m.server.Smtp
s := m.serverSettings.Smtp
auth := smtp.PlainAuth("", s.User, s.Pass, s.Host)
msg := fmt.Sprintf("From: %v <%v>\nTo: %v\nSubject: %v\nMIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n%v", s.FromName, s.FromMail, to, subject, message)
log.Info("Sending mail to %v", to)
err := smtp.SendMail(s.Host+":"+s.Port, auth, s.FromMail, []string{to}, []byte(msg))
if err != nil {
log.Error("Error sending mail: %v", err)
}
return smtp.SendMail(s.Host+":"+s.Port, auth, s.FromMail, []string{to}, []byte(msg))
}

View File

@@ -1,49 +1,48 @@
package service
import (
"web-app-template/log"
"web-app-template/types"
"me-fit/types"
"crypto/rand"
"encoding/base64"
"log/slog"
"github.com/google/uuid"
)
type Random interface {
type RandomService interface {
Bytes(size int) ([]byte, error)
String(size int) (string, error)
UUID() (uuid.UUID, error)
}
type RandomImpl struct {
type RandomServiceImpl struct {
}
func NewRandomImpl() *RandomImpl {
return &RandomImpl{}
func NewRandomServiceImpl() *RandomServiceImpl {
return &RandomServiceImpl{}
}
func (r *RandomImpl) Bytes(size int) ([]byte, error) {
func (r *RandomServiceImpl) Bytes(size int) ([]byte, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
log.Error("Error generating random bytes: %v", err)
slog.Error("Error generating random bytes: " + err.Error())
return []byte{}, types.ErrInternal
}
return b, nil
}
func (r *RandomImpl) String(size int) (string, error) {
func (r *RandomServiceImpl) String(size int) (string, error) {
bytes, err := r.Bytes(size)
if err != nil {
log.Error("Error generating random string: %v", err)
return "", types.ErrInternal
}
return base64.StdEncoding.EncodeToString(bytes), nil
}
func (r *RandomImpl) UUID() (uuid.UUID, error) {
func (r *RandomServiceImpl) UUID() (uuid.UUID, error) {
return uuid.NewRandom()
}

View File

@@ -1,35 +1,35 @@
package service
import (
"web-app-template/db"
"web-app-template/types"
"me-fit/db"
"me-fit/types"
"errors"
"strconv"
"time"
)
type Workout interface {
AddWorkout(user *types.User, workoutDto *WorkoutDto) (*WorkoutDto, error)
DeleteWorkout(user *types.User, rowId int) error
GetWorkouts(user *types.User) ([]*WorkoutDto, error)
type WorkoutService interface {
AddWorkout(user *User, workoutDto *WorkoutDto) (*WorkoutDto, error)
DeleteWorkout(user *User, rowId int) error
GetWorkouts(user *User) ([]*WorkoutDto, error)
}
type WorkoutImpl struct {
db db.WorkoutDb
random Random
clock Clock
mail Mail
settings *types.Settings
type WorkoutServiceImpl struct {
dbWorkout db.WorkoutDb
randomGenerator RandomService
clock ClockService
mailService MailService
serverSettings *types.ServerSettings
}
func NewWorkoutImpl(db db.WorkoutDb, random Random, clock Clock, mail Mail, settings *types.Settings) Workout {
return WorkoutImpl{
db: db,
random: random,
func NewWorkoutServiceImpl(dbWorkout db.WorkoutDb, randomGenerator RandomService, clock ClockService, mailService MailService, serverSettings *types.ServerSettings) WorkoutService {
return WorkoutServiceImpl{
dbWorkout: dbWorkout,
randomGenerator: randomGenerator,
clock: clock,
mail: mail,
settings: settings,
mailService: mailService,
serverSettings: serverSettings,
}
}
@@ -61,10 +61,10 @@ func NewWorkoutDto(rowId string, date string, workoutType string, sets string, r
}
var (
ErrInputValues = errors.New("invalid input values")
ErrInputValues = errors.New("Invalid input values")
)
func (service WorkoutImpl) AddWorkout(user *types.User, workoutDto *WorkoutDto) (*WorkoutDto, error) {
func (service WorkoutServiceImpl) AddWorkout(user *User, workoutDto *WorkoutDto) (*WorkoutDto, error) {
if workoutDto.Date == "" || workoutDto.Type == "" || workoutDto.Sets == "" || workoutDto.Reps == "" {
return nil, ErrInputValues
@@ -87,7 +87,7 @@ func (service WorkoutImpl) AddWorkout(user *types.User, workoutDto *WorkoutDto)
workoutInsert := db.NewWorkoutInsert(date, workoutDto.Type, sets, reps)
workout, err := service.db.InsertWorkout(user.Id, workoutInsert)
workout, err := service.dbWorkout.InsertWorkout(user.Id, workoutInsert)
if err != nil {
return nil, err
}
@@ -95,20 +95,20 @@ func (service WorkoutImpl) AddWorkout(user *types.User, workoutDto *WorkoutDto)
return NewWorkoutDtoFromDb(workout), nil
}
func (service WorkoutImpl) DeleteWorkout(user *types.User, rowId int) error {
func (service WorkoutServiceImpl) DeleteWorkout(user *User, rowId int) error {
if user == nil {
return types.ErrInternal
}
return service.db.DeleteWorkout(user.Id, rowId)
return service.dbWorkout.DeleteWorkout(user.Id, rowId)
}
func (service WorkoutImpl) GetWorkouts(user *types.User) ([]*WorkoutDto, error) {
func (service WorkoutServiceImpl) GetWorkouts(user *User) ([]*WorkoutDto, error) {
if user == nil {
return nil, types.ErrInternal
}
workouts, err := service.db.GetWorkouts(user.Id)
workouts, err := service.dbWorkout.GetWorkouts(user.Id)
if err != nil {
return nil, err
}
@@ -125,6 +125,15 @@ func (service WorkoutImpl) GetWorkouts(user *types.User) ([]*WorkoutDto, error)
return workoutsDto, nil
}
func renderDateStr(date string) (string, error) {
t, err := time.Parse("2006-01-02 15:04:05-07:00", date)
if err != nil {
return "", err
}
return renderDate(t), nil
}
func renderDate(date time.Time) string {
return date.Format("2006-01-02")
}

26
tailwind.config.js Normal file
View File

@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./template/**/*.templ", "./static/**/*.js"],
theme: {
extend: {
animation: {
fade: 'fadeOut 0.25s ease-in',
},
keyframes: _ => ({
fadeOut: {
'0%': { opacity: '1' },
'100%': { opacity: '0' },
},
}),
},
},
plugins: [
require('daisyui'),
],
daisyui: {
themes: ["retro"],
},
}

View File

@@ -4,7 +4,7 @@ templ ChangePasswordComp(isPasswordReset bool) {
<form
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
if isPasswordReset {
hx-post="/api/auth/forgot-password-actual"
hx-post="/api/auth/reset-password-actual"
} else {
hx-post="/api/auth/change-password"
}
@@ -15,29 +15,11 @@ templ ChangePasswordComp(isPasswordReset bool) {
</h2>
if !isPasswordReset {
<label class="input input-bordered flex items-center gap-2">
<input
type="password"
class="grow"
placeholder="Current Password"
name="current-password"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
<input type="password" class="grow" placeholder="Current Password" name="current-password"/>
</label>
}
<label class="input input-bordered flex items-center gap-2">
<input
type="password"
class="grow"
placeholder="New Password"
name="new-password"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
<input type="password" class="grow" placeholder="New Password" name="new-password"/>
</label>
<button class="btn btn-primary self-end">
Change Password

View File

@@ -12,19 +12,10 @@ templ DeleteAccountComp() {
<p class="text-xl text-red-500 mb-4">
Are you sure you want to delete your account? This action is irreversible.
</p>
<label class="flex items-center gap-2">
<input
type="password"
class="grow"
placeholder="Password"
name="password"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
<label class="input input-bordered flex items-center gap-2">
<input type="password" class="grow" placeholder="Password" name="password"/>
</label>
<button class="self-end">
<button class="btn btn-error self-end">
Delete Account
</button>
</form>

View File

@@ -3,23 +3,14 @@ package auth
templ ResetPasswordComp() {
<form
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
hx-post="/api/auth/forgot-password"
hx-post="/api/auth/reset-password"
hx-swap="none"
>
<h2 class="text-6xl mb-10">
Reset Password
</h2>
<label class="input input-bordered flex items-center gap-2">
<input
type="email"
class="grow"
placeholder="E-Mail"
name="email"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
<input type="email" class="grow" placeholder="E-Mail" name="email"/>
</label>
<button class="btn btn-primary self-end">
Request Password Reset

View File

@@ -1,18 +1,14 @@
package auth
templ SignInOrUpComp(isSignIn bool) {
{{
var postUrl string
if isSignIn {
postUrl = "/api/auth/signin"
} else {
postUrl = "/api/auth/signup"
}
}}
<form
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
hx-target="#sign-in-or-up-error"
hx-post={ postUrl }
if isSignIn {
hx-post="/api/auth/signin"
} else {
hx-post="/api/auth/signup"
}
>
<h2 class="text-6xl mb-10">
if isSignIn {
@@ -22,7 +18,12 @@ if isSignIn {
}
</h2>
<label class="input input-bordered flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-4 w-4 opacity-70">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<path
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z"
></path>
@@ -30,39 +31,26 @@ if isSignIn {
d="M15 6.954 8.978 9.86a2.25 2.25 0 0 1-1.956 0L1 6.954V11.5A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.954Z"
></path>
</svg>
<input
type="text"
class="grow"
placeholder="Email"
name="email"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
<input type="text" class="grow" placeholder="Email" name="email"/>
</label>
<label class="input input-bordered flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-4 w-4 opacity-70">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<path
fill-rule="evenodd"
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
clip-rule="evenodd"
></path>
</svg>
<input
type="password"
class="grow"
placeholder="Password"
name="password"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
<input type="password" class="grow" placeholder="Password" name="password"/>
</label>
<div class="flex justify-end items-center gap-2">
if isSignIn {
<a href="/auth/forgot-password" class="grow link text-gray-500 text-sm">Forgot Password?</a>
<a href="/auth/reset-password" class="grow link text-gray-500 text-sm">Forgot Password?</a>
<a href="/auth/signup" class="link text-gray-500 text-sm">Don't have an account? Sign Up</a>
<button class="btn btn-primary">
Sign In

View File

@@ -3,28 +3,36 @@ package auth
templ UserComp(user string) {
<div id="user-info" class="flex gap-5 items-center">
if user != "" {
<div class="inline-block relative">
<button class="font-semibold py-2 px-4 inline-flex items-center">
<div class="group inline-block relative">
<button
class="font-semibold py-2 px-4 inline-flex items-center"
>
<span class="mr-1">{ user }</span>
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"></path>
<svg
class="fill-current h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path
d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"
></path>
</svg>
</button>
<div class="absolute hidden group-hover:block w-full">
<ul class="w-fit float-right mr-4 p-3">
<ul class="menu bg-base-300 rounded-box w-fit float-right mr-4 p-3">
<li class="mb-1">
<a hx-post="/api/auth/signout" hx-target="#user-info">Sign Out</a>
<a hx-get="/api/auth/signout" hx-target="#user-info">Sign Out</a>
</li>
<li class="mb-1">
<a href="/auth/change-password">Change Password</a>
</li>
<li><a href="/auth/delete-account" class="">Delete Account</a></li>
<li><a href="/auth/delete-account" class="text-error">Delete Account</a></li>
</ul>
</div>
</div>
} else {
<a href="/auth/signup" class="">Sign Up</a>
<a href="/auth/signin" class="">Sign In</a>
<a href="/auth/signup" class="btn btn-sm">Sign Up</a>
<a href="/auth/signin" class="btn btn-sm">Sign In</a>
}
</div>
}

View File

@@ -12,7 +12,7 @@ templ VerifyComp() {
<p class="text-lg text-center">
Please check your inbox/spam and click on the link to verify your account.
</p>
<button class="mt-8" hx-get="/api/auth/verify-resend" hx-sync="this:drop" hx-swap="outerHTML">
<button class="btn mt-8" hx-get="/api/auth/verify-resend" hx-sync="this:drop" hx-swap="outerHTML">
resend verification email
</button>
</div>

View File

@@ -1,29 +0,0 @@
package auth
templ VerifyResponseComp(isVerified bool) {
<main>
<div class="flex flex-col items-center justify-center h-screen">
if isVerified {
<h2 class="text-6xl mb-10">
Your email has been verified
</h2>
<p class="text-lg text-center">
You have completed the verification process. Thank you!
</p>
<a class="mt-8" href="/">
Go Home
</a>
} else {
<h2 class="text-6xl mb-10">
Error during verification
</h2>
<p class="text-lg text-center">
Please try again by sign up process
</p>
<a class="mt-8" href="/auth/signup">
Sign Up
</a>
}
</div>
</main>
}

View File

@@ -1,15 +1,15 @@
package template
templ Index() {
<div class="h-full">
<div class="text-center">
<div class="hero bg-base-200 h-full">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Next Level Workout Tracker</h1>
<p class="py-6">
Ever wanted to track your workouts and see your progress over time? web-app-template is the perfect
Ever wanted to track your workouts and see your progress over time? ME-FIT is the perfect
solution for you.
</p>
<a href="/workout" class="">Get Started</a>
<a href="/workout" class="btn btn-primary">Get Started</a>
</div>
</div>
</div>

View File

@@ -1,31 +1,33 @@
package template
templ Layout(slot templ.Component, user templ.Component) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>web-app-template</title>
<link rel="icon" href="/static/favicon.svg"/>
<link rel="stylesheet" href="/static/css/tailwind.css"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta
name="htmx-config"
content='{
templ Layout(slot templ.Component, user templ.Component, environment string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ME-FIT</title>
<link rel="icon" href="/static/favicon.svg" />
<link rel="stylesheet" href="/static/css/tailwind.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
if environment == "prod" {
<script defer src="https://umami.me-fit.eu/script.js" data-website-id="3c8efb09-44e4-4372-8a1e-c3bc675cd89a"></script>
}
<meta name="htmx-config" content='{
"includeIndicatorStyles": false,
"selfRequestsOnly": true,
"allowScriptTags": false
}'
/>
}' />
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/toast.js"></script>
</head>
<body hx-headers='{"csrf-token": "CSRF_TOKEN"}'>
</head>
<body>
<div class="h-screen flex flex-col">
<div class="flex justify-end items-center gap-2 py-1 px-2 h-12 md:gap-10 md:px-10 md:py-2 shadow-sm">
<div class="flex justify-end items-center gap-2 py-1 px-2 h-12 md:gap-10 md:px-10 md:py-2 shadow">
<a href="/" class="flex-1 flex gap-2">
<img src="/static/favicon.svg" alt="web-app-template logo"/>
<span>web-app-template</span>
<img src="/static/favicon.svg" alt="ME-FIT logo" />
<span>ME-FIT</span>
</a>
@user
</div>
@@ -35,11 +37,12 @@ templ Layout(slot templ.Component, user templ.Component) {
}
</div>
</div>
<div class="" id="toasts">
<div class="hidden" id="toast">
<div class="toast" id="toasts">
<div class="hidden alert" id="toast">
New message arrived.
</div>
</div>
</body>
</html>
</body>
</html>
}

View File

@@ -3,21 +3,17 @@ package mail;
import "net/url"
templ Register(baseUrl string, token string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Welcome</title>
</head>
<body>
</head>
<body>
<h4>Thank you for Sign Up!</h4>
<p>Click <a href={ templ.URL(baseUrl + "/auth/verify-email?token=" + url.QueryEscape(token)) }>here</a> to finalize
your registration.</p>
<p>Click <a href={ templ.URL(baseUrl + "/auth/verify-email?token=" + url.QueryEscape(token)) }>here</a> to verify your account.</p>
<p>Kind regards</p>
</body>
</html>
</body>
</html>
}

View File

@@ -1,11 +1,11 @@
package template
templ NotFound() {
<main class="flex h-full justify-center items-center">
<div class="p-16 rounded-lg">
<h1 class="text-4xl mb-5">Not Found</h1>
<p class="text-lg mb-5">The page you are looking for does not exist.</p>
<a href="/" class="">Go back to home</a>
<main class="flex h-full justify-center items-center ">
<div class="bg-error p-16 rounded-lg">
<h1 class="text-4xl text-error-content mb-5">Not Found</h1>
<p class="text-lg text-error-content mb-5">The page you are looking for does not exist.</p>
<a href="/" class="btn btn-lg btn-primary">Go back to home</a>
</div>
</main>
}

View File

@@ -9,14 +9,14 @@ templ WorkoutComp(currentDate string) {
hx-swap="outerHTML"
>
<h2 class="text-4xl mb-8">Track your workout</h2>
<input id="date" type="date" class="" value={ currentDate } name="date"/>
<select class="w-full" name="type">
<input id="date" type="date" class="input input-bordered" value={ currentDate } name="date"/>
<select class="select select-bordered w-full" name="type">
<option>Push Ups</option>
<option>Pull Ups</option>
</select>
<input type="number" class="" placeholder="Sets" name="sets"/>
<input type="number" class="" placeholder="Reps" name="reps"/>
<button class="self-end">Save</button>
<input type="number" class="input input-bordered" placeholder="Sets" name="sets"/>
<input type="number" class="input input-bordered" placeholder="Reps" name="reps"/>
<button class="btn btn-primary self-end">Save</button>
</form>
<div hx-get="/api/workout" hx-trigger="load"></div>
</main>
@@ -31,7 +31,7 @@ type Workout struct {
}
templ WorkoutListComp(workouts []Workout) {
<div class="overflow-x-auto mx-auto max-w-lg">
<div class="overflow-x-auto mx-auto max-w-screen-lg">
<h2 class="text-4xl mt-14 mb-8">Workout history</h2>
<table class="table table-auto max-w-full">
<thead>
@@ -64,7 +64,7 @@ templ WorkoutItemComp(w Workout, includePlaceholder bool) {
<th>{ w.Reps }</th>
<th>
<div class="tooltip" data-tip="Delete Entry">
<button hx-delete={ "api/workout/" + w.Id } hx-target="closest tr" type="submit">
<button hx-delete={ "api/workout/" + w.Id } hx-target="closest tr">
Delete
</button>
</div>

View File

@@ -1,75 +0,0 @@
package types
import (
"time"
"github.com/google/uuid"
)
type User struct {
Id uuid.UUID
Email string
EmailVerified bool
EmailVerifiedAt *time.Time
IsAdmin bool
Password []byte
Salt []byte
CreateAt time.Time
}
func NewUser(id uuid.UUID, email string, emailVerified bool, emailVerifiedAt *time.Time, isAdmin bool, password []byte, salt []byte, createAt time.Time) *User {
return &User{
Id: id,
Email: email,
EmailVerified: emailVerified,
EmailVerifiedAt: emailVerifiedAt,
IsAdmin: isAdmin,
Password: password,
Salt: salt,
CreateAt: createAt,
}
}
type Session struct {
Id string
UserId uuid.UUID
CreatedAt time.Time
ExpiresAt time.Time
}
func NewSession(id string, userId uuid.UUID, createdAt time.Time, expiresAt time.Time) *Session {
return &Session{
Id: id,
UserId: userId,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
}
}
type Token struct {
UserId uuid.UUID
SessionId string
Token string
Type TokenType
CreatedAt time.Time
ExpiresAt time.Time
}
type TokenType string
var (
TokenTypeEmailVerify TokenType = "email_verify"
TokenTypePasswordReset TokenType = "password_reset"
TokenTypeCsrf TokenType = "csrf"
)
func NewToken(userId uuid.UUID, sessionId string, token string, tokenType TokenType, createdAt time.Time, expiresAt time.Time) *Token {
return &Token{
UserId: userId,
SessionId: sessionId,
Token: token,
Type: tokenType,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
}
}

View File

@@ -1,10 +1,11 @@
package types
import (
"web-app-template/log"
"log"
"log/slog"
)
type Settings struct {
type ServerSettings struct {
Port string
PrometheusEnabled bool
@@ -22,7 +23,7 @@ type SmtpSettings struct {
FromName string
}
func NewSettingsFromEnv(env func(string) string) *Settings {
func NewServerSettingsFromEnv(env func(string) string) *ServerSettings {
var smtp *SmtpSettings
if env("SMTP_ENABLED") == "true" {
@@ -55,7 +56,7 @@ func NewSettingsFromEnv(env func(string) string) *Settings {
}
}
settings := &Settings{
settings := &ServerSettings{
Port: env("PORT"),
PrometheusEnabled: env("PROMETHEUS_ENABLED") == "true",
BaseUrl: env("BASE_URL"),
@@ -77,8 +78,8 @@ func NewSettingsFromEnv(env func(string) string) *Settings {
log.Fatal("SMTP and Prometheus must be enabled in production")
}
log.Info("BASE_URL is %q", settings.BaseUrl)
log.Info("ENVIRONMENT is %q", settings.Environment)
slog.Info("BASE_URL is " + settings.BaseUrl)
slog.Info("ENVIRONMENT is " + settings.Environment)
return settings
}

View File

@@ -5,5 +5,5 @@ import (
)
var (
ErrInternal = errors.New("internal server error")
ErrInternal = errors.New("Internal server error")
)

View File

@@ -1,11 +1,10 @@
package db
package utils
import (
"web-app-template/log"
"web-app-template/types"
"database/sql"
"errors"
"log/slog"
"me-fit/types"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
@@ -15,7 +14,7 @@ import (
func RunMigrations(db *sql.DB, pathPrefix string) error {
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil {
log.Error("Could not create Migration instance: %v", err)
slog.Error("Could not create Migration instance: " + err.Error())
return types.ErrInternal
}
@@ -24,18 +23,17 @@ func RunMigrations(db *sql.DB, pathPrefix string) error {
"",
driver)
if err != nil {
log.Error("Could not create migrations instance: %v", err)
slog.Error("Could not create migrations instance: " + err.Error())
return types.ErrInternal
}
err = m.Up()
if err != nil {
if !errors.Is(err, migrate.ErrNoChange) {
log.Error("Could not run migrations: %v", err)
slog.Error("Could not run migrations: " + err.Error())
return types.ErrInternal
}
}
return nil
}

View File

@@ -2,18 +2,43 @@ package utils
import (
"fmt"
"log/slog"
"net/http"
"time"
"web-app-template/log"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
func TriggerToast(w http.ResponseWriter, r *http.Request, class string, message string, statusCode int) {
type ContextKey string
const (
ContextKeyUser ContextKey = "user_id"
)
var (
errorMetric = promauto.NewCounter(
prometheus.CounterOpts{
Name: "mefit_error_total",
Help: "The total number of errors during processing",
},
)
)
func LogError(message string, err error) {
slog.Error(message + ": " + err.Error())
errorMetric.Inc()
}
func LogErrorMsg(message string) {
slog.Error(message)
errorMetric.Inc()
}
func TriggerToast(w http.ResponseWriter, r *http.Request, class string, message string) {
if isHtmx(r) {
w.Header().Set("HX-Trigger", fmt.Sprintf(`{"toast": "%v|%v"}`, class, message))
w.WriteHeader(statusCode)
} else {
log.Error("Trying to trigger toast in non-HTMX request")
LogErrorMsg("Trying to trigger toast in non-HTMX request")
}
}
@@ -32,6 +57,15 @@ func WaitMinimumTime[T interface{}](waitTime time.Duration, function func() (T,
return result, err
}
func GetSessionID(r *http.Request) string {
for _, c := range r.Cookies() {
if c.Name == "id" {
return c.Value
}
}
return ""
}
func isHtmx(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}