Compare commits
155 Commits
741697b233
...
fix-tests
| Author | SHA1 | Date | |
|---|---|---|---|
| 28460a6bac | |||
| 3039d66295 | |||
| 9b96e8f0a5 | |||
| b86b737a82 | |||
| f2951985c2 | |||
| a88ed4bb47 | |||
| 7ac910aec6 | |||
| 15ccd4ef01 | |||
| 54f8082430 | |||
| 0d5143b91b | |||
| 3d094154ce | |||
| 3d1111256c | |||
| bc82ad123b | |||
| cb01d5e0d4 | |||
| 7cb46aad36 | |||
| 92bb836e87 | |||
| 1d89f45ff9 | |||
| bc70babaca | |||
| d3700d5a3b | |||
| 9a8dfc96db | |||
| 52f6d3d706 | |||
| 508aa3038b | |||
| 0b155af4c9 | |||
| 917218da82 | |||
| fe7f01e035 | |||
| 55408da398 | |||
| b0f183aeed | |||
| 42a910df4b | |||
| 73333256c5 | |||
| 14b477f560 | |||
| 87188724ac | |||
| 5ea400352f | |||
| 397442767a | |||
| 9462f8b245 | |||
| 7a7d7cf204 | |||
| 96b4cc6889 | |||
| 7a9d34d464 | |||
| 52cd85d904 | |||
| fb6cc0acda | |||
| 6a551929c5 | |||
| ea653f0087 | |||
| 143662fff0 | |||
| fdb955f20c | |||
| dcc5207272 | |||
| 43d0a3d022 | |||
| c48194c36f | |||
| 23aa3d4b0e | |||
| 9bb603970d | |||
| 6d3902e572 | |||
| 88892ab6ca | |||
| 28a97414d4 | |||
| f0ec293be8 | |||
| 1ad694ce2b | |||
| 60fe2789cc | |||
| 5d83c9dcc0 | |||
| a8937a0e64 | |||
| 9629e71962 | |||
| 380dd979f6 | |||
| e81fa4b2b6 | |||
| 5579e5da0c | |||
| 12d7c13b02 | |||
| 8cf2210aaf | |||
| eab42c26f8 | |||
| 57989c9b03 | |||
| bbcdbf7a01 | |||
| 93fd45117d | |||
| fb20672105 | |||
| 5ef59df2d0 | |||
| 2d5f42bb28 | |||
| 17899cbf3d | |||
| adae04b83e | |||
| 832e106b2d | |||
| 3ee26cd32b | |||
| 521119fc02 | |||
| 5198487feb | |||
| 9e8e595258 | |||
| 01ceb9eb33 | |||
| 9ed0721b78 | |||
| 1f8c4a39b4 | |||
| 0accb49871 | |||
| 7c67720621 | |||
| 48ec7b64ac | |||
| e201ac7b2c | |||
| a62f0fb037 | |||
| 8ee4c1ede4 | |||
| 3188d25d7d | |||
| 983354dec4 | |||
| 841c8be63d | |||
| d752de0447 | |||
| b1af29633a | |||
| 6a36eb0580 | |||
| ae32bf7232 | |||
| 9ab78b6cc8 | |||
| 003ccbe035 | |||
| 15a53ed8cd | |||
| 925041ef29 | |||
| 35fea60750 | |||
| 641185919c | |||
| 45a1cbdfd4 | |||
| 490d858508 | |||
| 6670d1d440 | |||
| 47c079749b | |||
| 3cfa4d4c91 | |||
| c36e1475c8 | |||
| 0ad537107b | |||
| 1e2ac485c1 | |||
| c971a79675 | |||
| 6cff5aed4d | |||
| 92062d1634 | |||
| 1ed504c49b | |||
| 9fd9f9649e | |||
| 1eff0a5060 | |||
| 9496b5c687 | |||
| 506fc8d248 | |||
| 7910196874 | |||
| 96062c6329 | |||
| 5ebe6d5808 | |||
| 65d39b0d0c | |||
| cf0861dde8 | |||
| 4f6b49e1a0 | |||
| ae0f21ada5 | |||
| 96d7650b2c | |||
| d81e09307a | |||
| 790a3b0575 | |||
| 912ac69f1d | |||
| 6abae81e1c | |||
| 0fab1e1f2e | |||
| 4dfd29eac1 | |||
| 3232632200 | |||
| 6b033e2c2e | |||
| d36f880a01 | |||
| 5b4160b09f | |||
| abd8663cf3 | |||
| 3083ff206a | |||
| 53718d8710 | |||
| 2d56a6a317 | |||
| abb5da3ffd | |||
| 008f1076da | |||
| fd6c68a71b | |||
| 4bc17c50a4 | |||
| 3486081cf6 | |||
| f3995fbcfe | |||
| cbf5b39294 | |||
| cc3747b226 | |||
| 8d90874d04 | |||
| 915c9238f6 | |||
| f2a98e5f49 | |||
| 33380e2124 | |||
| bddcfc6778 | |||
| a53ad627fc | |||
| 7c1d561bd8 | |||
| a70138f2f7 | |||
| 25e82be339 | |||
| a97507455e | |||
| 300aca64f4 |
@@ -3,13 +3,13 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '**' # matches every branch
|
- '**' # matches every branch
|
||||||
- '!master'
|
- '!prod'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
Explore-Gitea-Actions:
|
Build-Docker-Image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
- run: docker build . -t me-fit-test
|
- run: docker build . -t web-app-template-test
|
||||||
- run: docker rmi me-fit-test
|
- run: docker rmi web-app-template-test
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ name: Build and Push Docker Image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- prod
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
Explore-Gitea-Actions:
|
Build-And-Push-Docker-Image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }}
|
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }}
|
||||||
- run: docker build . -t git.wundenbergs.de/x/me-fit:latest -t git.wundenbergs.de/x/me-fit:$GITHUB_SHA
|
- 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/me-fit:latest
|
- run: docker push git.wundenbergs.de/x/web-app-template:latest
|
||||||
- run: docker push git.wundenbergs.de/x/me-fit:$GITHUB_SHA
|
- run: docker push git.wundenbergs.de/x/web-app-template:$GITHUB_SHA
|
||||||
- run: docker rmi git.wundenbergs.de/x/me-fit:latest git.wundenbergs.de/x/me-fit:$GITHUB_SHA
|
- run: docker rmi git.wundenbergs.de/x/web-app-template:latest git.wundenbergs.de/x/web-app-template:$GITHUB_SHA
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -32,3 +32,6 @@ node_modules/
|
|||||||
static/css/tailwind.css
|
static/css/tailwind.css
|
||||||
static/js/htmx.min.js
|
static/js/htmx.min.js
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
|
mocks/*
|
||||||
|
!mocks/default.go
|
||||||
|
|||||||
13
.mockery.yaml
Normal file
13
.mockery.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
with-expecter: True
|
||||||
|
dir: mocks/
|
||||||
|
outpkg: mocks
|
||||||
|
issue-845-fix: True
|
||||||
|
packages:
|
||||||
|
web-app-template/service:
|
||||||
|
interfaces:
|
||||||
|
Random:
|
||||||
|
Clock:
|
||||||
|
Mail:
|
||||||
|
web-app-template/db:
|
||||||
|
interfaces:
|
||||||
|
Auth:
|
||||||
32
Dockerfile
32
Dockerfile
@@ -1,22 +1,32 @@
|
|||||||
FROM golang:1.23.1@sha256:4f063a24d429510e512cc730c3330292ff49f3ade3ae79bda8f84a24fa25ecb0 AS builder_go
|
FROM golang:1.23.5@sha256:e213430692e5c31aba27473cdc84cfff2896d0c097e984bef67b6a44c75a8181 AS builder_go
|
||||||
WORKDIR /me-fit
|
WORKDIR /web-app-template
|
||||||
|
RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.62.2
|
||||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||||
|
RUN go install github.com/vektra/mockery/v2@latest
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
COPY . ./
|
COPY . ./
|
||||||
RUN templ generate && go test ./... && go build -o /me-fit/me-fit .
|
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 .
|
||||||
|
|
||||||
|
|
||||||
FROM node:22.9.0@sha256:cbe2d5f94110cea9817dd8c5809d05df49b4bd1aac5203f3594d88665ad37988 AS builder_node
|
FROM node:22.13.1@sha256:ae2f3d4cc65d251352eca01ba668824f651a2ee4d2a37e2efb22649521a483fd AS builder_node
|
||||||
WORKDIR /me-fit
|
WORKDIR /web-app-template
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm clean-install
|
||||||
COPY . ./
|
COPY . ./
|
||||||
RUN npm install && npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
FROM debian:12.7@sha256:27586f4609433f2f49a9157405b473c62c3cb28a581c413393975b4e8496d0ab
|
FROM debian:12.9@sha256:4abf773f2a570e6873259c4e3ba16de6c6268fb571fd46ec80be7c67822823b3
|
||||||
WORKDIR /me-fit
|
WORKDIR /web-app-template
|
||||||
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
|
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
|
||||||
COPY --from=builder_go /me-fit/me-fit ./me-fit
|
|
||||||
COPY --from=builder_node /me-fit/static ./static
|
|
||||||
COPY migration ./migration
|
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
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/me-fit/me-fit"]
|
ENTRYPOINT ["/web-app-template/web-app-template"]
|
||||||
|
|
||||||
|
|||||||
92
Readme.md
92
Readme.md
@@ -1,44 +1,98 @@
|
|||||||
|
|
||||||
# stackFAST
|
# Web-App-Template
|
||||||
|
|
||||||
Your (almost) independent tech stack to host on a VPC.
|
A basic template with authentication to easily host on a VPC.
|
||||||
|
|
||||||
## Features
|
## 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.
|
This template includes everything essential to build an app. It includes the following features:
|
||||||
|
|
||||||
The blueprint contains the following features:
|
- Authentication: Users can login, logout, register and reset their password. (for increased security TOTP is planned aswell.)
|
||||||
- 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.
|
||||||
- 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.
|
- 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. Best thing is, you can add your more with 3 lines of code
|
- SSL: This is included by using traefik as reverse proxy. It handles SSL certificates automatically. Furthermore all services are accessible through subdomains.
|
||||||
- Actual Stack: SSG SvelteKit + Tailwindcss + DaisyUI + GO Backend for easy and fast feature development
|
- Stack: Tailwindcss + HTMX + GO Backend with templ and sqlite
|
||||||
|
|
||||||
|
|
||||||
## Architecture Design Decisions
|
## Architecture Design Decisions
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
There are a few restrictions I would like to contain:
|
There are a few restrictions I would like to contain:
|
||||||
- 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 want this template do as much as as possible without relying on external services. This way the setup cost and dependencies can be minimized.
|
||||||
- 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 still be possible to run on a small VPC (2vcpu, 2GB).
|
||||||
- It should be as secure as possible
|
- It should be as secure as possible
|
||||||
|
|
||||||
As of 2024 there are 4 options:
|
I determined 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.
|
1. Implement the authentication myself
|
||||||
- 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.
|
2. Using OAuth2 with Keycloak
|
||||||
- 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.
|
3. Using OAuth2 with Google and Apple
|
||||||
- Firebase, Clerk, etc.: Users have to sign up again AND blueprint users have to setup another project.
|
4. Firebase, Clerk, etc.
|
||||||
|
|
||||||
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.
|
|
||||||
|
#### 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.
|
||||||
|
|
||||||
|
|
||||||
### Email
|
### 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 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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
406
db/auth.go
406
db/auth.go
@@ -1,60 +1,88 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"web-app-template/log"
|
||||||
|
"web-app-template/types"
|
||||||
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"me-fit/types"
|
"strings"
|
||||||
"me-fit/utils"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrUserNotFound = errors.New("User not found")
|
ErrNotFound = errors.New("value not found")
|
||||||
|
ErrAlreadyExists = errors.New("row already exists")
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type Auth interface {
|
||||||
Id uuid.UUID
|
InsertUser(user *types.User) error
|
||||||
Email string
|
UpdateUser(user *types.User) error
|
||||||
EmailVerified bool
|
GetUserByEmail(email string) (*types.User, error)
|
||||||
EmailVerifiedAt time.Time
|
GetUser(userId uuid.UUID) (*types.User, error)
|
||||||
IsAdmin bool
|
DeleteUser(userId uuid.UUID) error
|
||||||
Password []byte
|
|
||||||
Salt []byte
|
InsertToken(token *types.Token) error
|
||||||
CreateAt time.Time
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUser(id uuid.UUID, email string, emailVerified bool, emailVerifiedAt time.Time, isAdmin bool, password []byte, salt []byte, createAt time.Time) *User {
|
type AuthSqlite struct {
|
||||||
return &User{
|
|
||||||
Id: id,
|
|
||||||
Email: email,
|
|
||||||
EmailVerified: emailVerified,
|
|
||||||
EmailVerifiedAt: emailVerifiedAt,
|
|
||||||
IsAdmin: isAdmin,
|
|
||||||
Password: password,
|
|
||||||
Salt: salt,
|
|
||||||
CreateAt: createAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type DbAuth interface {
|
|
||||||
GetUser(email string) (*User, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DbAuthSqlite struct {
|
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDbAuthSqlite(db *sql.DB) *DbAuthSqlite {
|
func NewAuthSqlite(db *sql.DB) *AuthSqlite {
|
||||||
return &DbAuthSqlite{db: db}
|
return &AuthSqlite{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db DbAuthSqlite) GetUser(email string) (*User, error) {
|
func (db AuthSqlite) InsertUser(user *types.User) error {
|
||||||
|
_, err := db.db.Exec(`
|
||||||
|
INSERT INTO user (user_id, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Error("SQL error InsertUser: %v", 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) {
|
||||||
var (
|
var (
|
||||||
userId uuid.UUID
|
userId uuid.UUID
|
||||||
emailVerified bool
|
emailVerified bool
|
||||||
emailVerifiedAt time.Time
|
emailVerifiedAt *time.Time
|
||||||
isAdmin bool
|
isAdmin bool
|
||||||
password []byte
|
password []byte
|
||||||
salt []byte
|
salt []byte
|
||||||
@@ -62,17 +90,319 @@ func (db DbAuthSqlite) GetUser(email string) (*User, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
err := db.db.QueryRow(`
|
err := db.db.QueryRow(`
|
||||||
SELECT user_uuid, email_verified, email_verified_at, password, salt, created_at
|
SELECT user_id, email_verified, email_verified_at, password, salt, created_at
|
||||||
FROM user
|
FROM user
|
||||||
WHERE email = ?`, email).Scan(&userId, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
|
WHERE email = ?`, email).Scan(&userId, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, ErrUserNotFound
|
return nil, ErrNotFound
|
||||||
} else {
|
} else {
|
||||||
utils.LogError("SQL error GetUser", err)
|
log.Error("SQL error GetUser: %v", err)
|
||||||
return nil, types.ErrInternal
|
return nil, types.ErrInternal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
|
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db AuthSqlite) GetUser(userId uuid.UUID) (*types.User, error) {
|
||||||
|
var (
|
||||||
|
email string
|
||||||
|
emailVerified bool
|
||||||
|
emailVerifiedAt *time.Time
|
||||||
|
isAdmin bool
|
||||||
|
password []byte
|
||||||
|
salt []byte
|
||||||
|
createdAt time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
} else {
|
||||||
|
log.Error("SQL error GetUser %v", err)
|
||||||
|
return nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db AuthSqlite) DeleteUser(userId uuid.UUID) error {
|
||||||
|
|
||||||
|
tx, err := db.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Could not start transaction: %v", 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)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec("DELETE FROM token WHERE user_id = ?", userId)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
log.Error("Could not delete user tokens: %v", err)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec("DELETE FROM session WHERE user_id = ?", userId)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
log.Error("Could not delete sessions: %v", err)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec("DELETE FROM user WHERE user_id = ?", userId)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
log.Error("Could not delete user: %v", err)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Could not commit transaction: %v", err)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db AuthSqlite) InsertToken(token *types.Token) 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)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Could not insert token: %v", 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
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|
||||||
|
_, err := db.db.Exec(`
|
||||||
|
INSERT INTO session (session_id, user_id, created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?)`, session.Id, session.UserId, session.CreatedAt, session.ExpiresAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Could not insert new session %v", err)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db AuthSqlite) GetSession(sessionId string) (*types.Session, error) {
|
||||||
|
|
||||||
|
var (
|
||||||
|
userId uuid.UUID
|
||||||
|
createdAt time.Time
|
||||||
|
expiresAt time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
err := db.db.QueryRow(`
|
||||||
|
SELECT user_id, created_at, expires_at
|
||||||
|
FROM session
|
||||||
|
WHERE session_id = ?`, sessionId).Scan(&userId, &createdAt, &expiresAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Session not found: %v", err)
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewSession(sessionId, userId, createdAt, expiresAt), 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)
|
||||||
|
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)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db AuthSqlite) 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)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
200
db/auth_test.go
200
db/auth_test.go
@@ -2,12 +2,12 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"me-fit/utils"
|
"web-app-template/types"
|
||||||
"reflect"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupDb(t *testing.T) *sql.DB {
|
func setupDb(t *testing.T) *sql.DB {
|
||||||
@@ -15,55 +15,185 @@ func setupDb(t *testing.T) *sql.DB {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Error opening database: %v", err)
|
t.Fatalf("Error opening database: %v", err)
|
||||||
}
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Close()
|
||||||
|
})
|
||||||
|
|
||||||
utils.MustRunMigrations(db, "../")
|
err = RunMigrations(db, "../")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Error running migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetUser(t *testing.T) {
|
func TestUser(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
t.Run("should return UserNotFound", func(t *testing.T) {
|
t.Run("should insert and get the same", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
db := setupDb(t)
|
db := setupDb(t)
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
underTest := DbAuthSqlite{db: db}
|
underTest := AuthSqlite{db: db}
|
||||||
|
|
||||||
_, err := underTest.GetUser("someNonExistentEmail")
|
|
||||||
if err != ErrUserNotFound {
|
|
||||||
t.Errorf("Expected UserNotFound, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("should find user in database", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
db := setupDb(t)
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
underTest := DbAuthSqlite{db: db}
|
|
||||||
|
|
||||||
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
|
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
|
||||||
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||||
user := NewUser(uuid.New(), "some@email.de", true, verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
|
expected := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
|
||||||
|
|
||||||
_, err := db.Exec(`
|
err := underTest.InsertUser(expected)
|
||||||
INSERT INTO user (user_uuid, email, email_verified, email_verified_at, is_admin, password, salt, created_at)
|
assert.Nil(t, err)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`, user.Id, user.Email, user.EmailVerified, user.EmailVerifiedAt, user.IsAdmin, user.Password, user.Salt, user.CreateAt)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Error inserting user: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual, err := underTest.GetUser(user.Email)
|
actual, err := underTest.GetUser(expected.Id)
|
||||||
if err != nil {
|
assert.Nil(t, err)
|
||||||
t.Fatalf("Error getting user: %v", err)
|
assert.Equal(t, expected, actual)
|
||||||
}
|
|
||||||
|
|
||||||
if !reflect.DeepEqual(user, actual) {
|
actual, err = underTest.GetUserByEmail(expected.Email)
|
||||||
t.Errorf("Expected %v, got %v", user, actual)
|
assert.Nil(t, err)
|
||||||
}
|
assert.Equal(t, expected, actual)
|
||||||
})
|
})
|
||||||
|
t.Run("should return ErrNotFound", 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}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("should insert and get the same", 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)
|
||||||
|
expiresAt := createAt.Add(24 * time.Hour)
|
||||||
|
expected := types.NewToken(uuid.New(), "sessionId", "token", types.TokenTypeCsrf, createAt, expiresAt)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
t.Run("should insert and return multiple tokens", 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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
err := underTest.InsertToken(expected1)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
err = underTest.InsertToken(expected2)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
41
db/default.go
Normal file
41
db/default.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"web-app-template/log"
|
||||||
|
"web-app-template/types"
|
||||||
|
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||||
|
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := migrate.NewWithDatabaseInstance(
|
||||||
|
"file://"+pathPrefix+"migration/",
|
||||||
|
"",
|
||||||
|
driver)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Could not create migrations instance: %v", err)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
err = m.Up()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
log.Error("Could not run migrations: %v", err)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
119
db/workout.go
Normal file
119
db/workout.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"web-app-template/log"
|
||||||
|
"web-app-template/types"
|
||||||
|
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWorkoutNotExists = errors.New("Workout does not exist")
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkoutDb interface {
|
||||||
|
InsertWorkout(userId uuid.UUID, workout *WorkoutInsert) (*Workout, error)
|
||||||
|
GetWorkouts(userId uuid.UUID) ([]Workout, error)
|
||||||
|
DeleteWorkout(userId uuid.UUID, rowId int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkoutDbSqlite struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkoutDbSqlite(db *sql.DB) *WorkoutDbSqlite {
|
||||||
|
return &WorkoutDbSqlite{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkoutInsert struct {
|
||||||
|
Date time.Time
|
||||||
|
Type string
|
||||||
|
Sets int
|
||||||
|
Reps int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Workout struct {
|
||||||
|
RowId int
|
||||||
|
Date time.Time
|
||||||
|
Type string
|
||||||
|
Sets int
|
||||||
|
Reps int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkoutInsert(date time.Time, workoutType string, sets int, reps int) *WorkoutInsert {
|
||||||
|
return &WorkoutInsert{Date: date, Type: workoutType, Sets: sets, Reps: reps}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkoutFromInsert(rowId int, workoutInsert *WorkoutInsert) *Workout {
|
||||||
|
return &Workout{RowId: rowId, Date: workoutInsert.Date, Type: workoutInsert.Type, Sets: workoutInsert.Sets, Reps: workoutInsert.Reps}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db WorkoutDbSqlite) InsertWorkout(userId uuid.UUID, workout *WorkoutInsert) (*Workout, error) {
|
||||||
|
var rowId int
|
||||||
|
err := db.db.QueryRow(`
|
||||||
|
INSERT INTO workout (user_id, date, type, sets, reps)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
RETURNING rowid`, userId, workout.Date, workout.Type, workout.Sets, workout.Reps).Scan(&rowId)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error inserting workout: %v", err)
|
||||||
|
return nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewWorkoutFromInsert(rowId, workout), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
var workouts = make([]Workout, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
workout Workout
|
||||||
|
date string
|
||||||
|
)
|
||||||
|
|
||||||
|
err = rows.Scan(&workout.RowId, &date, &workout.Type, &workout.Sets, &workout.Reps)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Could not scan workout: %v", 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)
|
||||||
|
return nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
workouts = append(workouts, workout)
|
||||||
|
}
|
||||||
|
|
||||||
|
return workouts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db WorkoutDbSqlite) DeleteWorkout(userId uuid.UUID, rowId int) error {
|
||||||
|
|
||||||
|
res, err := db.db.Exec("DELETE FROM workout WHERE user_id = ? AND rowid = ?", userId, rowId)
|
||||||
|
if err != nil {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return ErrWorkoutNotExists
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
24
go.mod
24
go.mod
@@ -1,28 +1,36 @@
|
|||||||
module me-fit
|
module web-app-template
|
||||||
|
|
||||||
go 1.22.5
|
go 1.23
|
||||||
|
|
||||||
|
toolchain go1.23.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/a-h/templ v0.2.778
|
github.com/a-h/templ v0.3.833
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.1
|
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.23
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
github.com/prometheus/client_golang v1.20.4
|
github.com/prometheus/client_golang v1.20.5
|
||||||
golang.org/x/crypto v0.27.0
|
github.com/stretchr/testify v1.10.0
|
||||||
|
golang.org/x/crypto v0.32.0
|
||||||
|
golang.org/x/net v0.34.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/klauspost/compress v1.17.9 // indirect
|
github.com/klauspost/compress v1.17.9 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/client_model v0.6.1 // indirect
|
||||||
github.com/prometheus/common v0.55.0 // indirect
|
github.com/prometheus/common v0.55.0 // indirect
|
||||||
github.com/prometheus/procfs v0.15.1 // 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
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/sys v0.25.0 // indirect
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
41
go.sum
41
go.sum
@@ -1,13 +1,13 @@
|
|||||||
github.com/a-h/templ v0.2.778 h1:VzhOuvWECrwOec4790lcLlZpP4Iptt5Q4K9aFxQmtaM=
|
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
|
||||||
github.com/a-h/templ v0.2.778/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
|
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
|
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
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/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -21,33 +21,46 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
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=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
|
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||||
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
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 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
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 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||||
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
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=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
381
handler/auth.go
381
handler/auth.go
@@ -1,79 +1,104 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"me-fit/service"
|
"web-app-template/handler/middleware"
|
||||||
"me-fit/utils"
|
"web-app-template/log"
|
||||||
"time"
|
"web-app-template/service"
|
||||||
|
"web-app-template/template/auth"
|
||||||
|
"web-app-template/types"
|
||||||
|
"web-app-template/utils"
|
||||||
|
|
||||||
"database/sql"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HandlerAuth interface {
|
type Auth interface {
|
||||||
handle(router *http.ServeMux)
|
Handle(router *http.ServeMux)
|
||||||
}
|
}
|
||||||
|
|
||||||
type HandlerAuthImpl struct {
|
type AuthImpl struct {
|
||||||
db *sql.DB
|
service service.Auth
|
||||||
service service.ServiceAuth
|
render *Render
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandlerAuth(db *sql.DB, service service.ServiceAuth) HandlerAuth {
|
func NewAuth(service service.Auth, render *Render) Auth {
|
||||||
return HandlerAuthImpl{
|
return AuthImpl{
|
||||||
db: db,
|
|
||||||
service: service,
|
service: service,
|
||||||
|
render: render,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler HandlerAuthImpl) handle(router *http.ServeMux) {
|
func (handler AuthImpl) 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("GET /auth/signin", handler.handleSignInPage())
|
||||||
router.Handle("/auth/signin", service.HandleSignInPage(handler.db))
|
router.Handle("POST /api/auth/signin", handler.handleSignIn())
|
||||||
router.Handle("/auth/signup", service.HandleSignUpPage(handler.db))
|
|
||||||
router.Handle("/auth/verify", service.HandleSignUpVerifyPage(handler.db)) // Hint for the user to verify their email
|
router.Handle("/auth/signup", handler.handleSignUpPage())
|
||||||
router.Handle("/auth/delete-account", service.HandleDeleteAccountPage(handler.db))
|
router.Handle("/auth/verify", handler.handleSignUpVerifyPage())
|
||||||
router.Handle("/auth/verify-email", service.HandleSignUpVerifyResponsePage(handler.db)) // The link contained in the email
|
router.Handle("/api/auth/verify-resend", handler.handleVerifyResendComp())
|
||||||
router.Handle("/auth/change-password", service.HandleChangePasswordPage(handler.db))
|
router.Handle("/auth/verify-email", handler.handleSignUpVerifyResponsePage())
|
||||||
router.Handle("/auth/reset-password", service.HandleResetPasswordPage(handler.db))
|
router.Handle("/api/auth/signup", handler.handleSignUp())
|
||||||
router.Handle("/api/auth/signup", service.HandleSignUpComp(handler.db))
|
|
||||||
router.Handle("/api/auth/signin", handler.handleSignIn())
|
router.Handle("POST /api/auth/signout", handler.handleSignOut())
|
||||||
router.Handle("/api/auth/signout", service.HandleSignOutComp(handler.db))
|
|
||||||
router.Handle("/api/auth/delete-account", service.HandleDeleteAccountComp(handler.db))
|
router.Handle("/auth/delete-account", handler.handleDeleteAccountPage())
|
||||||
router.Handle("/api/auth/verify-resend", service.HandleVerifyResendComp(handler.db))
|
router.Handle("/api/auth/delete-account", handler.handleDeleteAccountComp())
|
||||||
router.Handle("/api/auth/change-password", service.HandleChangePasswordComp(handler.db))
|
|
||||||
router.Handle("/api/auth/reset-password", service.HandleResetPasswordComp(handler.db))
|
router.Handle("GET /auth/change-password", handler.handleChangePasswordPage())
|
||||||
router.Handle("/api/auth/reset-password-actual", service.HandleActualResetPasswordComp(handler.db))
|
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
securityWaitDuration = 250 * time.Millisecond
|
securityWaitDuration = 250 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
func (handler HandlerAuthImpl) handleSignIn() http.HandlerFunc {
|
func (handler AuthImpl) handleSignInPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*service.User, error) {
|
user := middleware.GetUser(r)
|
||||||
var email = r.FormValue("email")
|
if user != nil {
|
||||||
var password = r.FormValue("password")
|
if !user.EmailVerified {
|
||||||
|
utils.DoRedirect(w, r, "/auth/verify")
|
||||||
|
} else {
|
||||||
|
utils.DoRedirect(w, r, "/")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user, err := handler.service.SignIn(email, password)
|
comp := auth.SignInOrUpComp(true)
|
||||||
|
|
||||||
|
handler.render.RenderLayout(r, w, comp, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler AuthImpl) handleSignIn() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = service.TryCreateSessionAndSetCookie(r, w, handler.db, user.Id)
|
cookie := middleware.CreateSessionCookie(session.Id)
|
||||||
if err != nil {
|
http.SetCookie(w, &cookie)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrInvaidCredentials {
|
if err == service.ErrInvalidCredentials {
|
||||||
utils.TriggerToast(w, r, "error", "Invalid email or password")
|
utils.TriggerToast(w, r, "error", "Invalid email or password", http.StatusUnauthorized)
|
||||||
http.Error(w, "Invalid email or password", http.StatusUnauthorized)
|
|
||||||
} else {
|
} else {
|
||||||
utils.LogError("Error signing in", err)
|
utils.TriggerToast(w, r, "error", "An error occurred", http.StatusInternalServerError)
|
||||||
http.Error(w, "An error occurred", http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -85,3 +110,273 @@ func (handler HandlerAuthImpl) handleSignIn() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (handler AuthImpl) handleSignUpPage() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := middleware.GetUser(r)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If err is "service.ErrAccountExists", then just continue
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.TriggerToast(w, r, "success", "An activation link has been send to your email", http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler AuthImpl) handleSignOut() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := middleware.GetSession(r)
|
||||||
|
|
||||||
|
if session != nil {
|
||||||
|
err := handler.service.SignOut(session.Id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "An error occurred", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c := http.Cookie{
|
||||||
|
Name: "id",
|
||||||
|
Value: "",
|
||||||
|
MaxAge: -1,
|
||||||
|
Secure: true,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &c)
|
||||||
|
utils.DoRedirect(w, r, "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler AuthImpl) handleDeleteAccountPage() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := middleware.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
utils.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := auth.DeleteAccountComp()
|
||||||
|
handler.render.RenderLayout(r, w, comp, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := middleware.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
utils.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
err := handler.service.DeleteAccount(user, 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)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.DoRedirect(w, r, "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
isPasswordReset := r.URL.Query().Has("token")
|
||||||
|
|
||||||
|
user := middleware.GetUser(r)
|
||||||
|
|
||||||
|
if user == nil && !isPasswordReset {
|
||||||
|
utils.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := auth.ChangePasswordComp(isPasswordReset)
|
||||||
|
handler.render.RenderLayout(r, w, comp, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler AuthImpl) 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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currPass := r.FormValue("current-password")
|
||||||
|
newPass := r.FormValue("new-password")
|
||||||
|
|
||||||
|
err := handler.service.ChangePassword(user, session.Id, currPass, newPass)
|
||||||
|
if err != nil {
|
||||||
|
utils.TriggerToast(w, r, "error", "Password not correct", http.StatusBadRequest)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"me-fit/db"
|
|
||||||
"me-fit/middleware"
|
|
||||||
"me-fit/service"
|
|
||||||
|
|
||||||
"database/sql"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetHandler(d *sql.DB) http.Handler {
|
|
||||||
var router = http.NewServeMux()
|
|
||||||
|
|
||||||
router.HandleFunc("/", service.HandleIndexAnd404(d))
|
|
||||||
|
|
||||||
handlerAuth := NewHandlerAuth(d, service.NewServiceAuthImpl(db.NewDbAuthSqlite(d)))
|
|
||||||
|
|
||||||
// Serve static files (CSS, JS and images)
|
|
||||||
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
|
|
||||||
|
|
||||||
handleWorkout(d, router)
|
|
||||||
|
|
||||||
handlerAuth.handle(router)
|
|
||||||
|
|
||||||
return middleware.Logging(middleware.EnableCors(router))
|
|
||||||
}
|
|
||||||
|
|
||||||
func auth(db *sql.DB, h http.Handler) http.Handler {
|
|
||||||
return middleware.EnsureValidSession(db, h)
|
|
||||||
}
|
|
||||||
50
handler/index_and_404.go
Normal file
50
handler/index_and_404.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"web-app-template/handler/middleware"
|
||||||
|
"web-app-template/service"
|
||||||
|
"web-app-template/template"
|
||||||
|
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Index interface {
|
||||||
|
Handle(router *http.ServeMux)
|
||||||
|
}
|
||||||
|
|
||||||
|
type IndexImpl struct {
|
||||||
|
service service.Auth
|
||||||
|
render *Render
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIndex(service service.Auth, render *Render) Index {
|
||||||
|
return IndexImpl{
|
||||||
|
service: service,
|
||||||
|
render: render,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler IndexImpl) Handle(router *http.ServeMux) {
|
||||||
|
router.Handle("/", handler.handleIndexAnd404())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler IndexImpl) handleIndexAnd404() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := middleware.GetUser(r)
|
||||||
|
|
||||||
|
var comp templ.Component
|
||||||
|
|
||||||
|
var status int
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
comp = template.NotFound()
|
||||||
|
status = http.StatusNotFound
|
||||||
|
} else {
|
||||||
|
comp = template.Index()
|
||||||
|
status = http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.render.RenderLayoutWithStatus(r, w, comp, user, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
71
handler/middleware/authenticate.go
Normal file
71
handler/middleware/authenticate.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
23
handler/middleware/cache_control.go
Normal file
23
handler/middleware/cache_control.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
74
handler/middleware/cross_site_request_forgery.go
Normal file
74
handler/middleware/cross_site_request_forgery.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
15
handler/middleware/default.go
Normal file
15
handler/middleware/default.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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: "/",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"web-app-template/log"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
)
|
)
|
||||||
@@ -30,7 +31,7 @@ func (w *WrappedWriter) WriteHeader(code int) {
|
|||||||
w.StatusCode = code
|
w.StatusCode = code
|
||||||
}
|
}
|
||||||
|
|
||||||
func Logging(next http.Handler) http.Handler {
|
func Log(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ func Logging(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
next.ServeHTTP(wrapped, r)
|
next.ServeHTTP(wrapped, r)
|
||||||
|
|
||||||
slog.Info(r.RemoteAddr + " " + strconv.Itoa(wrapped.StatusCode) + " " + r.Method + " " + r.URL.Path + " " + time.Since(start).String())
|
log.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()
|
metrics.WithLabelValues(r.URL.Path, r.Method, http.StatusText(wrapped.StatusCode)).Inc()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
40
handler/middleware/security_headers.go
Normal file
40
handler/middleware/security_headers.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
13
handler/middleware/wrapper.go
Normal file
13
handler/middleware/wrapper.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func Wrapper(next http.Handler, handlers ...func(http.Handler) http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastHandler := next
|
||||||
|
for i := len(handlers) - 1; i >= 0; i-- {
|
||||||
|
lastHandler = handlers[i](lastHandler)
|
||||||
|
}
|
||||||
|
lastHandler.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
53
handler/render.go
Normal file
53
handler/render.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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("")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,129 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"me-fit/service"
|
"web-app-template/handler/middleware"
|
||||||
|
"web-app-template/service"
|
||||||
|
"web-app-template/template/workout"
|
||||||
|
"web-app-template/utils"
|
||||||
|
|
||||||
"database/sql"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func handleWorkout(db *sql.DB, router *http.ServeMux) {
|
type Workout interface {
|
||||||
router.Handle("/workout", auth(db, service.HandleWorkoutPage(db)))
|
Handle(router *http.ServeMux)
|
||||||
router.Handle("POST /api/workout", auth(db, service.HandleWorkoutNewComp(db)))
|
}
|
||||||
router.Handle("GET /api/workout", auth(db, service.HandleWorkoutGetComp(db)))
|
|
||||||
router.Handle("DELETE /api/workout/{id}", auth(db, service.HandleWorkoutDeleteComp(db)))
|
type WorkoutImpl struct {
|
||||||
|
service service.Workout
|
||||||
|
auth service.Auth
|
||||||
|
render *Render
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkout(service service.Workout, auth service.Auth, render *Render) Workout {
|
||||||
|
return WorkoutImpl{
|
||||||
|
service: service,
|
||||||
|
auth: auth,
|
||||||
|
render: render,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler WorkoutImpl) 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 {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := middleware.GetUser(r)
|
||||||
|
if user == 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler WorkoutImpl) handleAddWorkout() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := middleware.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
utils.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateStr = r.FormValue("date")
|
||||||
|
var typeStr = r.FormValue("type")
|
||||||
|
var setsStr = r.FormValue("sets")
|
||||||
|
var repsStr = r.FormValue("reps")
|
||||||
|
|
||||||
|
wo := service.NewWorkoutDto("", dateStr, typeStr, setsStr, repsStr)
|
||||||
|
wo, err := handler.service.AddWorkout(user, wo)
|
||||||
|
if err != nil {
|
||||||
|
utils.TriggerToast(w, r, "error", "Invalid input values", http.StatusBadRequest)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler WorkoutImpl) handleGetWorkout() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := middleware.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
utils.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workouts, err := handler.service.GetWorkouts(user)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wos := make([]workout.Workout, 0)
|
||||||
|
for _, wo := range workouts {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler WorkoutImpl) handleDeleteWorkout() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := middleware.GetUser(r)
|
||||||
|
if user == nil {
|
||||||
|
utils.DoRedirect(w, r, "/auth/signin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rowId := r.PathValue("id")
|
||||||
|
if rowId == "" {
|
||||||
|
utils.TriggerToast(w, r, "error", "Missing ID field", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rowIdInt, err := strconv.Atoi(rowId)
|
||||||
|
if err != nil {
|
||||||
|
utils.TriggerToast(w, r, "error", "Invalid ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.service.DeleteWorkout(user, rowIdInt)
|
||||||
|
if err != nil {
|
||||||
|
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
input.css
Normal file
18
input.css
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@source './static/**/*.js';
|
||||||
|
@source './template/**/*.templ';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--animate-fade: fadeOut 0.25s ease-in;
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
56
log/default.go
Normal file
56
log/default.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
103
main.go
103
main.go
@@ -1,14 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"me-fit/handler"
|
"web-app-template/db"
|
||||||
"me-fit/utils"
|
"web-app-template/handler"
|
||||||
|
"web-app-template/handler/middleware"
|
||||||
|
"web-app-template/log"
|
||||||
|
"web-app-template/service"
|
||||||
|
"web-app-template/types"
|
||||||
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -20,40 +23,49 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
run(context.Background())
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(ctx context.Context) {
|
|
||||||
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
slog.Info("Starting server...")
|
|
||||||
|
|
||||||
// init env
|
|
||||||
err := godotenv.Load()
|
err := godotenv.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error loading .env file")
|
log.Fatal("Error loading .env file")
|
||||||
}
|
}
|
||||||
utils.MustInitEnv()
|
|
||||||
|
|
||||||
// init db
|
|
||||||
db, err := sql.Open("sqlite3", "./data.db")
|
db, err := sql.Open("sqlite3", "./data.db")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Could not open Database data.db: ", err)
|
log.Fatal("Could not open Database data.db: %v", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
utils.MustRunMigrations(db, "")
|
|
||||||
|
run(context.Background(), db, os.Getenv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, database *sql.DB, env func(string) string) {
|
||||||
|
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
log.Info("Starting server...")
|
||||||
|
|
||||||
|
// init server settings
|
||||||
|
serverSettings := types.NewSettingsFromEnv(env)
|
||||||
|
|
||||||
|
// init db
|
||||||
|
err := db.RunMigrations(database, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Could not run migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// init servers
|
// init servers
|
||||||
|
var prometheusServer *http.Server
|
||||||
|
if serverSettings.PrometheusEnabled {
|
||||||
prometheusServer := &http.Server{
|
prometheusServer := &http.Server{
|
||||||
Addr: ":8081",
|
Addr: ":8081",
|
||||||
Handler: promhttp.Handler(),
|
Handler: promhttp.Handler(),
|
||||||
}
|
}
|
||||||
httpServer := &http.Server{
|
|
||||||
Addr: ":8080",
|
|
||||||
Handler: handler.GetHandler(db),
|
|
||||||
}
|
|
||||||
go startServer(prometheusServer)
|
go startServer(prometheusServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: ":" + serverSettings.Port,
|
||||||
|
Handler: createHandler(database, serverSettings),
|
||||||
|
}
|
||||||
go startServer(httpServer)
|
go startServer(httpServer)
|
||||||
|
|
||||||
// graceful shutdown
|
// graceful shutdown
|
||||||
@@ -65,21 +77,60 @@ func run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startServer(s *http.Server) {
|
func startServer(s *http.Server) {
|
||||||
slog.Info("Starting server on " + s.Addr)
|
log.Info("Starting server on %q", s.Addr)
|
||||||
if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
slog.Error("error listening and serving: " + err.Error())
|
log.Error("error listening and serving: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func shutdownServer(s *http.Server, ctx context.Context, wg *sync.WaitGroup) {
|
func shutdownServer(s *http.Server, ctx context.Context, wg *sync.WaitGroup) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
shutdownCtx := context.Background()
|
shutdownCtx := context.Background()
|
||||||
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := s.Shutdown(shutdownCtx); err != nil {
|
if err := s.Shutdown(shutdownCtx); err != nil {
|
||||||
slog.Error("error shutting down http server: " + err.Error())
|
log.Error("error shutting down http server: %v", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("Gracefully stopped http server on " + s.Addr)
|
log.Info("Gracefully stopped http server on %v", 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
1746
main_test.go
Normal file
1746
main_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"me-fit/utils"
|
|
||||||
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func EnsureValidSession(db *sql.DB, next http.Handler) http.Handler {
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
user := utils.GetUserFromSession(db, r)
|
|
||||||
if user == nil {
|
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !user.EmailVerified && r.URL.Path != "/auth/verify" {
|
|
||||||
utils.DoRedirect(w, r, "/auth/verify")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), utils.ContextKeyUser, user)
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"me-fit/utils"
|
|
||||||
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func EnableCors(next http.Handler) http.Handler {
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", utils.BaseUrl)
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE")
|
|
||||||
|
|
||||||
if r.Method == "OPTIONS" {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,40 @@
|
|||||||
|
|
||||||
|
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 (
|
CREATE TABLE workout (
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
date TEXT NOT NULL,
|
date TEXT NOT NULL,
|
||||||
@@ -6,4 +42,3 @@ CREATE TABLE workout (
|
|||||||
sets INTEGER NOT NULL,
|
sets INTEGER NOT NULL,
|
||||||
reps INTEGER NOT NULL
|
reps INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
|
|
||||||
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;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
ALTER TABLE user ADD COLUMN email_verified_at DATETIME DEFAULT NULL;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
|
|
||||||
-- 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
|
|
||||||
);
|
|
||||||
1
mocks/default.go
Normal file
1
mocks/default.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package mocks
|
||||||
2092
package-lock.json
generated
2092
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -1,19 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "me-fit",
|
"name": "web-app-template",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Your (almost) independent tech stack to host on a VPC.",
|
"description": "Your (almost) independent tech stack to host on a VPC.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"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",
|
"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 build -o static/css/tailwind.css --watch",
|
"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"
|
||||||
"test": ""
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"htmx.org": "2.0.2",
|
"htmx.org": "2.0.4",
|
||||||
"tailwindcss": "3.4.13",
|
"tailwindcss": "4.0.3",
|
||||||
"daisyui": "4.12.10"
|
"@tailwindcss/cli": "4.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": [
|
"extends": [
|
||||||
"config:best-practices"
|
"local>x/renovate-config"
|
||||||
],
|
]
|
||||||
"packageRules": [{
|
|
||||||
"matchUpdateTypes": ["minor", "patch", "digest", "pinDigest"],
|
|
||||||
"matchCurrentVersion": "!/^0/",
|
|
||||||
"automerge": true
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
|
|||||||
1006
service/auth.go
1006
service/auth.go
File diff suppressed because it is too large
Load Diff
@@ -1,107 +1,138 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"me-fit/db"
|
"web-app-template/db"
|
||||||
"me-fit/types"
|
"web-app-template/mocks"
|
||||||
|
"web-app-template/types"
|
||||||
|
|
||||||
"errors"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DbAuthStub struct {
|
func TestSignUp(t *testing.T) {
|
||||||
user *db.User
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d DbAuthStub) GetUser(email string) (*db.User, error) {
|
|
||||||
return d.user, d.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSignIn(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
t.Run("should return user if password is correct", func(t *testing.T) {
|
t.Run("should check for correct email address", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
mockAuthDb := mocks.NewMockAuth(t)
|
||||||
|
mockRandom := mocks.NewMockRandom(t)
|
||||||
|
mockClock := mocks.NewMockClock(t)
|
||||||
|
mockMail := mocks.NewMockMail(t)
|
||||||
|
|
||||||
|
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
|
||||||
|
|
||||||
|
_, err := underTest.SignUp("invalid email address", "SomeStrongPassword123!")
|
||||||
|
|
||||||
|
assert.Equal(t, ErrInvalidEmail, err)
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
|
||||||
|
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
|
||||||
|
|
||||||
|
weakPasswords := []string{
|
||||||
|
"123!ab", // too short
|
||||||
|
"no_upper_case_123",
|
||||||
|
"NO_LOWER_CASE_123",
|
||||||
|
"noSpecialChar123",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, password := range weakPasswords {
|
||||||
|
_, err := underTest.SignUp("some@valid.email", password)
|
||||||
|
assert.Equal(t, ErrInvalidPassword, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
|
||||||
|
userId := uuid.New()
|
||||||
|
email := "mail@mail.de"
|
||||||
|
password := "SomeStrongPassword123!"
|
||||||
salt := []byte("salt")
|
salt := []byte("salt")
|
||||||
stub := DbAuthStub{
|
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
user: db.NewUser(
|
|
||||||
uuid.New(),
|
|
||||||
"test@test.de",
|
|
||||||
true,
|
|
||||||
time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC),
|
|
||||||
false,
|
|
||||||
getHashPassword("password", salt),
|
|
||||||
salt,
|
|
||||||
time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
||||||
),
|
|
||||||
err: nil,
|
|
||||||
}
|
|
||||||
underTest := NewServiceAuthImpl(stub)
|
|
||||||
|
|
||||||
actualUser, err := underTest.SignIn("test@test.de", "password")
|
expected := types.NewUser(userId, email, false, nil, false, GetHashPassword(password, salt), salt, createTime)
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Expected nil, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedUser := User{
|
mockRandom.EXPECT().UUID().Return(userId, nil)
|
||||||
Id: stub.user.Id,
|
mockRandom.EXPECT().Bytes(16).Return(salt, nil)
|
||||||
Email: stub.user.Email,
|
mockClock.EXPECT().Now().Return(createTime)
|
||||||
EmailVerified: stub.user.EmailVerified,
|
mockAuthDb.EXPECT().InsertUser(expected).Return(nil)
|
||||||
}
|
|
||||||
if *actualUser != expectedUser {
|
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
|
||||||
t.Errorf("Expected %v, got %v", expectedUser, actualUser)
|
actual, err := underTest.SignUp(email, password)
|
||||||
}
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, expected, actual)
|
||||||
})
|
})
|
||||||
|
t.Run("should return ErrAccountExists", func(t *testing.T) {
|
||||||
t.Run("should return ErrInvalidCretentials if password is not correct", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
mockAuthDb := mocks.NewMockAuth(t)
|
||||||
|
mockRandom := mocks.NewMockRandom(t)
|
||||||
|
mockClock := mocks.NewMockClock(t)
|
||||||
|
mockMail := mocks.NewMockMail(t)
|
||||||
|
|
||||||
|
userId := uuid.New()
|
||||||
|
email := "some@valid.email"
|
||||||
|
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
password := "SomeStrongPassword123!"
|
||||||
salt := []byte("salt")
|
salt := []byte("salt")
|
||||||
stub := DbAuthStub{
|
user := types.NewUser(userId, email, false, nil, false, GetHashPassword(password, salt), salt, createTime)
|
||||||
user: db.NewUser(
|
|
||||||
uuid.New(),
|
|
||||||
"test@test.de",
|
|
||||||
true,
|
|
||||||
time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC),
|
|
||||||
false,
|
|
||||||
getHashPassword("password", salt),
|
|
||||||
salt,
|
|
||||||
time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
||||||
),
|
|
||||||
err: nil,
|
|
||||||
}
|
|
||||||
underTest := NewServiceAuthImpl(stub)
|
|
||||||
|
|
||||||
_, err := underTest.SignIn("test@test.de", "wrong password")
|
mockRandom.EXPECT().UUID().Return(user.Id, nil)
|
||||||
if err != ErrInvaidCredentials {
|
mockRandom.EXPECT().Bytes(16).Return(salt, nil)
|
||||||
t.Errorf("Expected %v, got %v", ErrInvaidCredentials, err)
|
mockClock.EXPECT().Now().Return(createTime)
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("should return ErrInvalidCretentials if user has not been found", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
stub := DbAuthStub{
|
|
||||||
user: nil,
|
|
||||||
err: db.ErrUserNotFound,
|
|
||||||
}
|
|
||||||
underTest := NewServiceAuthImpl(stub)
|
|
||||||
|
|
||||||
_, err := underTest.SignIn("test", "test")
|
mockAuthDb.EXPECT().InsertUser(user).Return(db.ErrAlreadyExists)
|
||||||
if err != ErrInvaidCredentials {
|
|
||||||
t.Errorf("Expected %v, got %v", ErrInvaidCredentials, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("should forward ErrInternal on any other error", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
stub := DbAuthStub{
|
|
||||||
user: nil,
|
|
||||||
err: errors.New("Some error"),
|
|
||||||
}
|
|
||||||
underTest := NewServiceAuthImpl(stub)
|
|
||||||
|
|
||||||
_, err := underTest.SignIn("test", "test")
|
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
|
||||||
if err != types.ErrInternal {
|
|
||||||
t.Errorf("Expected %v, got %v", types.ErrInternal, err)
|
_, err := underTest.SignUp(user.Email, password)
|
||||||
}
|
assert.Equal(t, ErrAccountExists, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendVerificationMail(t *testing.T) {
|
||||||
|
|
||||||
|
t.Parallel()
|
||||||
|
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}
|
||||||
|
|
||||||
|
email := "some@email.de"
|
||||||
|
userId := uuid.New()
|
||||||
|
|
||||||
|
mockAuthDb := mocks.NewMockAuth(t)
|
||||||
|
mockRandom := mocks.NewMockRandom(t)
|
||||||
|
mockClock := mocks.NewMockClock(t)
|
||||||
|
mockMail := mocks.NewMockMail(t)
|
||||||
|
|
||||||
|
mockAuthDb.EXPECT().GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify).Return(tokens, nil)
|
||||||
|
|
||||||
|
mockMail.EXPECT().SendMail(email, "Welcome to web-app-template", mock.MatchedBy(func(message string) bool {
|
||||||
|
return strings.Contains(message, token.Token)
|
||||||
|
})).Return()
|
||||||
|
|
||||||
|
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
|
||||||
|
|
||||||
|
underTest.SendVerificationMail(userId, email)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
17
service/clock.go
Normal file
17
service/clock.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Clock interface {
|
||||||
|
Now() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClockImpl struct{}
|
||||||
|
|
||||||
|
func NewClockImpl() Clock {
|
||||||
|
return &ClockImpl{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClockImpl) Now() time.Time {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"me-fit/template"
|
|
||||||
"me-fit/utils"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
|
||||||
)
|
|
||||||
|
|
||||||
func HandleIndexAnd404(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := utils.GetUserFromSession(db, r)
|
|
||||||
|
|
||||||
var comp templ.Component = nil
|
|
||||||
userComp := UserInfoComp(user)
|
|
||||||
|
|
||||||
if r.URL.Path != "/" {
|
|
||||||
comp = template.Layout(template.NotFound(), userComp)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
} else {
|
|
||||||
comp = template.Layout(template.Index(), userComp)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
service/mail.go
Normal file
44
service/mail.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"web-app-template/log"
|
||||||
|
"web-app-template/types"
|
||||||
|
|
||||||
|
"fmt"
|
||||||
|
"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 MailImpl struct {
|
||||||
|
server *types.Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMailImpl(server *types.Settings) MailImpl {
|
||||||
|
return MailImpl{server: server}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
s := m.server.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
service/random_generator.go
Normal file
49
service/random_generator.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"web-app-template/log"
|
||||||
|
"web-app-template/types"
|
||||||
|
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Random interface {
|
||||||
|
Bytes(size int) ([]byte, error)
|
||||||
|
String(size int) (string, error)
|
||||||
|
UUID() (uuid.UUID, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RandomImpl struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRandomImpl() *RandomImpl {
|
||||||
|
return &RandomImpl{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RandomImpl) Bytes(size int) ([]byte, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error generating random bytes: %v", err)
|
||||||
|
return []byte{}, types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RandomImpl) 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) {
|
||||||
|
return uuid.NewRandom()
|
||||||
|
}
|
||||||
@@ -1,191 +1,128 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"web-app-template/db"
|
||||||
"me-fit/template"
|
"web-app-template/types"
|
||||||
"me-fit/template/workout"
|
|
||||||
"me-fit/utils"
|
|
||||||
|
|
||||||
"database/sql"
|
"errors"
|
||||||
"net/http"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleWorkoutPage(db *sql.DB) http.HandlerFunc {
|
type Workout interface {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
AddWorkout(user *types.User, workoutDto *WorkoutDto) (*WorkoutDto, error)
|
||||||
user := utils.GetUser(r)
|
DeleteWorkout(user *types.User, rowId int) error
|
||||||
if user == nil {
|
GetWorkouts(user *types.User) ([]*WorkoutDto, error)
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentDate := time.Now().Format("2006-01-02")
|
type WorkoutImpl struct {
|
||||||
inner := workout.WorkoutComp(currentDate)
|
db db.WorkoutDb
|
||||||
userComp := UserInfoComp(user)
|
random Random
|
||||||
err := template.Layout(inner, userComp).Render(r.Context(), w)
|
clock Clock
|
||||||
if err != nil {
|
mail Mail
|
||||||
utils.LogError("Failed to render workout page", err)
|
settings *types.Settings
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
}
|
||||||
}
|
|
||||||
|
func NewWorkoutImpl(db db.WorkoutDb, random Random, clock Clock, mail Mail, settings *types.Settings) Workout {
|
||||||
|
return WorkoutImpl{
|
||||||
|
db: db,
|
||||||
|
random: random,
|
||||||
|
clock: clock,
|
||||||
|
mail: mail,
|
||||||
|
settings: settings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleWorkoutNewComp(db *sql.DB) http.HandlerFunc {
|
type WorkoutDto struct {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
RowId string
|
||||||
user := utils.GetUser(r)
|
Date string
|
||||||
if user == nil {
|
Type string
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
Sets string
|
||||||
return
|
Reps string
|
||||||
}
|
}
|
||||||
|
|
||||||
var dateStr = r.FormValue("date")
|
func NewWorkoutDtoFromDb(workout *db.Workout) *WorkoutDto {
|
||||||
var typeStr = r.FormValue("type")
|
return &WorkoutDto{
|
||||||
var setsStr = r.FormValue("sets")
|
RowId: strconv.Itoa(workout.RowId),
|
||||||
var repsStr = r.FormValue("reps")
|
Date: renderDate(workout.Date),
|
||||||
|
Type: workout.Type,
|
||||||
if dateStr == "" || typeStr == "" || setsStr == "" || repsStr == "" {
|
Sets: strconv.Itoa(workout.Sets),
|
||||||
utils.TriggerToast(w, r, "error", "Missing required fields")
|
Reps: strconv.Itoa(workout.Reps),
|
||||||
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
date, err := time.Parse("2006-01-02", dateStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.TriggerToast(w, r, "error", "Invalid date")
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sets, err := strconv.Atoi(setsStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.TriggerToast(w, r, "error", "Invalid number")
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reps, err := strconv.Atoi(repsStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.TriggerToast(w, r, "error", "Invalid number")
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var rowId int
|
|
||||||
err = db.QueryRow("INSERT INTO workout (user_id, date, type, sets, reps) VALUES (?, ?, ?, ?, ?) RETURNING rowid", user.Id, date, typeStr, sets, reps).Scan(&rowId)
|
|
||||||
if err != nil {
|
|
||||||
utils.LogError("Could not insert workout", err)
|
|
||||||
utils.TriggerToast(w, r, "error", "Internal Server Error")
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
wo := workout.Workout{
|
|
||||||
Id: strconv.Itoa(rowId),
|
|
||||||
Date: renderDate(date),
|
|
||||||
Type: r.FormValue("type"),
|
|
||||||
Sets: r.FormValue("sets"),
|
|
||||||
Reps: r.FormValue("reps"),
|
|
||||||
}
|
|
||||||
|
|
||||||
err = workout.WorkoutItemComp(wo, 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 NewWorkoutDto(rowId string, date string, workoutType string, sets string, reps string) *WorkoutDto {
|
||||||
|
return &WorkoutDto{
|
||||||
|
RowId: rowId,
|
||||||
|
Date: date,
|
||||||
|
Type: workoutType,
|
||||||
|
Sets: sets,
|
||||||
|
Reps: reps,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleWorkoutGetComp(db *sql.DB) http.HandlerFunc {
|
var (
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
ErrInputValues = errors.New("invalid input values")
|
||||||
user := utils.GetUser(r)
|
)
|
||||||
if user == nil {
|
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
func (service WorkoutImpl) AddWorkout(user *types.User, workoutDto *WorkoutDto) (*WorkoutDto, error) {
|
||||||
return
|
|
||||||
|
if workoutDto.Date == "" || workoutDto.Type == "" || workoutDto.Sets == "" || workoutDto.Reps == "" {
|
||||||
|
return nil, ErrInputValues
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := db.Query("SELECT rowid, date, type, sets, reps FROM workout WHERE user_id = ? ORDER BY date desc", user.Id)
|
date, err := time.Parse("2006-01-02", workoutDto.Date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.LogError("Could not get workouts", err)
|
return nil, ErrInputValues
|
||||||
utils.TriggerToast(w, r, "error", "Internal Server Error")
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var workouts = make([]workout.Workout, 0)
|
sets, err := strconv.Atoi(workoutDto.Sets)
|
||||||
for rows.Next() {
|
|
||||||
var workout workout.Workout
|
|
||||||
|
|
||||||
err = rows.Scan(&workout.Id, &workout.Date, &workout.Type, &workout.Sets, &workout.Reps)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.LogError("Could not scan workout", err)
|
return nil, ErrInputValues
|
||||||
utils.TriggerToast(w, r, "error", "Internal Server Error")
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
workout.Date, err = renderDateStr(workout.Date)
|
reps, err := strconv.Atoi(workoutDto.Reps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.LogError("Could not render date", err)
|
return nil, ErrInputValues
|
||||||
utils.TriggerToast(w, r, "error", "Internal Server Error")
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
workouts = append(workouts, workout)
|
workoutInsert := db.NewWorkoutInsert(date, workoutDto.Type, sets, reps)
|
||||||
|
|
||||||
|
workout, err := service.db.InsertWorkout(user.Id, workoutInsert)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
workout.WorkoutListComp(workouts).Render(r.Context(), w)
|
return NewWorkoutDtoFromDb(workout), nil
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleWorkoutDeleteComp(db *sql.DB) http.HandlerFunc {
|
func (service WorkoutImpl) DeleteWorkout(user *types.User, rowId int) error {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := utils.GetUser(r)
|
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.DoRedirect(w, r, "/auth/signin")
|
return types.ErrInternal
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rowId := r.PathValue("id")
|
return service.db.DeleteWorkout(user.Id, rowId)
|
||||||
if rowId == "" {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := db.Exec("DELETE FROM workout WHERE user_id = ? AND rowid = ?", user.Id, rowId)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
utils.LogError("Could not delete workout", err)
|
|
||||||
utils.TriggerToast(w, r, "error", "Internal Server Error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := res.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
utils.LogError("Could not get rows affected", err)
|
|
||||||
utils.TriggerToast(w, r, "error", "Internal Server Error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if rows == 0 {
|
|
||||||
http.Error(w, "Not found", http.StatusNotFound)
|
|
||||||
slog.Warn("Could not find workout to delete")
|
|
||||||
utils.TriggerToast(w, r, "error", "Not found. Refresh the page.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderDateStr(date string) (string, error) {
|
func (service WorkoutImpl) GetWorkouts(user *types.User) ([]*WorkoutDto, error) {
|
||||||
t, err := time.Parse("2006-01-02 15:04:05-07:00", date)
|
if user == nil {
|
||||||
if err != nil {
|
return nil, types.ErrInternal
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderDate(t), nil
|
workouts, err := service.db.GetWorkouts(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// for _, workout := range workouts {
|
||||||
|
// workout.Date = renderDate(workout.Date)
|
||||||
|
// }
|
||||||
|
|
||||||
|
workoutsDto := make([]*WorkoutDto, len(workouts))
|
||||||
|
for i, workout := range workouts {
|
||||||
|
workoutsDto[i] = NewWorkoutDtoFromDb(&workout)
|
||||||
|
}
|
||||||
|
|
||||||
|
return workoutsDto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderDate(date time.Time) string {
|
func renderDate(date time.Time) string {
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
/** @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"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ templ ChangePasswordComp(isPasswordReset bool) {
|
|||||||
<form
|
<form
|
||||||
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
|
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
|
||||||
if isPasswordReset {
|
if isPasswordReset {
|
||||||
hx-post="/api/auth/reset-password-actual"
|
hx-post="/api/auth/forgot-password-actual"
|
||||||
} else {
|
} else {
|
||||||
hx-post="/api/auth/change-password"
|
hx-post="/api/auth/change-password"
|
||||||
}
|
}
|
||||||
@@ -15,11 +15,29 @@ templ ChangePasswordComp(isPasswordReset bool) {
|
|||||||
</h2>
|
</h2>
|
||||||
if !isPasswordReset {
|
if !isPasswordReset {
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<input type="password" class="grow" placeholder="Current Password" name="current-password"/>
|
<input
|
||||||
|
type="password"
|
||||||
|
class="grow"
|
||||||
|
placeholder="Current Password"
|
||||||
|
name="current-password"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
}
|
}
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<input type="password" class="grow" placeholder="New Password" name="new-password"/>
|
<input
|
||||||
|
type="password"
|
||||||
|
class="grow"
|
||||||
|
placeholder="New Password"
|
||||||
|
name="new-password"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-primary self-end">
|
<button class="btn btn-primary self-end">
|
||||||
Change Password
|
Change Password
|
||||||
|
|||||||
@@ -12,10 +12,19 @@ templ DeleteAccountComp() {
|
|||||||
<p class="text-xl text-red-500 mb-4">
|
<p class="text-xl text-red-500 mb-4">
|
||||||
Are you sure you want to delete your account? This action is irreversible.
|
Are you sure you want to delete your account? This action is irreversible.
|
||||||
</p>
|
</p>
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input type="password" class="grow" placeholder="Password" name="password"/>
|
<input
|
||||||
|
type="password"
|
||||||
|
class="grow"
|
||||||
|
placeholder="Password"
|
||||||
|
name="password"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-error self-end">
|
<button class="self-end">
|
||||||
Delete Account
|
Delete Account
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,14 +3,23 @@ package auth
|
|||||||
templ ResetPasswordComp() {
|
templ ResetPasswordComp() {
|
||||||
<form
|
<form
|
||||||
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
|
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
|
||||||
hx-post="/api/auth/reset-password"
|
hx-post="/api/auth/forgot-password"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
>
|
>
|
||||||
<h2 class="text-6xl mb-10">
|
<h2 class="text-6xl mb-10">
|
||||||
Reset Password
|
Reset Password
|
||||||
</h2>
|
</h2>
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<input type="email" class="grow" placeholder="E-Mail" name="email"/>
|
<input
|
||||||
|
type="email"
|
||||||
|
class="grow"
|
||||||
|
placeholder="E-Mail"
|
||||||
|
name="email"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-primary self-end">
|
<button class="btn btn-primary self-end">
|
||||||
Request Password Reset
|
Request Password Reset
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
templ SignInOrUpComp(isSignIn bool) {
|
templ SignInOrUpComp(isSignIn bool) {
|
||||||
|
{{
|
||||||
|
var postUrl string
|
||||||
|
if isSignIn {
|
||||||
|
postUrl = "/api/auth/signin"
|
||||||
|
} else {
|
||||||
|
postUrl = "/api/auth/signup"
|
||||||
|
}
|
||||||
|
}}
|
||||||
<form
|
<form
|
||||||
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
|
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-target="#sign-in-or-up-error"
|
||||||
if isSignIn {
|
hx-post={ postUrl }
|
||||||
hx-post="/api/auth/signin"
|
|
||||||
} else {
|
|
||||||
hx-post="/api/auth/signup"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<h2 class="text-6xl mb-10">
|
<h2 class="text-6xl mb-10">
|
||||||
if isSignIn {
|
if isSignIn {
|
||||||
@@ -18,12 +22,7 @@ templ SignInOrUpComp(isSignIn bool) {
|
|||||||
}
|
}
|
||||||
</h2>
|
</h2>
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-4 w-4 opacity-70">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="h-4 w-4 opacity-70"
|
|
||||||
>
|
|
||||||
<path
|
<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"
|
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>
|
></path>
|
||||||
@@ -31,26 +30,39 @@ templ SignInOrUpComp(isSignIn bool) {
|
|||||||
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"
|
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>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<input type="text" class="grow" placeholder="Email" name="email"/>
|
<input
|
||||||
|
type="text"
|
||||||
|
class="grow"
|
||||||
|
placeholder="Email"
|
||||||
|
name="email"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-4 w-4 opacity-70">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="h-4 w-4 opacity-70"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
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"
|
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"
|
clip-rule="evenodd"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<input type="password" class="grow" placeholder="Password" name="password"/>
|
<input
|
||||||
|
type="password"
|
||||||
|
class="grow"
|
||||||
|
placeholder="Password"
|
||||||
|
name="password"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex justify-end items-center gap-2">
|
<div class="flex justify-end items-center gap-2">
|
||||||
if isSignIn {
|
if isSignIn {
|
||||||
<a href="/auth/reset-password" class="grow link text-gray-500 text-sm">Forgot Password?</a>
|
<a href="/auth/forgot-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>
|
<a href="/auth/signup" class="link text-gray-500 text-sm">Don't have an account? Sign Up</a>
|
||||||
<button class="btn btn-primary">
|
<button class="btn btn-primary">
|
||||||
Sign In
|
Sign In
|
||||||
|
|||||||
@@ -3,36 +3,28 @@ package auth
|
|||||||
templ UserComp(user string) {
|
templ UserComp(user string) {
|
||||||
<div id="user-info" class="flex gap-5 items-center">
|
<div id="user-info" class="flex gap-5 items-center">
|
||||||
if user != "" {
|
if user != "" {
|
||||||
<div class="group inline-block relative">
|
<div class="inline-block relative">
|
||||||
<button
|
<button class="font-semibold py-2 px-4 inline-flex items-center">
|
||||||
class="font-semibold py-2 px-4 inline-flex items-center"
|
|
||||||
>
|
|
||||||
<span class="mr-1">{ user }</span>
|
<span class="mr-1">{ user }</span>
|
||||||
<svg
|
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||||
class="fill-current h-4 w-4"
|
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"></path>
|
||||||
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="absolute hidden group-hover:block w-full">
|
<div class="absolute hidden group-hover:block w-full">
|
||||||
<ul class="menu bg-base-300 rounded-box w-fit float-right mr-4 p-3">
|
<ul class="w-fit float-right mr-4 p-3">
|
||||||
<li class="mb-1">
|
<li class="mb-1">
|
||||||
<a hx-get="/api/auth/signout" hx-target="#user-info">Sign Out</a>
|
<a hx-post="/api/auth/signout" hx-target="#user-info">Sign Out</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="mb-1">
|
<li class="mb-1">
|
||||||
<a href="/auth/change-password">Change Password</a>
|
<a href="/auth/change-password">Change Password</a>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="/auth/delete-account" class="text-error">Delete Account</a></li>
|
<li><a href="/auth/delete-account" class="">Delete Account</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<a href="/auth/signup" class="btn btn-sm">Sign Up</a>
|
<a href="/auth/signup" class="">Sign Up</a>
|
||||||
<a href="/auth/signin" class="btn btn-sm">Sign In</a>
|
<a href="/auth/signin" class="">Sign In</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ templ VerifyComp() {
|
|||||||
<p class="text-lg text-center">
|
<p class="text-lg text-center">
|
||||||
Please check your inbox/spam and click on the link to verify your account.
|
Please check your inbox/spam and click on the link to verify your account.
|
||||||
</p>
|
</p>
|
||||||
<button class="btn mt-8" hx-get="/api/auth/verify-resend" hx-sync="this:drop" hx-swap="outerHTML">
|
<button class="mt-8" hx-get="/api/auth/verify-resend" hx-sync="this:drop" hx-swap="outerHTML">
|
||||||
resend verification email
|
resend verification email
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
29
template/auth/verify_response.templ
Normal file
29
template/auth/verify_response.templ
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
package template
|
package template
|
||||||
|
|
||||||
templ Index() {
|
templ Index() {
|
||||||
<div class="hero bg-base-200 h-full">
|
<div class="h-full">
|
||||||
<div class="hero-content text-center">
|
<div class="text-center">
|
||||||
<div class="max-w-md">
|
<div class="max-w-md">
|
||||||
<h1 class="text-5xl font-bold">Next Level Workout Tracker</h1>
|
<h1 class="text-5xl font-bold">Next Level Workout Tracker</h1>
|
||||||
<p class="py-6">
|
<p class="py-6">
|
||||||
Ever wanted to track your workouts and see your progress over time? ME-FIT is the perfect
|
Ever wanted to track your workouts and see your progress over time? web-app-template is the perfect
|
||||||
solution for you.
|
solution for you.
|
||||||
</p>
|
</p>
|
||||||
<a href="/workout" class="btn btn-primary">Get Started</a>
|
<a href="/workout" class="">Get Started</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
package template
|
package template
|
||||||
|
|
||||||
import "me-fit/utils"
|
|
||||||
|
|
||||||
templ Layout(slot templ.Component, user templ.Component) {
|
templ Layout(slot templ.Component, user templ.Component) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<title>ME-FIT</title>
|
<title>web-app-template</title>
|
||||||
<link rel="icon" href="/static/favicon.svg"/>
|
<link rel="icon" href="/static/favicon.svg"/>
|
||||||
<link rel="stylesheet" href="/static/css/tailwind.css"/>
|
<link rel="stylesheet" href="/static/css/tailwind.css"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
if utils.Environment == "prod" {
|
<meta
|
||||||
<script defer src="https://umami.me-fit.eu/script.js" data-website-id="3c8efb09-44e4-4372-8a1e-c3bc675cd89a"></script>
|
name="htmx-config"
|
||||||
}
|
content='{
|
||||||
|
"includeIndicatorStyles": false,
|
||||||
|
"selfRequestsOnly": true,
|
||||||
|
"allowScriptTags": false
|
||||||
|
}'
|
||||||
|
/>
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
<script src="/static/js/toast.js"></script>
|
<script src="/static/js/toast.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body hx-headers='{"csrf-token": "CSRF_TOKEN"}'>
|
||||||
<div class="h-screen flex flex-col">
|
<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">
|
<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">
|
||||||
<a href="/" class="flex-1 flex gap-2">
|
<a href="/" class="flex-1 flex gap-2">
|
||||||
<img src="/static/favicon.svg" alt="ME-FIT logo"/>
|
<img src="/static/favicon.svg" alt="web-app-template logo"/>
|
||||||
<span>ME-FIT</span>
|
<span>web-app-template</span>
|
||||||
</a>
|
</a>
|
||||||
@user
|
@user
|
||||||
</div>
|
</div>
|
||||||
@@ -32,8 +35,8 @@ templ Layout(slot templ.Component, user templ.Component) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toasts">
|
<div class="" id="toasts">
|
||||||
<div class="hidden alert" id="toast">
|
<div class="hidden" id="toast">
|
||||||
New message arrived.
|
New message arrived.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
package mail;
|
package mail;
|
||||||
|
|
||||||
import (
|
import "net/url"
|
||||||
"me-fit/utils"
|
|
||||||
"net/url"
|
|
||||||
)
|
|
||||||
|
|
||||||
templ Register(token string) {
|
templ Register(baseUrl string, token string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8"/>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Welcome</title>
|
<title>Welcome</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
|
||||||
|
<body>
|
||||||
<h4>Thank you for Sign Up!</h4>
|
<h4>Thank you for Sign Up!</h4>
|
||||||
<p>Click <a href={ templ.URL(utils.BaseUrl + "/auth/verify-email?token=" + url.QueryEscape(token)) }>here</a> to verify your account.</p>
|
<p>Click <a href={ templ.URL(baseUrl + "/auth/verify-email?token=" + url.QueryEscape(token)) }>here</a> to finalize
|
||||||
|
your registration.</p>
|
||||||
<p>Kind regards</p>
|
<p>Kind regards</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
package mail;
|
package mail;
|
||||||
|
|
||||||
import (
|
import "net/url"
|
||||||
"me-fit/utils"
|
|
||||||
"net/url"
|
|
||||||
)
|
|
||||||
|
|
||||||
templ ResetPassword(token string) {
|
templ ResetPassword(baseUrl string, token string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -15,7 +12,7 @@ templ ResetPassword(token string) {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h4>Reset your password</h4>
|
<h4>Reset your password</h4>
|
||||||
<p>Click <a href={ templ.URL(utils.BaseUrl + "/auth/change-password?token=" + url.QueryEscape(token)) }>here</a> to change your password.</p>
|
<p>Click <a href={ templ.URL(baseUrl + "/auth/change-password?token=" + url.QueryEscape(token)) }>here</a> to change your password.</p>
|
||||||
<p>Kind regards</p>
|
<p>Kind regards</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package template
|
package template
|
||||||
|
|
||||||
templ NotFound() {
|
templ NotFound() {
|
||||||
<main class="flex h-full justify-center items-center ">
|
<main class="flex h-full justify-center items-center">
|
||||||
<div class="bg-error p-16 rounded-lg">
|
<div class="p-16 rounded-lg">
|
||||||
<h1 class="text-4xl text-error-content mb-5">Not Found</h1>
|
<h1 class="text-4xl mb-5">Not Found</h1>
|
||||||
<p class="text-lg text-error-content mb-5">The page you are looking for does not exist.</p>
|
<p class="text-lg 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>
|
<a href="/" class="">Go back to home</a>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,30 +9,14 @@ templ WorkoutComp(currentDate string) {
|
|||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<h2 class="text-4xl mb-8">Track your workout</h2>
|
<h2 class="text-4xl mb-8">Track your workout</h2>
|
||||||
<input
|
<input id="date" type="date" class="" value={ currentDate } name="date"/>
|
||||||
id="date"
|
<select class="w-full" name="type">
|
||||||
type="date"
|
|
||||||
class="input input-bordered"
|
|
||||||
value={ currentDate }
|
|
||||||
name="date"
|
|
||||||
/>
|
|
||||||
<select class="select select-bordered w-full" name="type">
|
|
||||||
<option>Push Ups</option>
|
<option>Push Ups</option>
|
||||||
<option>Pull Ups</option>
|
<option>Pull Ups</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input type="number" class="" placeholder="Sets" name="sets"/>
|
||||||
type="number"
|
<input type="number" class="" placeholder="Reps" name="reps"/>
|
||||||
class="input input-bordered"
|
<button class="self-end">Save</button>
|
||||||
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>
|
</form>
|
||||||
<div hx-get="/api/workout" hx-trigger="load"></div>
|
<div hx-get="/api/workout" hx-trigger="load"></div>
|
||||||
</main>
|
</main>
|
||||||
@@ -47,7 +31,7 @@ type Workout struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ WorkoutListComp(workouts []Workout) {
|
templ WorkoutListComp(workouts []Workout) {
|
||||||
<div class="overflow-x-auto mx-auto max-w-screen-lg">
|
<div class="overflow-x-auto mx-auto max-w-lg">
|
||||||
<h2 class="text-4xl mt-14 mb-8">Workout history</h2>
|
<h2 class="text-4xl mt-14 mb-8">Workout history</h2>
|
||||||
<table class="table table-auto max-w-full">
|
<table class="table table-auto max-w-full">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -80,7 +64,7 @@ templ WorkoutItemComp(w Workout, includePlaceholder bool) {
|
|||||||
<th>{ w.Reps }</th>
|
<th>{ w.Reps }</th>
|
||||||
<th>
|
<th>
|
||||||
<div class="tooltip" data-tip="Delete Entry">
|
<div class="tooltip" data-tip="Delete Entry">
|
||||||
<button hx-delete={ "api/workout/" + w.Id } hx-target="closest tr">
|
<button hx-delete={ "api/workout/" + w.Id } hx-target="closest tr" type="submit">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
75
types/auth.go
Normal file
75
types/auth.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
84
types/settings.go
Normal file
84
types/settings.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"web-app-template/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Settings struct {
|
||||||
|
Port string
|
||||||
|
PrometheusEnabled bool
|
||||||
|
|
||||||
|
BaseUrl string
|
||||||
|
Environment string
|
||||||
|
Smtp *SmtpSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmtpSettings struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
User string
|
||||||
|
Pass string
|
||||||
|
FromMail string
|
||||||
|
FromName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSettingsFromEnv(env func(string) string) *Settings {
|
||||||
|
|
||||||
|
var smtp *SmtpSettings
|
||||||
|
if env("SMTP_ENABLED") == "true" {
|
||||||
|
smtp = &SmtpSettings{
|
||||||
|
Host: env("SMTP_HOST"),
|
||||||
|
Port: env("SMTP_PORT"),
|
||||||
|
User: env("SMTP_USER"),
|
||||||
|
Pass: env("SMTP_PASS"),
|
||||||
|
FromMail: env("SMTP_FROM_MAIL"),
|
||||||
|
FromName: env("SMTP_FROM_NAME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if smtp.Host == "" {
|
||||||
|
log.Fatal("SMTP_HOST must be set")
|
||||||
|
}
|
||||||
|
if smtp.Port == "" {
|
||||||
|
log.Fatal("SMTP_PORT must be set")
|
||||||
|
}
|
||||||
|
if smtp.User == "" {
|
||||||
|
log.Fatal("SMTP_USER must be set")
|
||||||
|
}
|
||||||
|
if smtp.Pass == "" {
|
||||||
|
log.Fatal("SMTP_PASS must be set")
|
||||||
|
}
|
||||||
|
if smtp.FromMail == "" {
|
||||||
|
log.Fatal("SMTP_FROM_MAIL must be set")
|
||||||
|
}
|
||||||
|
if smtp.FromName == "" {
|
||||||
|
log.Fatal("SMTP_FROM_NAME must be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := &Settings{
|
||||||
|
Port: env("PORT"),
|
||||||
|
PrometheusEnabled: env("PROMETHEUS_ENABLED") == "true",
|
||||||
|
BaseUrl: env("BASE_URL"),
|
||||||
|
Environment: env("ENVIRONMENT"),
|
||||||
|
Smtp: smtp,
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.BaseUrl == "" {
|
||||||
|
log.Fatal("BASE_URL must be set")
|
||||||
|
}
|
||||||
|
if settings.Port == "" {
|
||||||
|
log.Fatal("PORT must be set")
|
||||||
|
}
|
||||||
|
if settings.Environment == "" {
|
||||||
|
log.Fatal("ENVIRONMENT must be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.Environment == "prod" && (settings.Smtp == nil || !settings.PrometheusEnabled) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
}
|
||||||
@@ -2,17 +2,8 @@ package types
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInternal = errors.New("Internal server error")
|
ErrInternal = errors.New("internal server error")
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
|
||||||
Id uuid.UUID
|
|
||||||
Email string
|
|
||||||
SessionId string
|
|
||||||
EmailVerified bool
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RandomToken() (string, error) {
|
|
||||||
b := make([]byte, 32)
|
|
||||||
_, err := rand.Read(b)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return base64.StdEncoding.EncodeToString(b), nil
|
|
||||||
}
|
|
||||||
32
utils/db.go
32
utils/db.go
@@ -1,32 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/golang-migrate/migrate/v4"
|
|
||||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
|
||||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
|
||||||
)
|
|
||||||
|
|
||||||
func MustRunMigrations(db *sql.DB, pathPrefix string) {
|
|
||||||
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m, err := migrate.NewWithDatabaseInstance(
|
|
||||||
"file://"+pathPrefix+"migration/",
|
|
||||||
"",
|
|
||||||
driver)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Could not create migrations instance: ", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = m.Up()
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() != "no change" {
|
|
||||||
log.Fatal("Could not run migrations: ", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
57
utils/env.go
57
utils/env.go
@@ -1,57 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
SmtpHost string
|
|
||||||
SmtpPort string
|
|
||||||
SmtpUser string
|
|
||||||
SmtpPass string
|
|
||||||
SmtpFromMail string
|
|
||||||
SmtpFromName string
|
|
||||||
BaseUrl string
|
|
||||||
Environment string
|
|
||||||
)
|
|
||||||
|
|
||||||
func MustInitEnv() {
|
|
||||||
SmtpHost = os.Getenv("SMTP_HOST")
|
|
||||||
SmtpPort = os.Getenv("SMTP_PORT")
|
|
||||||
SmtpUser = os.Getenv("SMTP_USER")
|
|
||||||
SmtpPass = os.Getenv("SMTP_PASS")
|
|
||||||
SmtpFromMail = os.Getenv("SMTP_FROM_MAIL")
|
|
||||||
SmtpFromName = os.Getenv("SMTP_FROM_NAME")
|
|
||||||
BaseUrl = os.Getenv("BASE_URL")
|
|
||||||
Environment = os.Getenv("ENVIRONMENT")
|
|
||||||
|
|
||||||
if SmtpHost == "" {
|
|
||||||
log.Fatal("SMTP_HOST must be set")
|
|
||||||
}
|
|
||||||
if SmtpPort == "" {
|
|
||||||
log.Fatal("SMTP_PORT must be set")
|
|
||||||
}
|
|
||||||
if SmtpUser == "" {
|
|
||||||
log.Fatal("SMTP_USER must be set")
|
|
||||||
}
|
|
||||||
if SmtpPass == "" {
|
|
||||||
log.Fatal("SMTP_PASS must be set")
|
|
||||||
}
|
|
||||||
if SmtpFromMail == "" {
|
|
||||||
log.Fatal("SMTP_FROM_MAIL must be set")
|
|
||||||
}
|
|
||||||
if SmtpFromName == "" {
|
|
||||||
log.Fatal("SMTP_FROM_NAME must be set")
|
|
||||||
}
|
|
||||||
if BaseUrl == "" {
|
|
||||||
log.Fatal("BASE_URL must be set")
|
|
||||||
}
|
|
||||||
if Environment == "" {
|
|
||||||
log.Fatal("ENVIRONMENT must be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("BASE_URL is " + BaseUrl)
|
|
||||||
slog.Info("ENVIRONMENT is " + Environment)
|
|
||||||
}
|
|
||||||
@@ -1,46 +1,19 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"me-fit/types"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"web-app-template/log"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextKey string
|
func TriggerToast(w http.ResponseWriter, r *http.Request, class string, message string, statusCode int) {
|
||||||
|
|
||||||
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) {
|
if isHtmx(r) {
|
||||||
w.Header().Set("HX-Trigger", fmt.Sprintf(`{"toast": "%v|%v"}`, class, message))
|
w.Header().Set("HX-Trigger", fmt.Sprintf(`{"toast": "%v|%v"}`, class, message))
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
} else {
|
} else {
|
||||||
LogErrorMsg("Trying to trigger toast in non-HTMX request")
|
log.Error("Trying to trigger toast in non-HTMX request")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,44 +25,6 @@ func DoRedirect(w http.ResponseWriter, r *http.Request, url string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUser(r *http.Request) *types.User {
|
|
||||||
user := r.Context().Value(ContextKeyUser)
|
|
||||||
if user != nil {
|
|
||||||
return user.(*types.User)
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetUserFromSession(db *sql.DB, r *http.Request) *types.User {
|
|
||||||
sessionId := getSessionID(r)
|
|
||||||
if sessionId == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var user types.User
|
|
||||||
var createdAt time.Time
|
|
||||||
|
|
||||||
user.SessionId = sessionId
|
|
||||||
|
|
||||||
err := db.QueryRow(`
|
|
||||||
SELECT u.user_uuid, u.email, u.email_verified, s.created_at
|
|
||||||
FROM session s
|
|
||||||
INNER JOIN user u ON s.user_uuid = u.user_uuid
|
|
||||||
WHERE session_id = ?`, sessionId).Scan(&user.Id, &user.Email, &user.EmailVerified, &createdAt)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("Could not verify session: " + err.Error())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if createdAt.Add(time.Duration(8 * time.Hour)).Before(time.Now()) {
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
return &user
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func WaitMinimumTime[T interface{}](waitTime time.Duration, function func() (T, error)) (T, error) {
|
func WaitMinimumTime[T interface{}](waitTime time.Duration, function func() (T, error)) (T, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
result, err := function()
|
result, err := function()
|
||||||
@@ -97,15 +32,6 @@ func WaitMinimumTime[T interface{}](waitTime time.Duration, function func() (T,
|
|||||||
return result, err
|
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 {
|
func isHtmx(r *http.Request) bool {
|
||||||
return r.Header.Get("HX-Request") == "true"
|
return r.Header.Get("HX-Request") == "true"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/smtp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func SendMail(to string, subject string, message string) error {
|
|
||||||
|
|
||||||
auth := smtp.PlainAuth("", SmtpUser, SmtpPass, SmtpHost)
|
|
||||||
|
|
||||||
msg := fmt.Sprintf("From: %v <%v>\nTo: %v\nSubject: %v\nMIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n%v", SmtpFromName, SmtpFromMail, to, subject, message)
|
|
||||||
|
|
||||||
return smtp.SendMail(SmtpHost+":"+SmtpPort, auth, SmtpFromMail, []string{to}, []byte(msg))
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user